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,187 @@
|
|
|
1
|
+
"""User-defined extraction filters, stored under the top-level
|
|
2
|
+
``extraction_filters:`` key in ``configs/config.yml``.
|
|
3
|
+
|
|
4
|
+
Each filter is a dict with any subset of {ac_type, ac_owner, ac, dep,
|
|
5
|
+
arr, custom_header, custom_value}. Empty fields are skipped during
|
|
6
|
+
matching (AND semantics across specified fields, case-insensitive
|
|
7
|
+
exact). Writes splice the block in place so surrounding comments live.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .config_yaml import CONFIG_YML, config_write_lock
|
|
17
|
+
|
|
18
|
+
#: The fields a filter may specify. Anything else in a submitted dict is
|
|
19
|
+
#: dropped by _normalize_filter.
|
|
20
|
+
_FILTER_FIELDS = (
|
|
21
|
+
"ac_type", "ac_owner", "ac", "dep", "arr",
|
|
22
|
+
"custom_header", "custom_value",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_extraction_filters() -> list[dict[str, str]]:
|
|
29
|
+
"""Read current extraction filters from config.yml. Only returns
|
|
30
|
+
fields that have a non-empty value — pad-with-empties would
|
|
31
|
+
produce YAML `''` scalars on round-trip and corrupt the file."""
|
|
32
|
+
raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
|
|
33
|
+
filters = raw.get("extraction_filters") or []
|
|
34
|
+
out: list[dict[str, str]] = []
|
|
35
|
+
for f in filters:
|
|
36
|
+
if not isinstance(f, dict):
|
|
37
|
+
continue
|
|
38
|
+
entry: dict[str, str] = {}
|
|
39
|
+
for k in _FILTER_FIELDS:
|
|
40
|
+
v = f.get(k)
|
|
41
|
+
if v is None:
|
|
42
|
+
continue
|
|
43
|
+
sv = str(v).strip()
|
|
44
|
+
if sv:
|
|
45
|
+
entry[k] = sv
|
|
46
|
+
if entry:
|
|
47
|
+
out.append(entry)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_filter(filt: dict[str, str]) -> dict[str, str]:
|
|
52
|
+
"""Strip whitespace and uppercase the short codes (airport / type).
|
|
53
|
+
Custom header/value are stripped only (case preserved for header
|
|
54
|
+
since that needs to match the SV-portal column header exactly).
|
|
55
|
+
"""
|
|
56
|
+
n: dict[str, str] = {}
|
|
57
|
+
for key in _FILTER_FIELDS:
|
|
58
|
+
v = str(filt.get(key) or "").strip()
|
|
59
|
+
if not v:
|
|
60
|
+
continue
|
|
61
|
+
if key in ("ac_type", "ac_owner", "ac", "dep", "arr"):
|
|
62
|
+
n[key] = v.upper()
|
|
63
|
+
else:
|
|
64
|
+
n[key] = v
|
|
65
|
+
if not n:
|
|
66
|
+
raise ValueError("filter has no fields set — at least one of "
|
|
67
|
+
f"{_FILTER_FIELDS} must be non-empty")
|
|
68
|
+
# If only one of custom_header / custom_value is set, that's a
|
|
69
|
+
# config error — both required together.
|
|
70
|
+
if ("custom_header" in n) ^ ("custom_value" in n):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"custom_header and custom_value must be provided together"
|
|
73
|
+
)
|
|
74
|
+
return n
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _yaml_scalar(v: str) -> str:
|
|
78
|
+
"""Render a string scalar safely for inline YAML. Plain strings
|
|
79
|
+
are written as-is; if the value contains characters YAML would
|
|
80
|
+
interpret (`:`, `#`, `&`, `*`, leading `-`, etc.) we quote it.
|
|
81
|
+
yaml.safe_dump is NOT used here because it appends a `\n...\n`
|
|
82
|
+
document-end marker for scalars, which corrupts the splice.
|
|
83
|
+
"""
|
|
84
|
+
s = str(v)
|
|
85
|
+
needs_quote = (
|
|
86
|
+
s == ""
|
|
87
|
+
or any(c in s for c in ":#&*!|>%@`")
|
|
88
|
+
or s[0] in "-?,[]{}"
|
|
89
|
+
or s.lower() in ("yes", "no", "true", "false", "null", "~", "on", "off")
|
|
90
|
+
)
|
|
91
|
+
if needs_quote:
|
|
92
|
+
# Single-quoted YAML: escape any internal single quotes by doubling.
|
|
93
|
+
escaped = s.replace("'", "''")
|
|
94
|
+
return f"'{escaped}'"
|
|
95
|
+
return s
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render_filters_block(filters: list[dict[str, str]]) -> str:
|
|
99
|
+
"""Format a list of filter dicts as YAML under the top-level
|
|
100
|
+
``extraction_filters:`` key. Always terminates with a newline.
|
|
101
|
+
"""
|
|
102
|
+
if not filters:
|
|
103
|
+
return "extraction_filters: []\n"
|
|
104
|
+
out = "extraction_filters:\n"
|
|
105
|
+
for entry in filters:
|
|
106
|
+
lines = [f"{k}: {_yaml_scalar(v)}" for k, v in entry.items()]
|
|
107
|
+
out += f" - {lines[0]}\n"
|
|
108
|
+
for ln in lines[1:]:
|
|
109
|
+
out += f" {ln}\n"
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _splice_filters_into_config(filters: list[dict[str, str]]) -> None:
|
|
114
|
+
"""Write ``filters`` back into ``configs/config.yml``'s
|
|
115
|
+
``extraction_filters:`` section, leaving every other byte of the
|
|
116
|
+
file intact (comments, whitespace, order)."""
|
|
117
|
+
raw_text = CONFIG_YML.read_text(encoding="utf-8")
|
|
118
|
+
new_block = _render_filters_block(filters).rstrip("\n") + "\n"
|
|
119
|
+
# Match the extraction_filters key + its current value (whether
|
|
120
|
+
# `[]`, `:` followed by a list, or just `:` followed by a blank).
|
|
121
|
+
# Use ^extraction_filters\s*: as anchor; consume up to (but not
|
|
122
|
+
# including) the next top-level key OR end of file.
|
|
123
|
+
pattern = re.compile(
|
|
124
|
+
r"^extraction_filters\s*:[^\n]*\n(?:[ \t-].*\n)*",
|
|
125
|
+
re.MULTILINE,
|
|
126
|
+
)
|
|
127
|
+
if pattern.search(raw_text):
|
|
128
|
+
new_text = pattern.sub(new_block, raw_text, count=1)
|
|
129
|
+
else:
|
|
130
|
+
# Defensive — config.yml ships with the key, so this branch
|
|
131
|
+
# is for upgrades where it was missing.
|
|
132
|
+
new_text = raw_text.rstrip() + "\n\n" + new_block
|
|
133
|
+
CONFIG_YML.write_text(new_text, encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def add_extraction_filter(filt: dict[str, str]) -> dict[str, str]:
|
|
137
|
+
"""Validate and append one extraction filter to config.yml.
|
|
138
|
+
Returns the normalised dict that was written. Idempotent — a
|
|
139
|
+
duplicate filter is a no-op (same dict returned)."""
|
|
140
|
+
norm = _normalize_filter(filt)
|
|
141
|
+
with config_write_lock:
|
|
142
|
+
existing = list_extraction_filters()
|
|
143
|
+
# Idempotent: skip duplicates (compare non-empty fields only).
|
|
144
|
+
for e in existing:
|
|
145
|
+
if {k: v for k, v in e.items() if v} == norm:
|
|
146
|
+
return norm
|
|
147
|
+
existing.append(norm)
|
|
148
|
+
_splice_filters_into_config(existing)
|
|
149
|
+
return norm
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def remove_extraction_filter(idx: int) -> dict[str, str]:
|
|
153
|
+
"""Remove the filter at 1-based index ``idx``. Returns the removed
|
|
154
|
+
filter dict. Raises IndexError if out of range."""
|
|
155
|
+
with config_write_lock:
|
|
156
|
+
existing = list_extraction_filters()
|
|
157
|
+
if idx < 1 or idx > len(existing):
|
|
158
|
+
raise IndexError(
|
|
159
|
+
f"extraction_filter index {idx} out of range (1..{len(existing)})"
|
|
160
|
+
)
|
|
161
|
+
removed = existing.pop(idx - 1)
|
|
162
|
+
_splice_filters_into_config(existing)
|
|
163
|
+
return removed
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def update_extraction_filter(
|
|
167
|
+
idx: int, new_filter: dict[str, str],
|
|
168
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
169
|
+
"""Replace the filter at 1-based index ``idx`` with ``new_filter``
|
|
170
|
+
atomically (single config write). Returns (old, new) so the caller
|
|
171
|
+
can render a diff. Raises IndexError if out of range, ValueError
|
|
172
|
+
if the new filter is invalid (e.g. all fields empty)."""
|
|
173
|
+
norm = _normalize_filter(new_filter)
|
|
174
|
+
with config_write_lock:
|
|
175
|
+
existing = list_extraction_filters()
|
|
176
|
+
if idx < 1 or idx > len(existing):
|
|
177
|
+
raise IndexError(
|
|
178
|
+
f"extraction_filter index {idx} out of range (1..{len(existing)})"
|
|
179
|
+
)
|
|
180
|
+
old = existing[idx - 1]
|
|
181
|
+
if {k: v for k, v in old.items() if v} == norm:
|
|
182
|
+
return old, norm # idempotent — no write needed
|
|
183
|
+
existing[idx - 1] = norm
|
|
184
|
+
_splice_filters_into_config(existing)
|
|
185
|
+
return old, norm
|
|
186
|
+
|
|
187
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Override-row mutation: the drawer's table rows, the recommender's
|
|
2
|
+
one-click fixes, and the staged add/remove forms.
|
|
3
|
+
|
|
4
|
+
Override rows live in ``state.STATE.overrides`` as dicts keyed by the
|
|
5
|
+
column names in ``state.OVERRIDE_HEADERS``. Each row type uses its own
|
|
6
|
+
subset of those keys (a ``p2f`` row cares about employee + shift; a
|
|
7
|
+
``waive_h10_pair`` row about flight + std + other_std), so nothing here
|
|
8
|
+
validates the shape — the reader for each type does that at solve time,
|
|
9
|
+
exactly as it would for a row typed in by hand.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ...state import OVERRIDE_HEADERS, OVERRIDE_HEADERS_VISIBLE, AppState
|
|
15
|
+
|
|
16
|
+
# Which columns each recommender "kind" must supply.
|
|
17
|
+
_PHASE_R_PAYLOAD_COLS = {
|
|
18
|
+
"waive_h10_pair": ("flight", "std", "employee", "other_std"),
|
|
19
|
+
"raise_cap": ("flight", "std", "employee"),
|
|
20
|
+
"skip_p2f_buffer": ("flight", "std", "employee"),
|
|
21
|
+
"skip_intl_removal": ("flight", "std"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_override(state: AppState, values: list[str]) -> int:
|
|
26
|
+
"""Append a drawer row. ``values`` are positional against
|
|
27
|
+
``OVERRIDE_HEADERS_VISIBLE``. Returns the new row's index."""
|
|
28
|
+
row = {
|
|
29
|
+
h: v for h, v in zip(OVERRIDE_HEADERS_VISIBLE, values)
|
|
30
|
+
if str(v).strip()
|
|
31
|
+
}
|
|
32
|
+
return state.add_override(row)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def update_override(state: AppState, index: int, values: list[str]) -> None:
|
|
36
|
+
"""Overwrite the visible cells of row ``index``, preserving any
|
|
37
|
+
type-specific fields (Phase-R flight / std) already on the row."""
|
|
38
|
+
with state.lock:
|
|
39
|
+
if not 0 <= index < len(state.overrides):
|
|
40
|
+
raise IndexError(f"no override row at index {index}")
|
|
41
|
+
merged = dict(state.overrides[index])
|
|
42
|
+
for h, v in zip(OVERRIDE_HEADERS_VISIBLE, values):
|
|
43
|
+
s = str(v).strip()
|
|
44
|
+
if s:
|
|
45
|
+
merged[h] = s
|
|
46
|
+
else:
|
|
47
|
+
merged.pop(h, None)
|
|
48
|
+
state.update_override(index, merged)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def delete_override(state: AppState, index: int) -> None:
|
|
52
|
+
state.delete_override(index)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear_all_overrides(state: AppState) -> int:
|
|
56
|
+
"""Drop every override row. Returns the count removed.
|
|
57
|
+
|
|
58
|
+
A fresh Plan resets the list so leftover nominations from the
|
|
59
|
+
previous cycle don't silently shadow the new roster's handler picks.
|
|
60
|
+
"""
|
|
61
|
+
with state.lock:
|
|
62
|
+
n = len(state.overrides)
|
|
63
|
+
state.overrides = []
|
|
64
|
+
return n
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_phase_r_override(
|
|
68
|
+
state: AppState, kind: str, payload: dict[str, str],
|
|
69
|
+
) -> int:
|
|
70
|
+
"""Write a Phase-R recommender suggestion as an override row.
|
|
71
|
+
|
|
72
|
+
``kind`` is one of waive_h10_pair / raise_cap / skip_p2f_buffer /
|
|
73
|
+
skip_intl_removal. Returns the new row's index. Raises ValueError on
|
|
74
|
+
an unknown kind and KeyError when a required field is missing.
|
|
75
|
+
"""
|
|
76
|
+
if kind not in _PHASE_R_PAYLOAD_COLS:
|
|
77
|
+
raise ValueError(f"unknown recommender kind: {kind!r}")
|
|
78
|
+
row: dict[str, str] = {"type": kind}
|
|
79
|
+
for col in _PHASE_R_PAYLOAD_COLS[kind]:
|
|
80
|
+
value = str(payload.get(col, "")).strip()
|
|
81
|
+
if not value:
|
|
82
|
+
raise KeyError(f"missing payload field {col!r} for kind {kind!r}")
|
|
83
|
+
row[col] = value
|
|
84
|
+
return state.add_override(row)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_staged_override(
|
|
88
|
+
state: AppState, op_type: str, payload: dict[str, str],
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Write a staged-form row (Add flight / Remove flight / Add staff /
|
|
91
|
+
Remove staff). The caller is expected to follow up with
|
|
92
|
+
``staged_overrides.apply_staged_overrides`` so the change shows on
|
|
93
|
+
the dashboard immediately.
|
|
94
|
+
|
|
95
|
+
Unknown payload keys are dropped rather than stored, so a typo in a
|
|
96
|
+
form field can't quietly become a column nothing reads.
|
|
97
|
+
"""
|
|
98
|
+
from ...schemas import STAGED_FORM_OVERRIDE_TYPES
|
|
99
|
+
valid = {t.value for t in STAGED_FORM_OVERRIDE_TYPES}
|
|
100
|
+
if op_type not in valid:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"unknown op_type: {op_type!r}. Valid: {sorted(valid)}"
|
|
103
|
+
)
|
|
104
|
+
row: dict[str, str] = {"type": op_type}
|
|
105
|
+
for key, value in payload.items():
|
|
106
|
+
k = str(key).strip().lower()
|
|
107
|
+
if k in OVERRIDE_HEADERS and str(value).strip():
|
|
108
|
+
row[k] = str(value).strip()
|
|
109
|
+
return state.add_override(row)
|
|
110
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Serialise the in-memory state into the JSON shapes the UI needs.
|
|
2
|
+
|
|
3
|
+
Every function takes the ``AppState`` and returns plain dicts/lists
|
|
4
|
+
ready for ``json.dumps``. Nothing here reads a file, so a GET is cheap
|
|
5
|
+
and can re-serialise on every request.
|
|
6
|
+
|
|
7
|
+
The package is split by what the caller is looking at:
|
|
8
|
+
|
|
9
|
+
common run date / INTL codes / roster-liveness predicates
|
|
10
|
+
dashboard headline stat cards + the at-risk table
|
|
11
|
+
tables the per-tab row lists (allocations, workload, pairs, ...)
|
|
12
|
+
planning Plan summary, handlers, staffing, roster, shift bands
|
|
13
|
+
session uploads, override rows, SV-portal columns
|
|
14
|
+
|
|
15
|
+
Import the names from here, not from the sub-modules — this is the
|
|
16
|
+
stable surface the API layer binds its routes to.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ...schemas import Severity
|
|
22
|
+
from .dashboard import read_dashboard
|
|
23
|
+
from .planning import (
|
|
24
|
+
read_handlers,
|
|
25
|
+
read_plan_summary,
|
|
26
|
+
read_roster_full,
|
|
27
|
+
read_shift_limits,
|
|
28
|
+
read_staff_names,
|
|
29
|
+
read_staffing,
|
|
30
|
+
)
|
|
31
|
+
from .session import (
|
|
32
|
+
read_inputs,
|
|
33
|
+
read_overrides,
|
|
34
|
+
read_roster_counts,
|
|
35
|
+
read_server_date,
|
|
36
|
+
read_sv_portal_columns,
|
|
37
|
+
)
|
|
38
|
+
from .tables import (
|
|
39
|
+
read_allocations,
|
|
40
|
+
read_pairs,
|
|
41
|
+
read_recommendations,
|
|
42
|
+
read_unallocated,
|
|
43
|
+
read_warnings,
|
|
44
|
+
read_workload,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Severity",
|
|
49
|
+
"read_allocations",
|
|
50
|
+
"read_dashboard",
|
|
51
|
+
"read_handlers",
|
|
52
|
+
"read_inputs",
|
|
53
|
+
"read_overrides",
|
|
54
|
+
"read_pairs",
|
|
55
|
+
"read_plan_summary",
|
|
56
|
+
"read_recommendations",
|
|
57
|
+
"read_roster_counts",
|
|
58
|
+
"read_roster_full",
|
|
59
|
+
"read_server_date",
|
|
60
|
+
"read_shift_limits",
|
|
61
|
+
"read_staff_names",
|
|
62
|
+
"read_staffing",
|
|
63
|
+
"read_sv_portal_columns",
|
|
64
|
+
"read_unallocated",
|
|
65
|
+
"read_warnings",
|
|
66
|
+
"read_workload",
|
|
67
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Shared primitives for the readback modules.
|
|
2
|
+
|
|
3
|
+
Everything here is pure: it reads ``AppState`` (or the config file) and
|
|
4
|
+
returns plain values. Keeping it in one place means a new readback
|
|
5
|
+
module never has to re-derive the run date or the INTL code set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ...schemas import CrewStatus
|
|
14
|
+
from ...state import AppState
|
|
15
|
+
|
|
16
|
+
# Absolute path to configs/config.yml relative to THIS source file, so a
|
|
17
|
+
# readback works whatever CWD the server was launched from.
|
|
18
|
+
CONFIG_PATH = Path(__file__).resolve().parents[3] / "configs" / "config.yml"
|
|
19
|
+
|
|
20
|
+
#: Canonical shift order used by every per-shift readback.
|
|
21
|
+
SHIFTS: tuple[str, ...] = ("M", "A", "N", "M1", "A1")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def intl_codes() -> set[str]:
|
|
25
|
+
from ...config import load_config
|
|
26
|
+
cfg = load_config(CONFIG_PATH)
|
|
27
|
+
return {c.upper() for c in cfg.io.sv_portal.international_airport_codes}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_date_iso(state: AppState) -> str:
|
|
31
|
+
return state.run_date.isoformat() if state.run_date else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_live(state: AppState, av: Any) -> bool:
|
|
35
|
+
"""A roster row counts for the dashboard when it is assignable, sits
|
|
36
|
+
on a shift, and belongs to the run date."""
|
|
37
|
+
if not av.assignable or not av.current_shift:
|
|
38
|
+
return False
|
|
39
|
+
d_iso = run_date_iso(state)
|
|
40
|
+
return not d_iso or av.date.isoformat() == d_iso
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_status(raw: str) -> CrewStatus | None:
|
|
44
|
+
"""Resolve a status literal, tolerating the legacy slash-reversed
|
|
45
|
+
form (``P2F/M``) by sorting the parts alphabetically."""
|
|
46
|
+
try:
|
|
47
|
+
return CrewStatus(raw)
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
if "/" in raw:
|
|
51
|
+
canonical = "/".join(
|
|
52
|
+
sorted(p.strip().upper() for p in raw.split("/") if p.strip())
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
return CrewStatus(canonical)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"CONFIG_PATH",
|
|
63
|
+
"SHIFTS",
|
|
64
|
+
"canonical_status",
|
|
65
|
+
"intl_codes",
|
|
66
|
+
"is_live",
|
|
67
|
+
"run_date_iso",
|
|
68
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Dashboard headline readback — the stat cards and the at-risk table.
|
|
2
|
+
|
|
3
|
+
One function, deliberately: the dashboard is the aggregate view, so it
|
|
4
|
+
touches allocations, roster and warnings at once. Per-tab detail lives
|
|
5
|
+
in the sibling modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ...state import AppState
|
|
13
|
+
from .common import run_date_iso
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_dashboard(state: AppState) -> dict[str, Any]:
|
|
18
|
+
"""Run-status block + the headline counts behind the stat cards."""
|
|
19
|
+
with state.lock:
|
|
20
|
+
# Per-class cards show CLEANED counts (flights to plan), so the
|
|
21
|
+
# numbers are real right after Plan rather than zeros that only
|
|
22
|
+
# fill in after Allocate.
|
|
23
|
+
by_class = {ops.value: len(rows) for ops, rows in state.cleaned.items()}
|
|
24
|
+
special_sub = {
|
|
25
|
+
"test": by_class.get("test", 0),
|
|
26
|
+
"ferry": by_class.get("ferry", 0),
|
|
27
|
+
"charter": by_class.get("charter", 0),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# "Flights allocated" counts allocation rows. A pre-plan-deferred
|
|
31
|
+
# row has empty staff but a populated planned_by — someone is
|
|
32
|
+
# preparing it, so it is not unallocated.
|
|
33
|
+
flights_total = len(state.allocations)
|
|
34
|
+
|
|
35
|
+
staff_at_risk: list[dict[str, Any]] = []
|
|
36
|
+
for w in state.workload:
|
|
37
|
+
# At risk = real cap violations only. Under-preferred is just
|
|
38
|
+
# a light load; above-preferred is informational.
|
|
39
|
+
joined = "; ".join(w.violations)
|
|
40
|
+
lowered = joined.lower()
|
|
41
|
+
if not any(p in lowered for p in (
|
|
42
|
+
"above hard cap", "exceeds cap", "exceeds hard cap",
|
|
43
|
+
"over hard cap",
|
|
44
|
+
)):
|
|
45
|
+
continue
|
|
46
|
+
staff_at_risk.append({
|
|
47
|
+
"employee_id": w.employee_id,
|
|
48
|
+
"name": w.name,
|
|
49
|
+
"shift": w.shift or "",
|
|
50
|
+
"actual": w.actual,
|
|
51
|
+
"target_preferred": w.target_preferred,
|
|
52
|
+
"hard_cap": w.hard_cap,
|
|
53
|
+
"violations": joined,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
warnings_total = len(state.warnings)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"run_date": run_date_iso(state),
|
|
60
|
+
"last_run": state.last_run,
|
|
61
|
+
"mode": state.mode,
|
|
62
|
+
"solver_status": state.solver_status,
|
|
63
|
+
"duration_s": state.duration_s,
|
|
64
|
+
"has_inputs": not state.missing_inputs(),
|
|
65
|
+
"missing_inputs": state.missing_inputs(),
|
|
66
|
+
"stats": {
|
|
67
|
+
"flights_total": flights_total,
|
|
68
|
+
"flights_unallocated": len(state.unallocated),
|
|
69
|
+
"day_ops": by_class.get("day", 0),
|
|
70
|
+
"night_ops": by_class.get("night", 0),
|
|
71
|
+
"p2f": by_class.get("p2f", 0),
|
|
72
|
+
"special_ops": sum(special_sub.values()),
|
|
73
|
+
"special_ops_test": special_sub["test"],
|
|
74
|
+
"special_ops_ferry": special_sub["ferry"],
|
|
75
|
+
"special_ops_charter": special_sub["charter"],
|
|
76
|
+
"staff_total": len(state.workload),
|
|
77
|
+
"staff_at_risk": len(staff_at_risk),
|
|
78
|
+
"warnings_total": warnings_total,
|
|
79
|
+
},
|
|
80
|
+
"staff_at_risk": staff_at_risk[:10],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|