-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcrew-assign.ts
More file actions
275 lines (255 loc) · 10.5 KB
/
Copy pathcrew-assign.ts
File metadata and controls
275 lines (255 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import type { createServerClient } from "@/lib/db/client";
import type { createLogger } from "@/lib/logger";
type SupabaseLike = ReturnType<typeof createServerClient>;
type Logger = ReturnType<typeof createLogger>;
/** Fallback minutes when a work order has no est_minutes and the crew_type has
* no median to borrow (a cold DB with zero estimated work). */
export const DEFAULT_EST_MINUTES = 60;
/** One assignable crew, with the ranking signals the balancer uses. */
export interface CrewCandidate {
id: string;
name: string;
memberCount: number;
/** Count of the crew's OPEN work orders — the first load tie-break. */
openWorkOrders: number;
/** Sum of est_minutes across the crew's OPEN work orders (NULLs already
* resolved to the crew_type median / DEFAULT_EST_MINUTES by the caller). */
openMinutes: number;
/** Epoch ms of the crew's most recent assignment (max created_at of its open
* work orders), or null if it holds none — drives round-robin. */
lastAssignedAt: number | null;
}
/** Per-capita workload: minutes of open work divided by crew size, so a bigger
* crew absorbs proportionally more before it looks "loaded". memberCount is
* floored at 1 — shells (0 members) are already deprioritized by the staffed
* gate below, so this only guards against a divide-by-zero, never reorders. */
function loadPerCapita(c: CrewCandidate): number {
return c.openMinutes / Math.max(c.memberCount, 1);
}
/**
* Deterministic crew pick — no model call. Ranking (argmin load):
* 1. staffed crews before hollow shells (a shell is still assignable — the
* city created it precisely so work can route there before it's staffed —
* but a crew with people wins when one exists);
* 2. lowest workload-hours ÷ crew size (real effort balanced by team size);
* 3. fewest open work orders (spread raw count on a minutes tie);
* 4. least-recently-assigned, i.e. smallest lastAssignedAt — null (never
* assigned) sorts first, giving idle crews the next job (round-robin);
* 5. name (stable final tie-break so re-runs pick the same crew).
*/
export function pickCrew(candidates: CrewCandidate[]): CrewCandidate | null {
if (candidates.length === 0) return null;
const sorted = [...candidates].sort((a, b) => {
const aStaffed = a.memberCount > 0;
const bStaffed = b.memberCount > 0;
if (aStaffed !== bStaffed) return aStaffed ? -1 : 1;
const loadA = loadPerCapita(a);
const loadB = loadPerCapita(b);
if (loadA !== loadB) return loadA - loadB;
if (a.openWorkOrders !== b.openWorkOrders)
return a.openWorkOrders - b.openWorkOrders;
// null = never assigned, sorts before any timestamp (round-robin: the
// crew idle longest is next). Number.NEGATIVE_INFINITY models "never".
const lastA = a.lastAssignedAt ?? Number.NEGATIVE_INFINITY;
const lastB = b.lastAssignedAt ?? Number.NEGATIVE_INFINITY;
if (lastA !== lastB) return lastA - lastB;
return a.name.localeCompare(b.name);
});
return sorted[0];
}
/**
* Auto-assign a crew to a freshly created work order (AI_CREW_ASSIGN flag).
*
* Candidates: the city's ACTIVE crews in the work order's division whose
* crew_type equals the work order's crew_type — strict match, because the
* type is the semantic contract between the AI pick and the org chart. No
* match → no assignment (staff assign manually, exactly as before).
*
* Best-effort by design, mirroring the team_key stamp: any failure logs and
* returns null, never throws — the pipeline must not break on an un-migrated
* DB or a transient query error. The final UPDATE is guarded with
* `.is("assigned_crew_id", null)` so a re-run (pipeline is idempotent via
* upsert) never clobbers an assignment staff already made.
*
* `crewHint` (AI crew_hint, from the crews' own descriptions): when it names
* one of the candidates — case-insensitive exact name match — that crew wins
* outright and the load ranking is skipped. Any other value (hallucinated,
* stale, cross-division) is ignored and logged; routing quality can degrade,
* assignment correctness cannot.
*/
export async function autoAssignCrew(
supabase: SupabaseLike,
opts: {
workOrderId: string;
cityId: string;
teamKey: string;
crewType: string;
crewHint?: string | null;
log: Logger;
},
): Promise<string | null> {
const { workOrderId, cityId, teamKey, crewType, crewHint, log } = opts;
try {
const { data: crewData, error: crewErr } = await supabase
.from("crews")
.select("id, name")
.eq("city_id", cityId)
.eq("team_key", teamKey)
.eq("crew_type", crewType)
.eq("active", true);
if (crewErr) {
log.warn("crew_auto_assign_query_failed", {
workOrderId,
error: crewErr.message,
});
return null;
}
const crews = (crewData ?? []) as { id: string; name: string }[];
if (crews.length === 0) return null;
// AI hint first: an exact (case-insensitive) name match among the
// candidates ends the search — the model chose from these very crews'
// descriptions, so its pick outranks the load heuristic. No match →
// ignore the hint and rank as usual.
const hint = crewHint?.trim().toLowerCase();
let chosen: { id: string; name: string } | null = null;
if (hint) {
chosen = crews.find((c) => c.name.trim().toLowerCase() === hint) ?? null;
if (!chosen) {
log.warn("crew_auto_assign_hint_miss", { workOrderId, crewHint });
}
}
if (!chosen) {
const crewIds = crews.map((c) => c.id);
// Roster sizes — a hollow shell has zero rows here.
const memberCounts = new Map<string, number>();
const { data: memberData, error: memberErr } = await supabase
.from("crew_members")
.select("crew_id")
.in("crew_id", crewIds);
if (memberErr) {
log.warn("crew_auto_assign_members_failed", {
workOrderId,
error: memberErr.message,
});
}
for (const m of (memberData ?? []) as { crew_id: string }[]) {
memberCounts.set(m.crew_id, (memberCounts.get(m.crew_id) ?? 0) + 1);
}
// Current load — open (not yet completed) work orders per crew. The
// report status join matters: rejected/merged reports never get their
// work order completed_at stamped, so completed_at alone would count
// dead work forever and permanently skew the ranking. est_minutes drives
// the workload-hours side of the balancer; created_at is the recency
// signal for the round-robin tie-break.
const openCounts = new Map<string, number>();
const openMinutes = new Map<string, number>();
const lastAssignedAt = new Map<string, number>();
const { data: woData, error: woErr } = await supabase
.from("work_orders")
.select("assigned_crew_id, est_minutes, created_at, reports(status)")
.in("assigned_crew_id", crewIds)
.is("completed_at", null);
if (woErr) {
log.warn("crew_auto_assign_load_failed", {
workOrderId,
error: woErr.message,
});
}
const DEAD_STATUSES = new Set(["closed", "merged", "rejected"]);
// Double cast: supabase-js infers the reports embed as an array, but the
// work_orders→reports FK is many-to-one so PostgREST returns an object.
const liveOrders = (
(woData ?? []) as unknown as {
assigned_crew_id: string | null;
est_minutes: number | null;
created_at: string | null;
reports: { status: string } | null;
}[]
).filter(
(w) =>
w.assigned_crew_id &&
!(w.reports && DEAD_STATUSES.has(w.reports.status)),
);
// NULL est_minutes borrows the median of the estimated open work in this
// candidate set (same crew_type + division), falling back to a fixed
// default when nothing is estimated yet — so an un-estimated order still
// contributes realistic load instead of zero.
const estimated = liveOrders
.map((w) => w.est_minutes)
.filter((m): m is number => m != null && m > 0)
.sort((a, b) => a - b);
const median =
estimated.length > 0
? estimated[Math.floor((estimated.length - 1) / 2)]
: DEFAULT_EST_MINUTES;
for (const w of liveOrders) {
const crewId = w.assigned_crew_id as string;
openCounts.set(crewId, (openCounts.get(crewId) ?? 0) + 1);
const minutes =
w.est_minutes != null && w.est_minutes > 0 ? w.est_minutes : median;
openMinutes.set(crewId, (openMinutes.get(crewId) ?? 0) + minutes);
const ts = w.created_at ? Date.parse(w.created_at) : NaN;
if (!Number.isNaN(ts)) {
lastAssignedAt.set(
crewId,
Math.max(
lastAssignedAt.get(crewId) ?? Number.NEGATIVE_INFINITY,
ts,
),
);
}
}
const candidates: CrewCandidate[] = crews.map((c) => ({
id: c.id,
name: c.name,
memberCount: memberCounts.get(c.id) ?? 0,
openWorkOrders: openCounts.get(c.id) ?? 0,
openMinutes: openMinutes.get(c.id) ?? 0,
lastAssignedAt: lastAssignedAt.get(c.id) ?? null,
}));
const picked = pickCrew(candidates);
if (!picked) return null;
chosen = { id: picked.id, name: picked.name };
// Observability: the load inputs behind the pick, for balancer tuning.
log.info("crew_auto_assign_loads", {
workOrderId,
crewType,
candidates: candidates.map((c) => ({
id: c.id,
members: c.memberCount,
openWorkOrders: c.openWorkOrders,
openMinutes: c.openMinutes,
loadPerCapita: c.openMinutes / Math.max(c.memberCount, 1),
})),
});
}
const { error: updateErr } = await supabase
.from("work_orders")
.update({ assigned_crew_id: chosen.id })
.eq("id", workOrderId)
.is("assigned_crew_id", null);
if (updateErr) {
log.warn("crew_auto_assign_update_failed", {
workOrderId,
crewId: chosen.id,
error: updateErr.message,
});
return null;
}
log.info("crew_auto_assigned", {
workOrderId,
crewId: chosen.id,
crewName: chosen.name,
crewType,
teamKey,
viaHint: Boolean(hint && chosen.name.trim().toLowerCase() === hint),
});
return chosen.id;
} catch (err) {
log.warn("crew_auto_assign_threw", {
workOrderId,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}