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,108 @@
|
|
|
1
|
+
"""Pair-per-shift-change validation (user direction 2026-05-12, Task 3).
|
|
2
|
+
|
|
3
|
+
After pair generation, walk every (boundary, side, employee) triple and
|
|
4
|
+
flag the cases where a single person ends up with more than one distinct
|
|
5
|
+
partner at the same boundary. The only sanctioned exception is the
|
|
6
|
+
``A_TO_N`` split-1+2 pattern, where one Night staff legitimately has two
|
|
7
|
+
A-shift partners (primary count=2, secondary count=1) — that's part of
|
|
8
|
+
the round-3 design, not a discrepancy.
|
|
9
|
+
|
|
10
|
+
Output is a list of ``WarningRow`` entries (severity=WARN, code=W215)
|
|
11
|
+
so the existing OUT_Warnings pipeline + dashboard panel surface them
|
|
12
|
+
without any new sheet. Each warning describes the conflict in plain
|
|
13
|
+
English so the assigner can review:
|
|
14
|
+
|
|
15
|
+
"Jai Sundrani is paired with both Deepak Kathiat and Joel Verghese
|
|
16
|
+
at the M→A boundary (3 partner(s) total). Review for exception."
|
|
17
|
+
|
|
18
|
+
The validator is purely diagnostic — it never blocks allocation.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from datetime import date as date_t
|
|
25
|
+
|
|
26
|
+
from ..schemas import OpsBoundary, Pair, Severity, WarningRow
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_a_to_n_split(pairs: list[Pair]) -> bool:
|
|
30
|
+
"""Return True when this ``A_TO_N`` group is the sanctioned split-1+2
|
|
31
|
+
pattern: exactly 2 partners on the opposite side, preplan counts
|
|
32
|
+
summing to 3 with one entry =2 and the other =1. Other A→N
|
|
33
|
+
multi-partner shapes (e.g., 1+1+1 or 2+2) still flag as discrepancies.
|
|
34
|
+
"""
|
|
35
|
+
if len(pairs) != 2:
|
|
36
|
+
return False
|
|
37
|
+
counts = sorted(p.preplan_count for p in pairs)
|
|
38
|
+
return counts == [1, 2]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _format_partners(pairs: list[Pair], side: str) -> list[str]:
|
|
42
|
+
"""``side`` = 'next' or 'prev' — the OPPOSITE side from the one
|
|
43
|
+
we're checking. Returns deduped, sorted display strings."""
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for p in pairs:
|
|
47
|
+
emp_id = p.next_employee_id if side == "next" else p.prev_employee_id
|
|
48
|
+
name = p.next_name if side == "next" else p.prev_name
|
|
49
|
+
if emp_id is None:
|
|
50
|
+
continue
|
|
51
|
+
key = f"{emp_id}|{name or ''}"
|
|
52
|
+
if key in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(key)
|
|
55
|
+
out.append(name or emp_id)
|
|
56
|
+
out.sort()
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_pair_discrepancies(
|
|
61
|
+
pairs: list[Pair], d_day: date_t,
|
|
62
|
+
) -> list[WarningRow]:
|
|
63
|
+
"""Identify staff who appear in multiple pairs at the same boundary.
|
|
64
|
+
|
|
65
|
+
Returns one WarningRow per flagged staff. Empty list when every
|
|
66
|
+
pair is 1-to-1 (or every multi-pair group is a sanctioned exception
|
|
67
|
+
such as A_TO_N split-1+2).
|
|
68
|
+
"""
|
|
69
|
+
# Group by (boundary, "prev"/"next", employee_id). Each group is the
|
|
70
|
+
# list of pairs that mention this person on this side at this
|
|
71
|
+
# boundary. Multi-entry groups are candidates for a discrepancy
|
|
72
|
+
# warning.
|
|
73
|
+
by_role: dict[tuple[OpsBoundary, str, str, str], list[Pair]] = defaultdict(list)
|
|
74
|
+
for p in pairs:
|
|
75
|
+
if p.prev_employee_id is not None:
|
|
76
|
+
by_role[(p.boundary, "prev", p.prev_employee_id, p.prev_name or "")].append(p)
|
|
77
|
+
if p.next_employee_id is not None:
|
|
78
|
+
by_role[(p.boundary, "next", p.next_employee_id, p.next_name or "")].append(p)
|
|
79
|
+
|
|
80
|
+
out: list[WarningRow] = []
|
|
81
|
+
for (boundary, side, emp_id, emp_name), group in by_role.items():
|
|
82
|
+
if len(group) <= 1:
|
|
83
|
+
continue
|
|
84
|
+
# Sanctioned A→N split-1+2 exception.
|
|
85
|
+
if boundary == OpsBoundary.A_TO_N and _is_a_to_n_split(group):
|
|
86
|
+
continue
|
|
87
|
+
opposite_side = "next" if side == "prev" else "prev"
|
|
88
|
+
partners = _format_partners(group, opposite_side)
|
|
89
|
+
if len(partners) <= 1:
|
|
90
|
+
continue # All entries share the same partner — not a conflict.
|
|
91
|
+
out.append(WarningRow(
|
|
92
|
+
severity=Severity.WARN,
|
|
93
|
+
code="W215",
|
|
94
|
+
name=emp_name or emp_id,
|
|
95
|
+
date=d_day,
|
|
96
|
+
message=(
|
|
97
|
+
f"{emp_name or emp_id} is paired with {len(partners)} "
|
|
98
|
+
f"different partner(s) at the {boundary.value} boundary: "
|
|
99
|
+
f"{', '.join(partners)}. Review for exception."
|
|
100
|
+
),
|
|
101
|
+
))
|
|
102
|
+
# Stable order: by boundary then employee name for deterministic
|
|
103
|
+
# rendering in OUT_Warnings + the dashboard panel.
|
|
104
|
+
out.sort(key=lambda w: (str(w.code), str(w.name)))
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["validate_pair_discrepancies"]
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Pair generator for Step 4 (H8 + correction r2-7).
|
|
2
|
+
|
|
3
|
+
Auto-generates the full set of boundary pairs for an ops day, sequentially
|
|
4
|
+
by IN_Staff row order. ZCs only pair with ZCs at every boundary. The
|
|
5
|
+
A→N boundary uses a three-tier hierarchy when |A| > |N|.
|
|
6
|
+
|
|
7
|
+
Inputs:
|
|
8
|
+
staff_today — sorted by IN_Staff row order, already filtered to
|
|
9
|
+
assignable staff (off-day rows excluded by the
|
|
10
|
+
caller)
|
|
11
|
+
yesterday_n_staff — yesterday's N-shift roster, used for the N→M
|
|
12
|
+
boundary (D-1's N planned today's M flights). May
|
|
13
|
+
be None on the first run; in that case, today's M
|
|
14
|
+
staff self-plan their first flight.
|
|
15
|
+
overrides — pair-specific override rows; each one replaces
|
|
16
|
+
any auto-generated pair sharing the same (boundary,
|
|
17
|
+
prev_emp) or (boundary, next_emp).
|
|
18
|
+
|
|
19
|
+
Output: list[Pair]. Surplus staff with no partner are absent from the
|
|
20
|
+
output (emitted Pairs always have BOTH sides populated). The caller can
|
|
21
|
+
infer "this staff is unpaired at this boundary" by absence — useful for
|
|
22
|
+
the pair map's surplus annotations and for diagnosing under-staffing.
|
|
23
|
+
|
|
24
|
+
Three-tier hierarchy at A→N:
|
|
25
|
+
Tier 1: 1-to-1 primary A↔N up to min(|A|, |N|).
|
|
26
|
+
Tier 2: A surplus routes to A↔A1 handover-only.
|
|
27
|
+
Tier 3: Remaining A surplus routes to secondary A↔N handover-only,
|
|
28
|
+
one secondary per N staff (so each N person can have at most
|
|
29
|
+
one primary + one secondary A pair).
|
|
30
|
+
If A surplus still remains after tier 3, raises PairGenerationError —
|
|
31
|
+
the assigner has too many A staff for the day's structure and must
|
|
32
|
+
resolve via override or by reducing the A roster.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections import defaultdict
|
|
38
|
+
|
|
39
|
+
from ..schemas import OpsBoundary, Pair, PairRole, Role, ShiftCode, StaffMember
|
|
40
|
+
|
|
41
|
+
# H6 per-primary-pair pre-plan counts (defaults). Aligned with REF_Constraints.
|
|
42
|
+
# Per-pair count actually used at construction may differ from these defaults
|
|
43
|
+
# in special cases — currently the only such case is the split 1+2 at A→N
|
|
44
|
+
# when N has 2 A-partners (primary pair gets 2, secondary gets 1).
|
|
45
|
+
_H6_DEFAULT_COUNTS: dict[OpsBoundary, int] = {
|
|
46
|
+
OpsBoundary.N_TO_M: 1,
|
|
47
|
+
OpsBoundary.M_TO_M1: 1,
|
|
48
|
+
OpsBoundary.M_TO_A: 2,
|
|
49
|
+
OpsBoundary.M1_TO_A1: 2,
|
|
50
|
+
OpsBoundary.A_TO_N: 3,
|
|
51
|
+
OpsBoundary.A1_TO_N: 0,
|
|
52
|
+
OpsBoundary.A_TO_A1: 0,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PairGenerationError(ValueError):
|
|
57
|
+
"""Raised when A surplus exceeds the total handover-routing capacity
|
|
58
|
+
(A↔A1 + secondary A↔N). Maps to W211 in the warnings system."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _by_shift_role(
|
|
62
|
+
staff: list[StaffMember],
|
|
63
|
+
) -> dict[tuple[ShiftCode, Role], list[StaffMember]]:
|
|
64
|
+
"""Group staff by (shift_today, role), preserving input order (which
|
|
65
|
+
is the assigner's IN_Staff row order). Off-shift and AM staff are
|
|
66
|
+
dropped — they don't pair."""
|
|
67
|
+
out: dict[tuple[ShiftCode, Role], list[StaffMember]] = defaultdict(list)
|
|
68
|
+
for s in staff:
|
|
69
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
70
|
+
continue
|
|
71
|
+
out[(s.shift_today, s.role)].append(s)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _make_pair(
|
|
76
|
+
prev: StaffMember, next_: StaffMember,
|
|
77
|
+
boundary: OpsBoundary, pair_role: PairRole,
|
|
78
|
+
prev_shift: ShiftCode, next_shift: ShiftCode,
|
|
79
|
+
*, preplan_count: int | None = None,
|
|
80
|
+
) -> Pair:
|
|
81
|
+
"""Build a Pair. ``preplan_count`` defaults to the H6 table value for
|
|
82
|
+
the boundary if not specified — pass an explicit override (e.g., 1
|
|
83
|
+
for split-secondary at A→N, 0 for handover-only)."""
|
|
84
|
+
if preplan_count is None:
|
|
85
|
+
preplan_count = (
|
|
86
|
+
_H6_DEFAULT_COUNTS[boundary]
|
|
87
|
+
if pair_role == PairRole.PRIMARY else 0
|
|
88
|
+
)
|
|
89
|
+
return Pair(
|
|
90
|
+
boundary=boundary, pair_role=pair_role, preplan_count=preplan_count,
|
|
91
|
+
prev_employee_id=prev.employee_id, prev_name=prev.name, prev_shift=prev_shift,
|
|
92
|
+
next_employee_id=next_.employee_id, next_name=next_.name, next_shift=next_shift,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _one_to_one(
|
|
97
|
+
prev: list[StaffMember], next_: list[StaffMember],
|
|
98
|
+
boundary: OpsBoundary, pair_role: PairRole,
|
|
99
|
+
prev_shift: ShiftCode, next_shift: ShiftCode,
|
|
100
|
+
*, preplan_count: int | None = None,
|
|
101
|
+
) -> tuple[list[Pair], list[StaffMember], list[StaffMember]]:
|
|
102
|
+
"""Sequential pairing prev[i] ↔ next[i] up to min(|prev|, |next|).
|
|
103
|
+
Returns (pairs, prev_unpaired, next_unpaired)."""
|
|
104
|
+
n = min(len(prev), len(next_))
|
|
105
|
+
pairs = [
|
|
106
|
+
_make_pair(
|
|
107
|
+
prev[i], next_[i], boundary, pair_role, prev_shift, next_shift,
|
|
108
|
+
preplan_count=preplan_count,
|
|
109
|
+
)
|
|
110
|
+
for i in range(n)
|
|
111
|
+
]
|
|
112
|
+
return pairs, prev[n:], next_[n:]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _simple_boundary(
|
|
116
|
+
by_today: dict[tuple[ShiftCode, Role], list[StaffMember]],
|
|
117
|
+
boundary: OpsBoundary,
|
|
118
|
+
prev_shift: ShiftCode, next_shift: ShiftCode,
|
|
119
|
+
pair_role: PairRole,
|
|
120
|
+
) -> list[Pair]:
|
|
121
|
+
"""Pair regulars-with-regulars and ZCs-with-ZCs at a boundary that
|
|
122
|
+
doesn't use the 3-tier hierarchy. ZC isolation: a regular never
|
|
123
|
+
pairs with a ZC at this layer (the N→M cross-role exception is
|
|
124
|
+
handled separately in _n_to_m_cross_role)."""
|
|
125
|
+
pairs: list[Pair] = []
|
|
126
|
+
for role in (Role.STAFF, Role.ZC):
|
|
127
|
+
prev = by_today.get((prev_shift, role), [])
|
|
128
|
+
next_ = by_today.get((next_shift, role), [])
|
|
129
|
+
new_pairs, _, _ = _one_to_one(
|
|
130
|
+
prev, next_, boundary, pair_role, prev_shift, next_shift,
|
|
131
|
+
)
|
|
132
|
+
pairs.extend(new_pairs)
|
|
133
|
+
return pairs
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _n_to_m_cross_role(
|
|
137
|
+
by_yesterday: dict[tuple[ShiftCode, Role], list[StaffMember]],
|
|
138
|
+
by_today: dict[tuple[ShiftCode, Role], list[StaffMember]],
|
|
139
|
+
) -> list[Pair]:
|
|
140
|
+
"""Round-3 update: at the N→M boundary, M-ZCs that don't have an
|
|
141
|
+
N-ZC partner (because there's typically only 1 N-ZC vs 4 M-ZCs)
|
|
142
|
+
are planned by N **regular** staff. N names may repeat — the
|
|
143
|
+
priority is "every M-ZC's first flight gets a planner" over "no N
|
|
144
|
+
staff is reused" (per user direction).
|
|
145
|
+
|
|
146
|
+
Algorithm:
|
|
147
|
+
1. Pair the N-ZCs to M-ZCs sequentially (same as existing ZC↔ZC).
|
|
148
|
+
2. Remaining M-ZCs round-robin onto N-regulars (with repetition
|
|
149
|
+
when N-regular pool is smaller than the M-ZC remainder).
|
|
150
|
+
3. ZC↔ZC pairs already emitted by _simple_boundary; this function
|
|
151
|
+
emits ONLY the cross-role overflow.
|
|
152
|
+
"""
|
|
153
|
+
n_zcs = by_yesterday.get(("N", Role.ZC), [])
|
|
154
|
+
m_zcs = by_today.get(("M", Role.ZC), [])
|
|
155
|
+
n_regs = by_yesterday.get(("N", Role.STAFF), [])
|
|
156
|
+
m_zcs_unmatched = m_zcs[len(n_zcs):]
|
|
157
|
+
if not m_zcs_unmatched:
|
|
158
|
+
return []
|
|
159
|
+
if not n_regs:
|
|
160
|
+
# No N-regulars to plan from; the unmatched M-ZCs self-plan their
|
|
161
|
+
# first flight.
|
|
162
|
+
return []
|
|
163
|
+
pairs: list[Pair] = []
|
|
164
|
+
for i, m_zc in enumerate(m_zcs_unmatched):
|
|
165
|
+
n_planner = n_regs[i % len(n_regs)] # round-robin with repetition
|
|
166
|
+
pairs.append(_make_pair(
|
|
167
|
+
n_planner, m_zc, OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
|
|
168
|
+
preplan_count=_H6_DEFAULT_COUNTS[OpsBoundary.N_TO_M],
|
|
169
|
+
))
|
|
170
|
+
return pairs
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _three_tier_a_to_n(
|
|
174
|
+
a_regulars: list[StaffMember],
|
|
175
|
+
n_regulars: list[StaffMember],
|
|
176
|
+
a1_regulars: list[StaffMember],
|
|
177
|
+
) -> list[Pair]:
|
|
178
|
+
"""A→N regulars with the 3-tier hierarchy (correction r2-7 + split-1+2).
|
|
179
|
+
|
|
180
|
+
Tier 1: primary 1-to-1 A↔N (default preplan_count = 3 each).
|
|
181
|
+
Tier 2: A surplus → A↔A1 handover-only (preplan_count = 0).
|
|
182
|
+
Tier 3: A surplus still remaining → secondary A↔N as a SECOND
|
|
183
|
+
primary pair, but with split planning (round-3 update). The
|
|
184
|
+
primary pair's count drops from 3 to 2; the secondary pair
|
|
185
|
+
carries count 1. Sum still 3 per N. Each N person can have
|
|
186
|
+
at most 1 secondary pair.
|
|
187
|
+
|
|
188
|
+
Edge cases:
|
|
189
|
+
- No N regulars: A→N doesn't apply. A surplus may still go to A1
|
|
190
|
+
handover-only without raising.
|
|
191
|
+
- More A surplus than tier-3 capacity (|N|): raise
|
|
192
|
+
PairGenerationError (W211).
|
|
193
|
+
"""
|
|
194
|
+
if not n_regulars:
|
|
195
|
+
if not a_regulars or not a1_regulars:
|
|
196
|
+
return []
|
|
197
|
+
tier2_only, _, _ = _one_to_one(
|
|
198
|
+
a_regulars, a1_regulars,
|
|
199
|
+
OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
|
|
200
|
+
preplan_count=0,
|
|
201
|
+
)
|
|
202
|
+
return tier2_only
|
|
203
|
+
|
|
204
|
+
# How many N's will get a split-secondary pair? It's the count of A
|
|
205
|
+
# staff still surplus after tiers 1 and 2.
|
|
206
|
+
a_used_tier1 = min(len(a_regulars), len(n_regulars))
|
|
207
|
+
a_after_tier1 = len(a_regulars) - a_used_tier1
|
|
208
|
+
a_used_tier2 = min(a_after_tier1, len(a1_regulars))
|
|
209
|
+
a_after_tier2 = a_after_tier1 - a_used_tier2
|
|
210
|
+
secondary_count = a_after_tier2
|
|
211
|
+
|
|
212
|
+
# 2026-05-28 (user direction — centralized softening): the
|
|
213
|
+
# capacity check WAS a hard raise (PairGenerationError) that
|
|
214
|
+
# aborted the entire allocation. In practice the unpaired tail is
|
|
215
|
+
# almost always 1–2 A staff out of 30+, which is operationally
|
|
216
|
+
# harmless (they just don't have a planned_by/relieved_by entry on
|
|
217
|
+
# their first/last flight — same as a pure single-shift staff).
|
|
218
|
+
# Aborting the whole run for 1 unpaired staff turned every "add
|
|
219
|
+
# one extra A-shift person" into a full allocation failure, which
|
|
220
|
+
# also masked downstream issues like add_staff / remove_staff not
|
|
221
|
+
# appearing. Now: cap secondary_count at capacity and let the
|
|
222
|
+
# surplus go unpaired without raising. The caller (step3) emits
|
|
223
|
+
# W211 INFO so the assigner still sees the count.
|
|
224
|
+
pairs_overflow_unpaired = max(0, secondary_count - len(n_regulars))
|
|
225
|
+
if pairs_overflow_unpaired > 0:
|
|
226
|
+
secondary_count = len(n_regulars)
|
|
227
|
+
|
|
228
|
+
pairs: list[Pair] = []
|
|
229
|
+
# Tier 1: per-N primary pair. The first ``secondary_count`` N's get
|
|
230
|
+
# primary preplan_count=2 (will be split with a secondary count=1);
|
|
231
|
+
# the remaining N's get the full 3.
|
|
232
|
+
for i in range(a_used_tier1):
|
|
233
|
+
primary_count = 2 if i < secondary_count else 3
|
|
234
|
+
pairs.append(_make_pair(
|
|
235
|
+
a_regulars[i], n_regulars[i],
|
|
236
|
+
OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
|
|
237
|
+
preplan_count=primary_count,
|
|
238
|
+
))
|
|
239
|
+
# Tier 2: A surplus → A↔A1 handover-only.
|
|
240
|
+
for j in range(a_used_tier2):
|
|
241
|
+
a_s = a_regulars[a_used_tier1 + j]
|
|
242
|
+
a1_s = a1_regulars[j]
|
|
243
|
+
pairs.append(_make_pair(
|
|
244
|
+
a_s, a1_s,
|
|
245
|
+
OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
|
|
246
|
+
preplan_count=0,
|
|
247
|
+
))
|
|
248
|
+
# Tier 3: remaining A surplus → split-secondary A↔N (preplan_count=1).
|
|
249
|
+
# These pair with the FIRST `secondary_count` N's — same N's whose
|
|
250
|
+
# primary preplan_count was reduced to 2 above.
|
|
251
|
+
for k in range(secondary_count):
|
|
252
|
+
a_s = a_regulars[a_used_tier1 + a_used_tier2 + k]
|
|
253
|
+
n_s = n_regulars[k]
|
|
254
|
+
pairs.append(_make_pair(
|
|
255
|
+
a_s, n_s,
|
|
256
|
+
OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
|
|
257
|
+
preplan_count=1,
|
|
258
|
+
))
|
|
259
|
+
return pairs
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _apply_overrides(pairs: list[Pair], overrides: list[Pair]) -> list[Pair]:
|
|
263
|
+
"""Replace auto-generated pairs that conflict with overrides.
|
|
264
|
+
|
|
265
|
+
An override "claims" any (boundary, prev_emp) and (boundary, next_emp)
|
|
266
|
+
slot it specifies. Auto-generated pairs sharing those slots are
|
|
267
|
+
dropped, leaving the override authoritative. Staff displaced by the
|
|
268
|
+
override become unpaired at that boundary (no pair row emitted).
|
|
269
|
+
"""
|
|
270
|
+
claimed: set[tuple[OpsBoundary, str, str]] = set()
|
|
271
|
+
for o in overrides:
|
|
272
|
+
if o.prev_employee_id is not None:
|
|
273
|
+
claimed.add((o.boundary, "prev", o.prev_employee_id))
|
|
274
|
+
if o.next_employee_id is not None:
|
|
275
|
+
claimed.add((o.boundary, "next", o.next_employee_id))
|
|
276
|
+
out: list[Pair] = []
|
|
277
|
+
for p in pairs:
|
|
278
|
+
if p.prev_employee_id and (p.boundary, "prev", p.prev_employee_id) in claimed:
|
|
279
|
+
continue
|
|
280
|
+
if p.next_employee_id and (p.boundary, "next", p.next_employee_id) in claimed:
|
|
281
|
+
continue
|
|
282
|
+
out.append(p)
|
|
283
|
+
out.extend(overrides)
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _hoist_p2f_first(
|
|
288
|
+
staff_list: list[StaffMember], p2f_id: str | None,
|
|
289
|
+
) -> list[StaffMember]:
|
|
290
|
+
"""Return the input list with the P2F nominee moved to the front.
|
|
291
|
+
Preserves order of every other member. No-op when ``p2f_id`` is
|
|
292
|
+
None / not in the list. Used by the v2 pair generator so the day's
|
|
293
|
+
P2F nominees naturally pair with each other via 1-to-1 sequencing.
|
|
294
|
+
"""
|
|
295
|
+
if not p2f_id:
|
|
296
|
+
return list(staff_list)
|
|
297
|
+
p2f = [s for s in staff_list if s.employee_id == p2f_id]
|
|
298
|
+
rest = [s for s in staff_list if s.employee_id != p2f_id]
|
|
299
|
+
return p2f + rest
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _generate_pairs_v2(
|
|
303
|
+
staff_today: list[StaffMember],
|
|
304
|
+
yesterday_n_staff: list[StaffMember] | None,
|
|
305
|
+
overrides: list[Pair] | None,
|
|
306
|
+
p2f_handlers: dict[ShiftCode, str] | None,
|
|
307
|
+
) -> list[Pair]:
|
|
308
|
+
"""Alternate pair generator (user direction 2026-05-28 — Iteration B).
|
|
309
|
+
|
|
310
|
+
Differences from the default ``generate_pairs``:
|
|
311
|
+
* For each within-day P2F boundary (M→A, A→N), the day's P2F
|
|
312
|
+
nominees are hoisted to the FRONT of their shift's STAFF list
|
|
313
|
+
before 1-to-1 pairing. When both shifts have a nominee, they
|
|
314
|
+
pair with each other; when one side has no nominee, the other's
|
|
315
|
+
nominee falls back to whoever the normal logic would pair them
|
|
316
|
+
with.
|
|
317
|
+
* ZC pairing first absorbs as many ZC↔ZC matches as possible
|
|
318
|
+
across boundaries (including A↔A1 ZCs at the A_TO_A1 boundary,
|
|
319
|
+
which the default code only used for STAFF surplus). Whatever
|
|
320
|
+
ZCs remain orphan are folded into that side's STAFF pool for
|
|
321
|
+
the boundary's normal STAFF pairing — they "spill to STAFF".
|
|
322
|
+
|
|
323
|
+
Everything else (N→M cross-role exception, the 3-tier A→N STAFF
|
|
324
|
+
hierarchy, override application) matches the default generator.
|
|
325
|
+
"""
|
|
326
|
+
by_today = _by_shift_role(staff_today)
|
|
327
|
+
p2f_handlers = p2f_handlers or {}
|
|
328
|
+
pairs: list[Pair] = []
|
|
329
|
+
|
|
330
|
+
# ---- ZC pairing pass (with overflow-to-STAFF) ----
|
|
331
|
+
# Two pools per shift: ``prev`` (ZCs available as boundary-prev,
|
|
332
|
+
# i.e. handing off OUT of this shift) and ``next`` (ZCs available
|
|
333
|
+
# as boundary-next, i.e. handing INTO this shift). A single ZC can
|
|
334
|
+
# legitimately appear in TWO pair rows in v1 (as next of an
|
|
335
|
+
# incoming boundary AND prev of an outgoing boundary) — that's the
|
|
336
|
+
# natural "handover bridge" pattern. Splitting prev / next
|
|
337
|
+
# preserves that pattern while still letting us detect ZCs that
|
|
338
|
+
# got zero pair entries (completely orphan).
|
|
339
|
+
zc_prev_pool: dict[ShiftCode, list[StaffMember]] = {
|
|
340
|
+
sh: list(by_today.get((sh, Role.ZC), []))
|
|
341
|
+
for sh in ("M", "M1", "A", "A1", "N")
|
|
342
|
+
}
|
|
343
|
+
zc_next_pool: dict[ShiftCode, list[StaffMember]] = {
|
|
344
|
+
sh: list(by_today.get((sh, Role.ZC), []))
|
|
345
|
+
for sh in ("M", "M1", "A", "A1", "N")
|
|
346
|
+
}
|
|
347
|
+
# Yesterday's N ZCs only ever play prev (handing INTO today's M).
|
|
348
|
+
by_yesterday = _by_shift_role(yesterday_n_staff) if yesterday_n_staff else {}
|
|
349
|
+
yesterday_n_zc_prev = list(by_yesterday.get(("N", Role.ZC), []))
|
|
350
|
+
# Track which ZCs participated in any pair so we can fold the
|
|
351
|
+
# completely-unpaired set into STAFF later.
|
|
352
|
+
paired_zc_ids: set[str] = set()
|
|
353
|
+
|
|
354
|
+
def _zc_pair_dir(
|
|
355
|
+
prev_pool: list[StaffMember],
|
|
356
|
+
next_pool: list[StaffMember],
|
|
357
|
+
boundary: OpsBoundary,
|
|
358
|
+
pair_role: PairRole,
|
|
359
|
+
prev_shift: ShiftCode, next_shift: ShiftCode,
|
|
360
|
+
*, preplan_count: int | None = None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Pair as many ZCs as possible 1-to-1 at this boundary.
|
|
363
|
+
|
|
364
|
+
2026-05-28 (centralized fix): drain ONLY the prev pool. Each ZC
|
|
365
|
+
plays at most one "prev" role (outgoing handover) across the
|
|
366
|
+
whole day — that's what the user's "rest left zc (here 2)"
|
|
367
|
+
math requires. But the "next" role (incoming handover) can be
|
|
368
|
+
filled multiple times — A1's only ZC legitimately receives
|
|
369
|
+
briefings from BOTH M1 (start-of-shift, M1→A1) AND A (mid-shift,
|
|
370
|
+
A→A1), so draining A1's next pool at M1→A1 would zero-out the
|
|
371
|
+
A→A1 ZC pair the user explicitly asked for. v1's behavior is
|
|
372
|
+
consistent with this — N's ZC was "next" at both A→N and
|
|
373
|
+
A1→N without conflict.
|
|
374
|
+
"""
|
|
375
|
+
n = min(len(prev_pool), len(next_pool))
|
|
376
|
+
if n <= 0:
|
|
377
|
+
return
|
|
378
|
+
for i in range(n):
|
|
379
|
+
pairs.append(_make_pair(
|
|
380
|
+
prev_pool[i], next_pool[i],
|
|
381
|
+
boundary, pair_role, prev_shift, next_shift,
|
|
382
|
+
preplan_count=preplan_count,
|
|
383
|
+
))
|
|
384
|
+
paired_zc_ids.add(prev_pool[i].employee_id)
|
|
385
|
+
paired_zc_ids.add(next_pool[i].employee_id)
|
|
386
|
+
# Drain ONLY prev (outgoing handover). Leave next pool intact
|
|
387
|
+
# so a ZC on the receiving side can absorb multiple incoming
|
|
388
|
+
# handovers across boundaries.
|
|
389
|
+
del prev_pool[:n]
|
|
390
|
+
|
|
391
|
+
# ZC pairing order: same set of boundaries as v1 + the new A↔A1 ZC
|
|
392
|
+
# handover. Drain is DIRECTIONAL (prev vs next), so a single ZC can
|
|
393
|
+
# still appear in multiple pair rows when they bridge boundaries.
|
|
394
|
+
if yesterday_n_staff:
|
|
395
|
+
_zc_pair_dir(
|
|
396
|
+
yesterday_n_zc_prev, zc_next_pool["M"],
|
|
397
|
+
OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
|
|
398
|
+
)
|
|
399
|
+
_zc_pair_dir(
|
|
400
|
+
zc_prev_pool["M"], zc_next_pool["M1"],
|
|
401
|
+
OpsBoundary.M_TO_M1, PairRole.PRIMARY, "M", "M1",
|
|
402
|
+
)
|
|
403
|
+
_zc_pair_dir(
|
|
404
|
+
zc_prev_pool["M"], zc_next_pool["A"],
|
|
405
|
+
OpsBoundary.M_TO_A, PairRole.PRIMARY, "M", "A",
|
|
406
|
+
)
|
|
407
|
+
_zc_pair_dir(
|
|
408
|
+
zc_prev_pool["M1"], zc_next_pool["A1"],
|
|
409
|
+
OpsBoundary.M1_TO_A1, PairRole.PRIMARY, "M1", "A1",
|
|
410
|
+
)
|
|
411
|
+
_zc_pair_dir(
|
|
412
|
+
zc_prev_pool["A"], zc_next_pool["N"],
|
|
413
|
+
OpsBoundary.A_TO_N, PairRole.PRIMARY, "A", "N",
|
|
414
|
+
)
|
|
415
|
+
# 2026-05-28 NEW: A↔A1 ZC handover. The default v1 code only paired
|
|
416
|
+
# STAFF at this boundary; here we catch surplus A-ZCs (prev side)
|
|
417
|
+
# against any unused A1-ZC (next side) before they spill to STAFF.
|
|
418
|
+
_zc_pair_dir(
|
|
419
|
+
zc_prev_pool["A"], zc_next_pool["A1"],
|
|
420
|
+
OpsBoundary.A_TO_A1, PairRole.HANDOVER_ONLY, "A", "A1",
|
|
421
|
+
preplan_count=0,
|
|
422
|
+
)
|
|
423
|
+
_zc_pair_dir(
|
|
424
|
+
zc_prev_pool["A1"], zc_next_pool["N"],
|
|
425
|
+
OpsBoundary.A1_TO_N, PairRole.HANDOVER_ONLY, "A1", "N",
|
|
426
|
+
preplan_count=0,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# ---- ZC overflow → STAFF augmentation ----
|
|
430
|
+
# Completely-unpaired ZCs (zero pair entries in EITHER prev or next
|
|
431
|
+
# role across all boundaries) get folded into their shift's STAFF
|
|
432
|
+
# list so the STAFF pair-generation logic below treats them as
|
|
433
|
+
# regular staff for pairing only. Their role / workload caps stay
|
|
434
|
+
# ZC — only their pair slot changes.
|
|
435
|
+
staff_lists: dict[ShiftCode, list[StaffMember]] = {
|
|
436
|
+
sh: list(by_today.get((sh, Role.STAFF), []))
|
|
437
|
+
for sh in ("M", "M1", "A", "A1", "N")
|
|
438
|
+
}
|
|
439
|
+
for sh in ("M", "M1", "A", "A1", "N"):
|
|
440
|
+
for zc in by_today.get((sh, Role.ZC), []):
|
|
441
|
+
if zc.employee_id not in paired_zc_ids:
|
|
442
|
+
staff_lists[sh].append(zc)
|
|
443
|
+
# Yesterday's N ZCs that didn't pair into today's M (rare):
|
|
444
|
+
yesterday_n_orphans = [
|
|
445
|
+
zc for zc in by_yesterday.get(("N", Role.ZC), [])
|
|
446
|
+
if zc.employee_id not in paired_zc_ids
|
|
447
|
+
] if yesterday_n_staff else []
|
|
448
|
+
yesterday_n_staff_aug = (
|
|
449
|
+
list(by_yesterday.get(("N", Role.STAFF), [])) + yesterday_n_orphans
|
|
450
|
+
if yesterday_n_staff else []
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# ---- P2F nominee hoisting on STAFF lists ----
|
|
454
|
+
# Move each shift's P2F nominee to the front of its augmented STAFF
|
|
455
|
+
# list so the 1-to-1 sequencing pairs nominees with each other at
|
|
456
|
+
# M→A and A→N. M / A / N are the only P2F-nominating shifts.
|
|
457
|
+
for sh in ("M", "A", "N"):
|
|
458
|
+
staff_lists[sh] = _hoist_p2f_first(
|
|
459
|
+
staff_lists[sh], p2f_handlers.get(sh),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# ---- STAFF pairing using the augmented lists ----
|
|
463
|
+
# N → M cross-day (with cross-role exception unchanged).
|
|
464
|
+
if yesterday_n_staff:
|
|
465
|
+
n2m_pairs, _, _ = _one_to_one(
|
|
466
|
+
yesterday_n_staff_aug, staff_lists["M"],
|
|
467
|
+
OpsBoundary.N_TO_M, PairRole.PRIMARY, "N", "M",
|
|
468
|
+
)
|
|
469
|
+
pairs.extend(n2m_pairs)
|
|
470
|
+
# Cross-role exception (preserved): unmatched M-ZCs (those
|
|
471
|
+
# already in zc_pool["M"] before we folded them into STAFF
|
|
472
|
+
# would have stayed orphan — but here they were already folded.
|
|
473
|
+
# So just call the existing helper against the original
|
|
474
|
+
# un-augmented yesterday's view, mirroring the default code.
|
|
475
|
+
pairs.extend(_n_to_m_cross_role(by_yesterday, by_today))
|
|
476
|
+
|
|
477
|
+
# M→M1
|
|
478
|
+
m_to_m1_pairs, _, _ = _one_to_one(
|
|
479
|
+
staff_lists["M"], staff_lists["M1"],
|
|
480
|
+
OpsBoundary.M_TO_M1, PairRole.PRIMARY, "M", "M1",
|
|
481
|
+
)
|
|
482
|
+
pairs.extend(m_to_m1_pairs)
|
|
483
|
+
|
|
484
|
+
# M→A
|
|
485
|
+
m_to_a_pairs, _, _ = _one_to_one(
|
|
486
|
+
staff_lists["M"], staff_lists["A"],
|
|
487
|
+
OpsBoundary.M_TO_A, PairRole.PRIMARY, "M", "A",
|
|
488
|
+
)
|
|
489
|
+
pairs.extend(m_to_a_pairs)
|
|
490
|
+
|
|
491
|
+
# M1→A1
|
|
492
|
+
m1_to_a1_pairs, _, _ = _one_to_one(
|
|
493
|
+
staff_lists["M1"], staff_lists["A1"],
|
|
494
|
+
OpsBoundary.M1_TO_A1, PairRole.PRIMARY, "M1", "A1",
|
|
495
|
+
)
|
|
496
|
+
pairs.extend(m1_to_a1_pairs)
|
|
497
|
+
|
|
498
|
+
# A→N: keep the 3-tier hierarchy for STAFF (orphan ZCs folded in
|
|
499
|
+
# above appear at the END of the A-STAFF list, so primary pairs
|
|
500
|
+
# still consume "real" STAFF first and any ZC overflow goes to
|
|
501
|
+
# tier-2/tier-3 — which is exactly the user-intended fallback).
|
|
502
|
+
pairs.extend(_three_tier_a_to_n(
|
|
503
|
+
staff_lists["A"], staff_lists["N"], staff_lists["A1"],
|
|
504
|
+
))
|
|
505
|
+
|
|
506
|
+
# A1→N handover-only
|
|
507
|
+
a1_to_n_pairs, _, _ = _one_to_one(
|
|
508
|
+
staff_lists["A1"], staff_lists["N"],
|
|
509
|
+
OpsBoundary.A1_TO_N, PairRole.HANDOVER_ONLY, "A1", "N",
|
|
510
|
+
preplan_count=0,
|
|
511
|
+
)
|
|
512
|
+
pairs.extend(a1_to_n_pairs)
|
|
513
|
+
|
|
514
|
+
if overrides:
|
|
515
|
+
pairs = _apply_overrides(pairs, overrides)
|
|
516
|
+
return pairs
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def generate_pairs(
|
|
520
|
+
staff_today: list[StaffMember],
|
|
521
|
+
*,
|
|
522
|
+
yesterday_n_staff: list[StaffMember] | None = None,
|
|
523
|
+
overrides: list[Pair] | None = None,
|
|
524
|
+
p2f_handlers: dict[ShiftCode, str] | None = None,
|
|
525
|
+
) -> list[Pair]:
|
|
526
|
+
"""Build the full pair list for an ops day. See module docstring for
|
|
527
|
+
the algorithm, invariants, and error conditions.
|
|
528
|
+
|
|
529
|
+
2026-05-28 (Phase 2 promoted to default — user direction): single
|
|
530
|
+
centralized path. The previous v1 generator and the v2 variant are
|
|
531
|
+
no longer two parallel paths; the v2 logic (P2F-prefer hoisting,
|
|
532
|
+
ZC-only with STAFF overflow, A↔A1 ZC pairing) is now the only path.
|
|
533
|
+
|
|
534
|
+
Why this is safe:
|
|
535
|
+
* Empty ``p2f_handlers`` → P2F-hoist is identity (same staff
|
|
536
|
+
order as v1).
|
|
537
|
+
* Balanced ZC counts → no orphan ZCs, no STAFF augmentation
|
|
538
|
+
happens (same STAFF pool as v1).
|
|
539
|
+
* A↔A1 ZC pairing only emits when BOTH have spare ZCs; otherwise
|
|
540
|
+
zero-op. (v1 had zero pairs at A_TO_A1 for ZCs — so any new
|
|
541
|
+
pair here is purely additive, never replaces a v1 pair.)
|
|
542
|
+
* ZC drain semantics: a single ZC pairs at most once across all
|
|
543
|
+
boundaries (in v1 a ZC could appear in two boundaries — once
|
|
544
|
+
as ``next`` and once as ``prev``). User direction 2026-05-28
|
|
545
|
+
requires drain so the "rest left zc (here 2)" overflow math
|
|
546
|
+
works for the example A=4 ZC, N=1 ZC, A1=1 ZC.
|
|
547
|
+
|
|
548
|
+
The ``p2f_adjustment.logic_v2`` config flag continues to govern the
|
|
549
|
+
P2F POST-PASS (anchored v1 vs. union-window v2) and the post-pass
|
|
550
|
+
order swap. Pair generation is no longer behind that flag.
|
|
551
|
+
"""
|
|
552
|
+
return _generate_pairs_v2(
|
|
553
|
+
staff_today, yesterday_n_staff, overrides, p2f_handlers,
|
|
554
|
+
)
|