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,366 @@
|
|
|
1
|
+
"""Floating-break post-pass (2026-09-23, soft / best-effort).
|
|
2
|
+
|
|
3
|
+
Gives each on-shift staff member a floating break somewhere mid-shift
|
|
4
|
+
(``break_pass.length_minutes`` in config.yml, 30 min by default),
|
|
5
|
+
WITHOUT changing anyone's flight count — it only performs 1-for-1
|
|
6
|
+
SWAPS between staff in the same (shift, role) bucket. Because
|
|
7
|
+
a swap is always "I give you one flight, you give me one flight",
|
|
8
|
+
both people's totals are unchanged, so the workload spread that H18 /
|
|
9
|
+
``postpass_rebalance`` already achieved is left completely intact.
|
|
10
|
+
|
|
11
|
+
Deliberately NOT built into the CP-SAT model: that would need a new
|
|
12
|
+
break-window variable plus break/flight overlap bookkeeping for
|
|
13
|
+
essentially every (flight, staff) pair in the eligibility matrix —
|
|
14
|
+
tens of thousands of new variables and constraints on top of a solve
|
|
15
|
+
that already runs ~400-700s. The risk is the solve slows down enough
|
|
16
|
+
to threaten the very H18 spread this pass exists to protect. A
|
|
17
|
+
lightweight post-pass swap gets the same practical outcome (everyone
|
|
18
|
+
gets a real, contiguous, no-flight window) at a fraction of the cost,
|
|
19
|
+
with the trade-off that it's best-effort: if no eligible swap partner
|
|
20
|
+
exists for a flight sitting in someone's best available window, that
|
|
21
|
+
flight is left in place and the break is reported as partial/absent
|
|
22
|
+
rather than forced.
|
|
23
|
+
|
|
24
|
+
Order of operations in the pipeline matters: this runs LAST, after
|
|
25
|
+
P2F, INTL, and the workload rebalance pass, so it's swapping against
|
|
26
|
+
the final, already-balanced schedule.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import contextlib
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import date as date_t
|
|
35
|
+
|
|
36
|
+
from ..config import BREAK_MINUTES_DEFAULT
|
|
37
|
+
from ..schemas import (
|
|
38
|
+
AllocationRow,
|
|
39
|
+
FlightInput,
|
|
40
|
+
OpsClass,
|
|
41
|
+
Role,
|
|
42
|
+
ShiftCode,
|
|
43
|
+
StaffMember,
|
|
44
|
+
)
|
|
45
|
+
from .eligibility import sheet_target_for
|
|
46
|
+
from .windows import (
|
|
47
|
+
SHIFT_NOMINAL_MIN,
|
|
48
|
+
SpacingKey,
|
|
49
|
+
spacing_clear,
|
|
50
|
+
spacing_key,
|
|
51
|
+
std_to_ops_day_minutes,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Default only — the Allocate run passes the configured length.
|
|
55
|
+
BREAK_LEN_MIN = BREAK_MINUTES_DEFAULT
|
|
56
|
+
# Keep the break out of the first/last hour of the shift so it reads as
|
|
57
|
+
# a genuine mid-shift break, not a shift-boundary artifact.
|
|
58
|
+
EDGE_MARGIN_MIN = 60
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt(ops_min: int) -> str:
|
|
62
|
+
day_offset, m = divmod(ops_min, 1440)
|
|
63
|
+
h, mm = divmod(m, 60)
|
|
64
|
+
suffix = " (+1d)" if day_offset else ""
|
|
65
|
+
return f"{h:02d}:{mm:02d}{suffix}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class BreakSummary:
|
|
70
|
+
n_staff_considered: int = 0
|
|
71
|
+
n_clean: int = 0 # already had a natural break-length gap
|
|
72
|
+
n_created_via_swap: int = 0 # fully cleared via 1+ swaps
|
|
73
|
+
n_partial: int = 0 # some conflicts swapped, some left
|
|
74
|
+
n_not_found: int = 0 # no window could be cleared at all
|
|
75
|
+
n_swaps: int = 0
|
|
76
|
+
workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
|
|
77
|
+
log_lines: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def note(self, staff_id: str, reason: str) -> None:
|
|
80
|
+
self.workload_notes[staff_id].append(reason)
|
|
81
|
+
|
|
82
|
+
def log(self, msg: str) -> None:
|
|
83
|
+
self.log_lines.append(msg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _transferable(flight: FlightInput | None) -> bool:
|
|
87
|
+
"""Only plain-domestic flights are swap candidates — P2F routing
|
|
88
|
+
is dedicated to specific handlers and isn't touched here."""
|
|
89
|
+
if flight is None:
|
|
90
|
+
return False
|
|
91
|
+
return flight.ops_class != OpsClass.P2F
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _best_window(
|
|
95
|
+
stds: list[int], lo: int, hi: int, break_len: int,
|
|
96
|
+
) -> tuple[int, list[int]] | None:
|
|
97
|
+
"""Find the break_len-minute window inside [lo, hi] that overlaps
|
|
98
|
+
the fewest of ``stds`` (a staff member's currently-assigned STDs,
|
|
99
|
+
in ops-day minutes). Returns (window_start, conflicting_stds), or
|
|
100
|
+
None if [lo, hi] is too narrow to fit a window at all.
|
|
101
|
+
|
|
102
|
+
Candidate starts: the range floor, the range ceiling minus the
|
|
103
|
+
break length, and "right after each existing flight" — cheap and
|
|
104
|
+
sufficient since we only need the true minimum, not every window.
|
|
105
|
+
"""
|
|
106
|
+
if hi - lo < break_len:
|
|
107
|
+
return None
|
|
108
|
+
candidates = {lo, hi - break_len}
|
|
109
|
+
for s in stds:
|
|
110
|
+
c = s + 1
|
|
111
|
+
if lo <= c <= hi - break_len:
|
|
112
|
+
candidates.add(c)
|
|
113
|
+
best: tuple[int, list[int]] | None = None
|
|
114
|
+
for c in sorted(candidates):
|
|
115
|
+
conflicts = [s for s in stds if c <= s < c + break_len]
|
|
116
|
+
if best is None or len(conflicts) < len(best[1]):
|
|
117
|
+
best = (c, conflicts)
|
|
118
|
+
if best[1] == []:
|
|
119
|
+
break
|
|
120
|
+
return best
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_break_pass(
|
|
124
|
+
rows: list[AllocationRow],
|
|
125
|
+
flights: list[FlightInput],
|
|
126
|
+
staff_today: list[StaffMember],
|
|
127
|
+
matrix: dict[str, set[str]],
|
|
128
|
+
d_day: date_t,
|
|
129
|
+
break_len: int = BREAK_LEN_MIN,
|
|
130
|
+
edge_margin: int = EDGE_MARGIN_MIN,
|
|
131
|
+
) -> tuple[list[AllocationRow], BreakSummary]:
|
|
132
|
+
summary = BreakSummary()
|
|
133
|
+
flight_by_uid = {f.unique_id: f for f in flights}
|
|
134
|
+
staff_by_id = {s.employee_id: s for s in staff_today}
|
|
135
|
+
|
|
136
|
+
rstate: dict[str, dict] = {}
|
|
137
|
+
for r in rows:
|
|
138
|
+
uid = (
|
|
139
|
+
f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
|
|
140
|
+
f"|{r.date.isoformat()}"
|
|
141
|
+
)
|
|
142
|
+
rstate[uid] = {
|
|
143
|
+
"row": r,
|
|
144
|
+
"staff_id": r.staff_employee_id,
|
|
145
|
+
"staff_name": r.staff_name,
|
|
146
|
+
"planned_by_emp": r.planned_by_employee_id,
|
|
147
|
+
"planned_by_name": r.planned_by_name,
|
|
148
|
+
"relieved_by_emp": r.relieved_by_employee_id,
|
|
149
|
+
"relieved_by_name": r.relieved_by_name,
|
|
150
|
+
"warning": r.warning,
|
|
151
|
+
"sheet_target": r.sheet_target,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Per-staff SpacingKey list + a quick uid lookup by (staff, std).
|
|
155
|
+
staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
|
|
156
|
+
uid_by_staff_std: dict[tuple[str, int], list[str]] = defaultdict(list)
|
|
157
|
+
for uid, st in rstate.items():
|
|
158
|
+
if not st["staff_id"]:
|
|
159
|
+
continue
|
|
160
|
+
f_ref = flight_by_uid.get(uid)
|
|
161
|
+
if f_ref is None:
|
|
162
|
+
continue
|
|
163
|
+
key = spacing_key(f_ref, d_day)
|
|
164
|
+
m = key[0]
|
|
165
|
+
staff_stds[st["staff_id"]].append(key)
|
|
166
|
+
uid_by_staff_std[(st["staff_id"], m)].append(uid)
|
|
167
|
+
|
|
168
|
+
buckets: dict[tuple[ShiftCode, Role], list[StaffMember]] = defaultdict(list)
|
|
169
|
+
for s in staff_today:
|
|
170
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
171
|
+
continue
|
|
172
|
+
buckets[(s.shift_today, s.role)].append(s)
|
|
173
|
+
|
|
174
|
+
assigned_break: dict[str, tuple[int, int]] = {}
|
|
175
|
+
|
|
176
|
+
def _spacing_ok(
|
|
177
|
+
staff_id: str, new_key: SpacingKey, skip_std: int | None = None,
|
|
178
|
+
) -> bool:
|
|
179
|
+
return spacing_clear(new_key, (
|
|
180
|
+
k for k in staff_stds.get(staff_id, ())
|
|
181
|
+
if skip_std is None or k[0] != skip_std
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
def _during_break(staff_id: str, std: int) -> bool:
|
|
185
|
+
win = assigned_break.get(staff_id)
|
|
186
|
+
return win is not None and win[0] <= std < win[1]
|
|
187
|
+
|
|
188
|
+
def _do_swap(uid_a: str, staff_a: str, uid_b: str, staff_b: str) -> None:
|
|
189
|
+
"""Give ``uid_a``'s flight to ``staff_b`` and ``uid_b``'s to
|
|
190
|
+
``staff_a``. Keeps both counts, staff_stds, and rstate in sync."""
|
|
191
|
+
st_a, st_b = rstate[uid_a], rstate[uid_b]
|
|
192
|
+
f_a, f_b = flight_by_uid[uid_a], flight_by_uid[uid_b]
|
|
193
|
+
std_a = std_to_ops_day_minutes(f_a.std, f_a.date, d_day)
|
|
194
|
+
std_b = std_to_ops_day_minutes(f_b.std, f_b.date, d_day)
|
|
195
|
+
recip_a = staff_by_id[staff_b] # receives uid_a
|
|
196
|
+
recip_b = staff_by_id[staff_a] # receives uid_b
|
|
197
|
+
|
|
198
|
+
entry_a = spacing_key(f_a, d_day)
|
|
199
|
+
entry_b = spacing_key(f_b, d_day)
|
|
200
|
+
with contextlib.suppress(ValueError):
|
|
201
|
+
staff_stds[staff_a].remove(entry_a)
|
|
202
|
+
with contextlib.suppress(ValueError):
|
|
203
|
+
staff_stds[staff_b].remove(entry_b)
|
|
204
|
+
staff_stds[staff_a].append(entry_b)
|
|
205
|
+
staff_stds[staff_b].append(entry_a)
|
|
206
|
+
|
|
207
|
+
st_a["staff_id"], st_a["staff_name"] = recip_a.employee_id, recip_a.name
|
|
208
|
+
st_a["sheet_target"] = sheet_target_for(f_a, recip_a)
|
|
209
|
+
st_b["staff_id"], st_b["staff_name"] = recip_b.employee_id, recip_b.name
|
|
210
|
+
st_b["sheet_target"] = sheet_target_for(f_b, recip_b)
|
|
211
|
+
|
|
212
|
+
for (shift_code, role), bucket in sorted(
|
|
213
|
+
buckets.items(), key=lambda kv: (kv[0][0], kv[0][1].value),
|
|
214
|
+
):
|
|
215
|
+
nominal = SHIFT_NOMINAL_MIN.get(shift_code)
|
|
216
|
+
if nominal is None:
|
|
217
|
+
continue
|
|
218
|
+
lo = nominal[0] + edge_margin
|
|
219
|
+
hi = nominal[1] - edge_margin
|
|
220
|
+
for s in sorted(bucket, key=lambda x: x.name):
|
|
221
|
+
summary.n_staff_considered += 1
|
|
222
|
+
window = _best_window(
|
|
223
|
+
[k[0] for k in staff_stds.get(s.employee_id, [])], lo, hi, break_len,
|
|
224
|
+
)
|
|
225
|
+
if window is None:
|
|
226
|
+
summary.n_not_found += 1
|
|
227
|
+
summary.log(
|
|
228
|
+
f"{s.name} ({shift_code}/{role.value}): shift too "
|
|
229
|
+
f"short for a {break_len}-min mid-shift window "
|
|
230
|
+
f"(margin={edge_margin})"
|
|
231
|
+
)
|
|
232
|
+
continue
|
|
233
|
+
win_start, conflicts = window
|
|
234
|
+
win_end = win_start + break_len
|
|
235
|
+
|
|
236
|
+
if not conflicts:
|
|
237
|
+
assigned_break[s.employee_id] = (win_start, win_end)
|
|
238
|
+
summary.n_clean += 1
|
|
239
|
+
summary.note(
|
|
240
|
+
s.employee_id,
|
|
241
|
+
f"Break: {_fmt(win_start)}-{_fmt(win_end)} (already clear)",
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
resolved: list[int] = []
|
|
246
|
+
unresolved: list[int] = []
|
|
247
|
+
for conflict_std in conflicts:
|
|
248
|
+
donor_uids = uid_by_staff_std.get((s.employee_id, conflict_std), [])
|
|
249
|
+
donor_uid = next(
|
|
250
|
+
(u for u in donor_uids if _transferable(flight_by_uid.get(u))),
|
|
251
|
+
None,
|
|
252
|
+
)
|
|
253
|
+
if donor_uid is None:
|
|
254
|
+
unresolved.append(conflict_std)
|
|
255
|
+
continue
|
|
256
|
+
f = flight_by_uid[donor_uid]
|
|
257
|
+
eligible_for_f = matrix.get(donor_uid, set())
|
|
258
|
+
|
|
259
|
+
swapped = False
|
|
260
|
+
for peer in sorted(bucket, key=lambda x: x.name):
|
|
261
|
+
if peer.employee_id == s.employee_id:
|
|
262
|
+
continue
|
|
263
|
+
if peer.employee_id not in eligible_for_f:
|
|
264
|
+
continue
|
|
265
|
+
if _during_break(peer.employee_id, conflict_std):
|
|
266
|
+
continue
|
|
267
|
+
if not _spacing_ok(peer.employee_id, spacing_key(f, d_day)):
|
|
268
|
+
continue
|
|
269
|
+
# Find a plain-domestic flight of peer's, outside s's
|
|
270
|
+
# break window, that s is eligible for and that fits
|
|
271
|
+
# s's schedule once the conflict flight is removed.
|
|
272
|
+
peer_uids = sorted(
|
|
273
|
+
(
|
|
274
|
+
u for u, st in rstate.items()
|
|
275
|
+
if st["staff_id"] == peer.employee_id
|
|
276
|
+
and _transferable(flight_by_uid.get(u))
|
|
277
|
+
),
|
|
278
|
+
key=lambda u: std_to_ops_day_minutes(
|
|
279
|
+
flight_by_uid[u].std, flight_by_uid[u].date, d_day,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
for g_uid in peer_uids:
|
|
283
|
+
g = flight_by_uid[g_uid]
|
|
284
|
+
g_std = std_to_ops_day_minutes(g.std, g.date, d_day)
|
|
285
|
+
if win_start <= g_std < win_end:
|
|
286
|
+
continue # would just create a new conflict
|
|
287
|
+
if s.employee_id not in matrix.get(g_uid, set()):
|
|
288
|
+
continue
|
|
289
|
+
if not _spacing_ok(
|
|
290
|
+
s.employee_id, spacing_key(g, d_day),
|
|
291
|
+
skip_std=conflict_std,
|
|
292
|
+
):
|
|
293
|
+
continue
|
|
294
|
+
_do_swap(donor_uid, s.employee_id, g_uid, peer.employee_id)
|
|
295
|
+
summary.n_swaps += 1
|
|
296
|
+
summary.note(
|
|
297
|
+
s.employee_id,
|
|
298
|
+
f"Break: swapped FLT {f.flt} to {peer.name} "
|
|
299
|
+
f"for FLT {g.flt}, to clear "
|
|
300
|
+
f"{_fmt(win_start)}-{_fmt(win_end)} break",
|
|
301
|
+
)
|
|
302
|
+
summary.note(
|
|
303
|
+
peer.employee_id,
|
|
304
|
+
f"Break: swapped FLT {g.flt} to {s.name} "
|
|
305
|
+
f"for FLT {f.flt}, to help their "
|
|
306
|
+
f"{_fmt(win_start)}-{_fmt(win_end)} break",
|
|
307
|
+
)
|
|
308
|
+
summary.log(
|
|
309
|
+
f"{shift_code}/{role.value}: break-swap "
|
|
310
|
+
f"{s.name}<->{peer.name}: {f.flt}<->{g.flt}"
|
|
311
|
+
)
|
|
312
|
+
swapped = True
|
|
313
|
+
break
|
|
314
|
+
if swapped:
|
|
315
|
+
break
|
|
316
|
+
if swapped:
|
|
317
|
+
resolved.append(conflict_std)
|
|
318
|
+
else:
|
|
319
|
+
unresolved.append(conflict_std)
|
|
320
|
+
|
|
321
|
+
if not unresolved:
|
|
322
|
+
assigned_break[s.employee_id] = (win_start, win_end)
|
|
323
|
+
summary.n_created_via_swap += 1
|
|
324
|
+
summary.note(
|
|
325
|
+
s.employee_id,
|
|
326
|
+
f"Break: {_fmt(win_start)}-{_fmt(win_end)} "
|
|
327
|
+
f"(cleared via {len(resolved)} swap(s))",
|
|
328
|
+
)
|
|
329
|
+
elif resolved:
|
|
330
|
+
summary.n_partial += 1
|
|
331
|
+
summary.note(
|
|
332
|
+
s.employee_id,
|
|
333
|
+
f"Break: best window {_fmt(win_start)}-{_fmt(win_end)} "
|
|
334
|
+
f"still has {len(unresolved)} flight(s) — no eligible "
|
|
335
|
+
"swap partner found for the rest",
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
summary.n_not_found += 1
|
|
339
|
+
summary.note(
|
|
340
|
+
s.employee_id,
|
|
341
|
+
f"Break: no eligible swap found — best window "
|
|
342
|
+
f"{_fmt(win_start)}-{_fmt(win_end)} still has "
|
|
343
|
+
f"{len(unresolved)} flight(s)",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Rebuild the rows list from rstate (AllocationRow is frozen).
|
|
347
|
+
new_rows: list[AllocationRow] = []
|
|
348
|
+
for uid, st in rstate.items():
|
|
349
|
+
f = flight_by_uid.get(uid)
|
|
350
|
+
if f is None:
|
|
351
|
+
new_rows.append(st["row"])
|
|
352
|
+
continue
|
|
353
|
+
new_rows.append(AllocationRow(
|
|
354
|
+
date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
|
|
355
|
+
pax=f.load,
|
|
356
|
+
staff_employee_id=st["staff_id"] or "",
|
|
357
|
+
staff_name=st["staff_name"] or "",
|
|
358
|
+
planned_by_employee_id=st["planned_by_emp"],
|
|
359
|
+
planned_by_name=st["planned_by_name"],
|
|
360
|
+
relieved_by_employee_id=st["relieved_by_emp"],
|
|
361
|
+
relieved_by_name=st["relieved_by_name"],
|
|
362
|
+
warning=st["warning"],
|
|
363
|
+
sheet_target=st["sheet_target"],
|
|
364
|
+
is_international=f.is_international,
|
|
365
|
+
))
|
|
366
|
+
return new_rows, summary
|