@cyc-seattle/platform 1.35.0 → 1.37.0
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.
- package/README.md +7 -7
- package/dist/outputs.d.ts +2 -0
- package/dist/outputs.js +4 -0
- package/dist/planning/activities.js +1 -1
- package/dist/planning/context.d.ts +6 -1
- package/dist/planning/context.js +21 -3
- package/dist/planning/engine.js +1 -1
- package/dist/planning/staffing.d.ts +27 -27
- package/dist/planning/staffing.js +353 -344
- package/dist/planning/types.d.ts +54 -39
- package/dist/schema/index.d.ts +70 -53
- package/package.json +1 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isoWeekday, within } from "./dates.js";
|
|
2
1
|
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
3
2
|
export function enrollmentOf(offering) {
|
|
4
3
|
if (offering.target_enrollment != null)
|
|
@@ -11,33 +10,29 @@ function sum(values) {
|
|
|
11
10
|
total += v;
|
|
12
11
|
return total;
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
return
|
|
13
|
+
function groupBy(rows, key) {
|
|
14
|
+
const groups = new Map();
|
|
15
|
+
for (const row of rows)
|
|
16
|
+
groups.set(key(row), [...(groups.get(key(row)) ?? []), row]);
|
|
17
|
+
return groups;
|
|
19
18
|
}
|
|
20
19
|
export class Staffing {
|
|
21
20
|
ctx;
|
|
22
|
-
boatClasses;
|
|
23
|
-
offeringBoatClasses = new Map();
|
|
24
21
|
covers = new Map();
|
|
25
22
|
warned = new Set();
|
|
26
23
|
constructor(ctx) {
|
|
27
24
|
this.ctx = ctx;
|
|
28
|
-
this.boatClasses = new Map(ctx.data.boat_classes.map((b) => [b.id, b]));
|
|
29
|
-
for (const row of ctx.data.plan_offering_boat_classes) {
|
|
30
|
-
this.offeringBoatClasses.set(row.offering_id, [...(this.offeringBoatClasses.get(row.offering_id) ?? []), row]);
|
|
31
|
-
}
|
|
32
25
|
for (const c of ctx.data.qualification_covers) {
|
|
33
26
|
this.covers.set(c.qualification_id, new Set([...(this.covers.get(c.qualification_id) ?? []), c.covers_id]));
|
|
34
27
|
}
|
|
35
28
|
}
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
/** Warns once for each row and plan copy, about the first date it's seen on. */
|
|
30
|
+
warnOnce(code, message, key, scope = {}) {
|
|
31
|
+
const id = `${code}|${key}|${scope.version_id ?? ""}`;
|
|
32
|
+
if (this.warned.has(id))
|
|
38
33
|
return;
|
|
39
|
-
this.warned.add(
|
|
40
|
-
this.ctx.warn(code, message, key);
|
|
34
|
+
this.warned.add(id);
|
|
35
|
+
this.ctx.warn(code, message, key, scope);
|
|
41
36
|
}
|
|
42
37
|
/** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
|
|
43
38
|
qualifies(held, required) {
|
|
@@ -54,383 +49,378 @@ export class Staffing {
|
|
|
54
49
|
}
|
|
55
50
|
return false;
|
|
56
51
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const perBoat = this.boatClasses.get(classId)?.sailors_per_boat;
|
|
77
|
-
if (perBoat == null || perBoat <= 0) {
|
|
78
|
-
if (n > 0) {
|
|
79
|
-
this.warnOnce("no_sailors_per_boat", classId === ""
|
|
80
|
-
? `Offering ${offeringKey} sails boats but names no boat class`
|
|
81
|
-
: `Boat class ${classId} has no sailors_per_boat, so offering ${offeringKey} counts no boats in it`, offeringKey);
|
|
82
|
-
}
|
|
52
|
+
/**
|
|
53
|
+
* Gathers each block's activities, times and allocations. A block's paid time runs from its first
|
|
54
|
+
* activity's start to its last one's end, plus the longest setup and put-away. Its time on the
|
|
55
|
+
* water is the longest `on_water_minutes` its activities set, or else the span of those that sail.
|
|
56
|
+
*/
|
|
57
|
+
blocks(scheduled) {
|
|
58
|
+
const { data } = this.ctx;
|
|
59
|
+
const staff = groupBy(data.plan_block_staff, (r) => r.block_id);
|
|
60
|
+
const resources = groupBy(data.plan_block_resources, (r) => r.block_id);
|
|
61
|
+
const boats = groupBy(data.plan_block_boats, (r) => r.block_id);
|
|
62
|
+
const rows = new Map(data.plan_activity_blocks.map((b) => [b.id, b]));
|
|
63
|
+
const members = new Map();
|
|
64
|
+
for (const a of scheduled) {
|
|
65
|
+
const id = a.stored.block_id;
|
|
66
|
+
if (id == null)
|
|
67
|
+
continue;
|
|
68
|
+
const row = rows.get(id);
|
|
69
|
+
if (row === undefined) {
|
|
70
|
+
this.ctx.warn("unknown_block", `Activity ${a.stored.key} names a missing block`, a.stored.key);
|
|
83
71
|
continue;
|
|
84
72
|
}
|
|
85
|
-
|
|
73
|
+
if (row.date !== a.date) {
|
|
74
|
+
this.ctx.warn("block_date_mismatch", `${a.offering.name}'s activity on ${a.date} is in ${row.name}, which is on ${row.date}`, a.stored.key, { version_id: a.stored.version_id, date: a.date });
|
|
75
|
+
}
|
|
76
|
+
members.set(id, [...(members.get(id) ?? []), a]);
|
|
86
77
|
}
|
|
87
|
-
|
|
78
|
+
const result = new Map();
|
|
79
|
+
for (const [id, list] of members) {
|
|
80
|
+
const row = rows.get(id);
|
|
81
|
+
const span = timeSpan(list);
|
|
82
|
+
const sailing = list.filter((a) => a.uses_boats);
|
|
83
|
+
const set = sailing.flatMap((a) => (a.stored.on_water_minutes == null ? [] : [a.stored.on_water_minutes]));
|
|
84
|
+
const sailingSpan = timeSpan(sailing);
|
|
85
|
+
const sailingMinutes = sailingSpan.start === null || sailingSpan.end === null
|
|
86
|
+
? Math.max(0, ...sailing.map((a) => a.on_water_minutes))
|
|
87
|
+
: sailingSpan.end - sailingSpan.start;
|
|
88
|
+
// Each offering's sailors count once, split among its activities in the block.
|
|
89
|
+
const enrollment = (a) => this.ctx.enrollment.get(a.offering.id) ?? 0;
|
|
90
|
+
const byOffering = groupBy(list, (a) => a.offering.id);
|
|
91
|
+
const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
|
|
92
|
+
const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
|
|
93
|
+
result.set(id, {
|
|
94
|
+
row,
|
|
95
|
+
members: list,
|
|
96
|
+
...span,
|
|
97
|
+
paid_minutes: (span.start === null || span.end === null ? 0 : span.end - span.start) +
|
|
98
|
+
Math.max(0, ...list.map((a) => a.stored.setup_minutes ?? 0)) +
|
|
99
|
+
Math.max(0, ...list.map((a) => a.stored.putaway_minutes ?? 0)),
|
|
100
|
+
on_water_minutes: set.length > 0 ? Math.max(...set) : sailingMinutes,
|
|
101
|
+
staff: staff.get(id) ?? [],
|
|
102
|
+
resources: resources.get(id) ?? [],
|
|
103
|
+
boats: boats.get(id) ?? [],
|
|
104
|
+
shares: new Map(list.map((a) => [a, share(a)])),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
88
108
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
boats = new Map();
|
|
102
|
-
else if (fixedBoats != null) {
|
|
103
|
-
const total = sum(sailors.values());
|
|
104
|
-
boats = new Map([...sailors].map(([c, n]) => [c, total > 0 ? (fixedBoats * n) / total : fixedBoats / sailors.size]));
|
|
109
|
+
/** Warns about each activity, other than travel, that's in no block, or in one with no staff. */
|
|
110
|
+
checkUnallocated(scheduled, blocks) {
|
|
111
|
+
for (const a of scheduled) {
|
|
112
|
+
if (a.kind === "travel")
|
|
113
|
+
continue;
|
|
114
|
+
const block = a.stored.block_id == null ? undefined : blocks.get(a.stored.block_id);
|
|
115
|
+
if (block !== undefined && sum(block.staff.map((s) => s.count)) > 0)
|
|
116
|
+
continue;
|
|
117
|
+
this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} on ${a.date} has no staff`, a.stored.key, {
|
|
118
|
+
version_id: a.stored.version_id,
|
|
119
|
+
date: a.date,
|
|
120
|
+
});
|
|
105
121
|
}
|
|
106
|
-
else
|
|
107
|
-
boats = this.boatsFor(sailors, offering.key);
|
|
108
|
-
return {
|
|
109
|
-
a,
|
|
110
|
-
enrollment,
|
|
111
|
-
coaches,
|
|
112
|
-
ribs,
|
|
113
|
-
boats,
|
|
114
|
-
sailors,
|
|
115
|
-
fixed: { coaches: fixedCoaches != null, ribs: fixedRibs != null, boats: fixedBoats != null },
|
|
116
|
-
salaried: 0,
|
|
117
|
-
positions: [],
|
|
118
|
-
hourlyPositions: [],
|
|
119
|
-
level: undefined,
|
|
120
|
-
block: null,
|
|
121
|
-
group: null,
|
|
122
|
-
};
|
|
123
122
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
/** Each program's settings, by its product. */
|
|
124
|
+
programs() {
|
|
125
|
+
return new Map(this.ctx.data.plan_programs.map((p) => [p.product_id, p]));
|
|
126
|
+
}
|
|
127
|
+
/** Whether a staff member holding `held`, at level `levelKey` if any, may coach a program coached at `required`. */
|
|
128
|
+
qualifiedFor(held, levelKey, required) {
|
|
129
|
+
if (levelKey === required)
|
|
130
|
+
return true;
|
|
131
|
+
const level = this.ctx.data.plan_staff_levels.find((l) => l.key === required);
|
|
132
|
+
const needed = level?.qualifications ?? [];
|
|
133
|
+
return needed.length > 0 && needed.every((q) => this.qualifies(held, q));
|
|
130
134
|
}
|
|
131
135
|
/**
|
|
132
|
-
*
|
|
133
|
-
* count
|
|
136
|
+
* Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
|
|
137
|
+
* sailors count against its own sailors per coach, and a coach counts if they're at the staff
|
|
138
|
+
* level of one of the block's programs, or qualified for it. Boats count the ones sailors bring.
|
|
134
139
|
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
s.ribs = coaches > 0 ? (ribTotal * s.coaches) / coaches : 0;
|
|
159
|
-
}
|
|
160
|
-
const boated = group.filter((s) => !s.fixed.boats && s.a.uses_boats);
|
|
161
|
-
const sailorsByClass = new Map();
|
|
162
|
-
for (const s of boated) {
|
|
163
|
-
for (const [c, n] of s.sailors)
|
|
164
|
-
sailorsByClass.set(c, (sailorsByClass.get(c) ?? 0) + n);
|
|
140
|
+
checkCapacity(block, programs) {
|
|
141
|
+
const { ctx } = this;
|
|
142
|
+
const offerings = [
|
|
143
|
+
...new Map(block.members.filter((a) => a.kind !== "travel").map((a) => [a.offering.id, a.offering])).values(),
|
|
144
|
+
];
|
|
145
|
+
const sailors = sum(offerings.map((o) => ctx.enrollment.get(o.id) ?? 0));
|
|
146
|
+
if (sailors <= 0)
|
|
147
|
+
return;
|
|
148
|
+
const scope = { version_id: block.row.version_id, date: block.row.date };
|
|
149
|
+
const where = `${block.row.date}, ${block.row.name}`;
|
|
150
|
+
let load = 0;
|
|
151
|
+
let counted = 0;
|
|
152
|
+
const required = [];
|
|
153
|
+
for (const o of offerings) {
|
|
154
|
+
const settings = programs.get(o.product_id);
|
|
155
|
+
if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
|
|
156
|
+
this.warnOnce("no_program_settings", `${o.name}'s program has no staff level or sailors per coach, so its sailors aren't checked against its staff`, o.product_id, { version_id: o.version_id, date: block.row.date });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const n = ctx.enrollment.get(o.id) ?? 0;
|
|
160
|
+
load += n / settings.sailors_per_coach;
|
|
161
|
+
counted += n;
|
|
162
|
+
required.push(settings.staff_level_key);
|
|
165
163
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
if (counted > 0) {
|
|
165
|
+
const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
|
|
166
|
+
const positions = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
|
|
167
|
+
const qualified = sum(block.staff.map((s) => {
|
|
168
|
+
const held = s.position_key != null
|
|
169
|
+
? (positions.get(s.position_key)?.qualifications ?? [])
|
|
170
|
+
: (levels.get(s.staff_level_key ?? "")?.qualifications ?? []);
|
|
171
|
+
const level = s.position_key != null ? null : s.staff_level_key;
|
|
172
|
+
return required.some((r) => this.qualifiedFor(held, level, r)) ? s.count : 0;
|
|
171
173
|
}));
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// slots. Those paid out of a salary mean fewer hourly coaches are paid.
|
|
175
|
-
// An offering with a fixed coach count keeps out of the shared coaches, so its coach does too.
|
|
176
|
-
for (const s of group)
|
|
177
|
-
if (s.fixed.coaches)
|
|
178
|
-
this.staffNamedCoach(s);
|
|
179
|
-
const named = (pay) => coached.flatMap((s) => s.a.offering.coach_position_key != null && payOf(s.a.offering) === pay ? [s.a.offering.coach_position_key] : []);
|
|
180
|
-
const positions = [...new Set([...(block.salaried_positions ?? []), ...named("salary")])];
|
|
181
|
-
const hourlyPositions = [...new Set(named("hourly"))].filter((key) => !positions.includes(key));
|
|
182
|
-
if (coachTotal > 0) {
|
|
183
|
-
const salariedTotal = Math.min(positions.length, coachTotal);
|
|
184
|
-
const hourlyTotal = Math.min(hourlyPositions.length, coachTotal - salariedTotal);
|
|
185
|
-
for (const s of coached) {
|
|
186
|
-
s.salaried = (salariedTotal * s.coaches) / coachTotal;
|
|
187
|
-
s.positions =
|
|
188
|
-
salariedTotal > 0 ? positions.map((key) => ({ key, coaches: s.salaried / positions.length })) : [];
|
|
189
|
-
const hourly = (hourlyTotal * s.coaches) / coachTotal;
|
|
190
|
-
s.hourlyPositions =
|
|
191
|
-
hourlyTotal > 0 ? hourlyPositions.map((key) => ({ key, coaches: hourly / hourlyPositions.length })) : [];
|
|
174
|
+
if (load > qualified + 1e-9) {
|
|
175
|
+
ctx.warn("over_capacity", `${where}: ${counted} sailors, but its staff can take ${Math.floor((qualified * counted) / load + 1e-9)}`, block.row.key, scope);
|
|
192
176
|
}
|
|
193
177
|
}
|
|
194
|
-
|
|
195
|
-
/** An offering's named coach, on an activity outside any block: one of its coaches. */
|
|
196
|
-
staffNamedCoach(s) {
|
|
197
|
-
const key = s.a.offering.coach_position_key;
|
|
198
|
-
if (key == null || s.a.kind === "travel" || s.coaches <= 0)
|
|
178
|
+
if (!block.members.some((a) => a.uses_boats))
|
|
199
179
|
return;
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
180
|
+
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
181
|
+
let seats = 0;
|
|
182
|
+
for (const b of block.boats) {
|
|
183
|
+
const perBoat = classes.get(b.boat_class_id)?.sailors_per_boat;
|
|
184
|
+
if (perBoat == null || perBoat <= 0) {
|
|
185
|
+
this.warnOnce("no_sailors_per_boat", `Boat class ${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no sailors per boat, so its boats take no sailors`, b.boat_class_id, scope);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
seats += (b.count + (b.sailor_owned ?? 0)) * perBoat;
|
|
204
189
|
}
|
|
205
|
-
|
|
206
|
-
|
|
190
|
+
if (sailors > seats) {
|
|
191
|
+
ctx.warn("over_capacity", `${where}: ${sailors} sailors, but its boats can take ${seats}`, block.row.key, scope);
|
|
207
192
|
}
|
|
208
193
|
}
|
|
209
194
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
195
|
+
* Warns when the organization's boats of one class, or a resource such as RIBs, are given to more
|
|
196
|
+
* blocks at once on a date than it has. Boats sailors bring don't count.
|
|
212
197
|
*/
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
for (const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
checkInventory(blocks) {
|
|
199
|
+
const { ctx } = this;
|
|
200
|
+
const dates = groupBy(blocks, (b) => b.row.date);
|
|
201
|
+
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
202
|
+
const fleet = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r.quantity]));
|
|
203
|
+
const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
|
|
204
|
+
for (const [date, list] of dates) {
|
|
205
|
+
const check = (code, what, have, used) => {
|
|
206
|
+
if (have == null)
|
|
207
|
+
return;
|
|
208
|
+
const out = list.flatMap((b) => (used(b) > 0 ? [{ block: b, n: used(b) }] : []));
|
|
209
|
+
const peak = mostAtOnce(out);
|
|
210
|
+
if (peak.total <= have)
|
|
211
|
+
return;
|
|
212
|
+
const names = peak.at.map(({ block, n }) => `${block.row.name} ${n}`).join(", ");
|
|
213
|
+
for (const version of new Set(peak.at.map(({ block }) => block.row.version_id))) {
|
|
214
|
+
ctx.warn(code, `${date}: ${peak.total} ${what} out, but the organization has ${have} (${names})`, what, {
|
|
215
|
+
version_id: version,
|
|
216
|
+
date: date,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const classIds = new Set(list.flatMap((b) => b.boats.filter((x) => x.count > 0).map((x) => x.boat_class_id)));
|
|
221
|
+
for (const id of classIds) {
|
|
222
|
+
if (fleet.get(id) == null) {
|
|
223
|
+
for (const b of list.filter((x) => x.boats.some((y) => y.boat_class_id === id && y.count > 0))) {
|
|
224
|
+
this.warnOnce("no_fleet_quantity", `The Operations Plan doesn't say how many ${classes.get(id)?.name ?? id} the organization has, so they aren't checked`, id, { version_id: b.row.version_id, date });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
check("over_inventory_boats", classes.get(id)?.name ?? id, fleet.get(id), (b) => sum(b.boats.filter((x) => x.boat_class_id === id).map((x) => x.count)));
|
|
224
228
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
const resourceKeys = new Set(list.flatMap((b) => b.resources.map((x) => x.resource_key)));
|
|
230
|
+
for (const key of resourceKeys) {
|
|
231
|
+
const resource = resources.get(key);
|
|
232
|
+
check("over_inventory_resource", resource?.name ?? key, resource?.quantity, (b) => sum(b.resources.filter((x) => x.resource_key === key).map((x) => x.quantity)));
|
|
228
233
|
}
|
|
229
234
|
}
|
|
230
|
-
return groups;
|
|
231
235
|
}
|
|
232
236
|
/**
|
|
233
|
-
* Warns when a salaried position coaches two
|
|
234
|
-
*
|
|
237
|
+
* Warns when a salaried position coaches two blocks whose times overlap. It's still charged to
|
|
238
|
+
* both. Returns each position's double-booked hours and dates.
|
|
235
239
|
*/
|
|
236
|
-
checkDoubleBooked(
|
|
240
|
+
checkDoubleBooked(blocks) {
|
|
237
241
|
const byDay = new Map();
|
|
238
|
-
for (const
|
|
239
|
-
for (const
|
|
240
|
-
if (
|
|
242
|
+
for (const b of blocks) {
|
|
243
|
+
for (const s of b.staff) {
|
|
244
|
+
if (s.position_key == null || s.count <= 0)
|
|
241
245
|
continue;
|
|
242
|
-
const id = `${
|
|
243
|
-
byDay.set(id, [...(byDay.get(id) ?? []),
|
|
246
|
+
const id = `${s.position_key}|${b.row.date}`;
|
|
247
|
+
byDay.set(id, [...(byDay.get(id) ?? []), b]);
|
|
244
248
|
}
|
|
245
249
|
}
|
|
246
250
|
const names = new Map(this.ctx.data.plan_salaried_positions.map((p) => [p.key, p.name]));
|
|
247
251
|
const result = new Map();
|
|
248
252
|
for (const [id, list] of byDay) {
|
|
249
253
|
const [key, date] = id.split("|");
|
|
250
|
-
const clash = list.flatMap((x, i) => list
|
|
251
|
-
.slice(i + 1)
|
|
252
|
-
.flatMap((y) => ((x.group !== null && x.group === y.group) || !overlaps(x, y) ? [] : [[x, y]])))[0];
|
|
254
|
+
const clash = list.flatMap((x, i) => list.slice(i + 1).flatMap((y) => (overlaps(x, y) ? [[x, y]] : [])))[0];
|
|
253
255
|
if (clash === undefined)
|
|
254
256
|
continue;
|
|
255
257
|
const hours = extraCoachMinutes(list) / 60;
|
|
256
|
-
const
|
|
257
|
-
result.set(key, { hours:
|
|
258
|
-
|
|
259
|
-
(
|
|
258
|
+
const total = result.get(key) ?? { hours: 0, dates: 0 };
|
|
259
|
+
result.set(key, { hours: total.hours + hours, dates: total.dates + 1 });
|
|
260
|
+
for (const version of new Set(list.map((b) => b.row.version_id))) {
|
|
261
|
+
this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time on ${date}` +
|
|
262
|
+
(hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { version_id: version, date });
|
|
263
|
+
}
|
|
260
264
|
}
|
|
261
265
|
return result;
|
|
262
266
|
}
|
|
263
|
-
/**
|
|
264
|
-
* One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
|
|
265
|
-
* it from resources excluded from MTDC, such as rentals.
|
|
266
|
-
*/
|
|
267
|
-
ribRate() {
|
|
268
|
-
const kind = this.ctx.data.rib_resource_kind_id;
|
|
269
|
-
if (kind == null)
|
|
270
|
-
return undefined;
|
|
271
|
-
const ribs = this.ctx.data.plan_resources.filter((r) => r.resource_kind_id === kind);
|
|
272
|
-
if (ribs.length === 0)
|
|
273
|
-
return undefined;
|
|
274
|
-
const weight = (r) => r.quantity ?? 1;
|
|
275
|
-
const totalWeight = sum(ribs.map(weight));
|
|
276
|
-
const average = (rows, cost) => sum(rows.map((r) => weight(r) * cost(r))) / totalWeight;
|
|
277
|
-
const rentals = ribs.filter((r) => r.excluded_from_mtdc);
|
|
278
|
-
return {
|
|
279
|
-
hourly: average(ribs, hourlyCost),
|
|
280
|
-
daily: average(ribs, (r) => r.cost_per_day_cents ?? 0),
|
|
281
|
-
hourlyRental: average(rentals, hourlyCost),
|
|
282
|
-
rental: average(rentals, (r) => r.cost_per_day_cents ?? 0),
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
/** Staffs every activity, and records what its coaches, RIBs, boats and resources cost. */
|
|
267
|
+
/** Costs every block's staff, RIBs, boats and resources, and checks them against the limits. */
|
|
286
268
|
run(scheduled) {
|
|
287
269
|
const { ctx } = this;
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const id = `${block.id}|${s.a.date}`;
|
|
297
|
-
const day = days.get(id) ?? { block, members: [] };
|
|
298
|
-
day.members.push(s);
|
|
299
|
-
days.set(id, day);
|
|
300
|
-
}
|
|
301
|
-
for (const [id, { block, members }] of days) {
|
|
302
|
-
this.overlapping(members).forEach((group, i) => {
|
|
303
|
-
for (const s of group)
|
|
304
|
-
s.group = `${id}|${i}`;
|
|
305
|
-
this.staffBlock(block, group);
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
const doubleBooked = this.checkDoubleBooked(staffed);
|
|
270
|
+
const blocks = this.blocks(scheduled);
|
|
271
|
+
this.checkUnallocated(scheduled, blocks);
|
|
272
|
+
const programs = this.programs();
|
|
273
|
+
// By date, so a warning given once names the first date it's seen on.
|
|
274
|
+
for (const block of byDate(blocks))
|
|
275
|
+
this.checkCapacity(block, programs);
|
|
276
|
+
this.checkInventory(byDate(blocks));
|
|
277
|
+
const doubleBooked = this.checkDoubleBooked([...blocks.values()]);
|
|
309
278
|
const coaching = [];
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
resourcesByActivity.set(r.activity_id, [...(resourcesByActivity.get(r.activity_id) ?? []), r]);
|
|
313
|
-
}
|
|
279
|
+
const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
|
|
280
|
+
const positions = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
|
|
314
281
|
const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
|
|
315
282
|
const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
283
|
+
const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
284
|
+
const ribKind = ctx.data.rib_resource_kind_id;
|
|
285
|
+
const given = new Map();
|
|
286
|
+
for (const block of byDate(blocks)) {
|
|
287
|
+
const scope = { version_id: block.row.version_id, date: block.row.date };
|
|
288
|
+
const paidHours = block.paid_minutes / 60;
|
|
289
|
+
const waterHours = block.on_water_minutes / 60;
|
|
290
|
+
// Each cost is split among the block's activities by their offerings' sailors.
|
|
324
291
|
const book = (type, amount, line, name, explanation, mtdcShare = 1) => {
|
|
325
292
|
if (amount === 0)
|
|
326
293
|
return;
|
|
327
294
|
const account = ctx.costAccount(type);
|
|
328
295
|
if (account === undefined)
|
|
329
296
|
return;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
s.level = this.levelFor(offering, s.block);
|
|
344
|
-
const hourly = s.coaches - s.salaried;
|
|
345
|
-
if (offering.required_qualification != null && s.level !== undefined) {
|
|
346
|
-
if (!this.qualifies(s.level.qualifications ?? [], offering.required_qualification)) {
|
|
347
|
-
this.warnOnce("unqualified_level", `Offering ${key}'s coach level ${s.level.name} lacks its required qualification`, key);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
if (hourly > 0 && s.level === undefined) {
|
|
351
|
-
this.warnOnce("no_coach_level", `Offering ${key} has coaches but no coach level, so they cost nothing`, key);
|
|
352
|
-
}
|
|
353
|
-
const volunteer = s.level?.pay_type === "volunteer";
|
|
354
|
-
const rate = volunteer ? 0 : (s.level?.hourly_rate_cents ?? 0);
|
|
355
|
-
// A salaried coach paid by the hour may have their own rate, even on a volunteer level.
|
|
356
|
-
const rateOf = (p) => salaried.get(p.key)?.hourly_coaching_rate_cents ?? rate;
|
|
357
|
-
const own = s.hourlyPositions.reduce((t, p) => t + p.coaches * (rateOf(p) - rate), 0);
|
|
358
|
-
const wages = (hourly * rate + own) * paidHours;
|
|
359
|
-
book("coach_wages", wages, "coach_wages", "Coach wages", "Hourly coaches × paid hours × each one's rate");
|
|
360
|
-
for (const p of s.positions) {
|
|
361
|
-
coaching.push({
|
|
362
|
-
position_key: p.key,
|
|
363
|
-
offering_key: key,
|
|
364
|
-
hours: p.coaches * paidHours,
|
|
365
|
-
pay: "salary",
|
|
366
|
-
wages: 0,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
for (const p of s.hourlyPositions) {
|
|
370
|
-
const hours = p.coaches * paidHours;
|
|
371
|
-
coaching.push({ position_key: p.key, offering_key: key, hours, pay: "hourly", wages: hours * rateOf(p) });
|
|
372
|
-
}
|
|
373
|
-
if (s.ribs > 0 && waterHours > 0) {
|
|
374
|
-
if (ribRate === undefined) {
|
|
375
|
-
this.warnOnce("no_rib_rate", "No RIB resource is in the plan, so RIBs cost nothing", "ribs");
|
|
297
|
+
for (const [a, share] of block.shares) {
|
|
298
|
+
ctx.book({
|
|
299
|
+
cost_center_type: "offering",
|
|
300
|
+
cost_center_key: a.offering.key,
|
|
301
|
+
account_id: account,
|
|
302
|
+
function: ctx.functionFor(account, null, "offering"),
|
|
303
|
+
amount: amount * share,
|
|
304
|
+
line_key: `${line}:${a.offering.key}`,
|
|
305
|
+
line_name: name,
|
|
306
|
+
explanation,
|
|
307
|
+
mtdc_share: mtdcShare,
|
|
308
|
+
wages: type === "coach_wages",
|
|
309
|
+
});
|
|
376
310
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
for (const [classId, boats] of s.boats) {
|
|
384
|
-
if (boats <= 0 || waterHours <= 0)
|
|
311
|
+
};
|
|
312
|
+
let wages = 0;
|
|
313
|
+
for (const s of block.staff) {
|
|
314
|
+
if (s.count <= 0)
|
|
385
315
|
continue;
|
|
386
|
-
|
|
387
|
-
|
|
316
|
+
if (s.position_key != null) {
|
|
317
|
+
const position = positions.get(s.position_key);
|
|
318
|
+
const hours = s.count * paidHours;
|
|
319
|
+
if (s.pay === "hourly") {
|
|
320
|
+
const rate = position?.hourly_coaching_rate_cents;
|
|
321
|
+
if (position !== undefined && rate == null) {
|
|
322
|
+
this.warnOnce("no_hourly_rate", `${position.name} is paid by the hour to coach but has no hourly rate, so those hours cost nothing`, position.key, scope);
|
|
323
|
+
}
|
|
324
|
+
wages += hours * (rate ?? 0);
|
|
325
|
+
for (const [a, share] of block.shares) {
|
|
326
|
+
coaching.push({
|
|
327
|
+
position_key: s.position_key,
|
|
328
|
+
offering_key: a.offering.key,
|
|
329
|
+
hours: hours * share,
|
|
330
|
+
pay: "hourly",
|
|
331
|
+
wages: hours * share * (rate ?? 0),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
for (const [a, share] of block.shares) {
|
|
337
|
+
coaching.push({
|
|
338
|
+
position_key: s.position_key,
|
|
339
|
+
offering_key: a.offering.key,
|
|
340
|
+
hours: hours * share,
|
|
341
|
+
pay: "salary",
|
|
342
|
+
wages: 0,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const level = s.staff_level_key == null ? undefined : levels.get(s.staff_level_key);
|
|
349
|
+
if (level === undefined) {
|
|
350
|
+
this.warnOnce("unknown_staff_level", `${block.row.name} has staff at a missing staff level, so they cost nothing`, s.staff_level_key ?? s.key, scope);
|
|
388
351
|
continue;
|
|
389
|
-
|
|
352
|
+
}
|
|
353
|
+
if (level.pay_type !== "volunteer")
|
|
354
|
+
wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
|
|
390
355
|
}
|
|
391
|
-
|
|
356
|
+
book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
|
|
357
|
+
let ribs = 0;
|
|
358
|
+
for (const use of block.resources) {
|
|
392
359
|
const resource = resources.get(use.resource_key);
|
|
393
360
|
if (resource === undefined) {
|
|
394
|
-
this.warnOnce("unknown_resource",
|
|
361
|
+
this.warnOnce("unknown_resource", `${block.row.name} uses missing resource ${use.resource_key}`, use.resource_key, scope);
|
|
395
362
|
continue;
|
|
396
363
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
364
|
+
const rib = ribKind != null && resource.resource_kind_id === ribKind;
|
|
365
|
+
if (rib)
|
|
366
|
+
ribs += use.quantity;
|
|
367
|
+
const hours = use.hours ?? (rib ? waterHours : paidHours);
|
|
368
|
+
const cost = use.quantity * (hours * hourlyCost(resource) + (resource.cost_per_day_cents ?? 0));
|
|
369
|
+
book(rib ? "ribs" : "resources", cost, rib ? "ribs" : `resource:${resource.key}`, rib ? "RIBs" : resource.name, `${resource.name}: quantity × hours × hourly cost, plus a day rate`, resource.excluded_from_mtdc ? 0 : 1);
|
|
370
|
+
}
|
|
371
|
+
for (const b of block.boats) {
|
|
372
|
+
const rate = boatRates.get(b.boat_class_id);
|
|
373
|
+
if (rate === undefined || b.count <= 0 || waterHours <= 0)
|
|
374
|
+
continue;
|
|
375
|
+
book("boat_maintenance", b.count * waterHours * rate.maintenance_per_boat_hour_cents, `boats:${b.boat_class_id}`, `Boat maintenance: ${boatClasses.get(b.boat_class_id)?.name ?? "boats"}`, "The organization's boats × on-water hours × the class's maintenance rate");
|
|
376
|
+
}
|
|
377
|
+
const coaches = sum(block.staff.map((s) => s.count));
|
|
378
|
+
const boats = sum(block.boats.map((b) => b.count));
|
|
379
|
+
for (const [a, share] of block.shares) {
|
|
380
|
+
given.set(a, { coaches: coaches * share, ribs: ribs * share, boats: boats * share });
|
|
399
381
|
}
|
|
400
382
|
}
|
|
401
|
-
this.bookTrips(
|
|
383
|
+
this.bookTrips(blocks, scheduled);
|
|
402
384
|
return {
|
|
403
385
|
coaching,
|
|
404
386
|
doubleBooked,
|
|
405
|
-
activities:
|
|
406
|
-
offering_id:
|
|
407
|
-
offering_key:
|
|
408
|
-
trip_id:
|
|
409
|
-
activity_id:
|
|
410
|
-
date:
|
|
411
|
-
kind:
|
|
412
|
-
paid_minutes:
|
|
413
|
-
on_water_minutes:
|
|
414
|
-
uses_boats:
|
|
415
|
-
coaches:
|
|
416
|
-
ribs:
|
|
417
|
-
boats:
|
|
418
|
-
|
|
387
|
+
activities: scheduled.map((a) => ({
|
|
388
|
+
offering_id: a.offering.id,
|
|
389
|
+
offering_key: a.offering.key,
|
|
390
|
+
trip_id: a.trip?.id ?? null,
|
|
391
|
+
activity_id: a.stored.id,
|
|
392
|
+
date: a.date,
|
|
393
|
+
kind: a.kind,
|
|
394
|
+
paid_minutes: a.paid_minutes,
|
|
395
|
+
on_water_minutes: a.on_water_minutes,
|
|
396
|
+
uses_boats: a.uses_boats,
|
|
397
|
+
coaches: given.get(a)?.coaches ?? 0,
|
|
398
|
+
ribs: given.get(a)?.ribs ?? 0,
|
|
399
|
+
boats: given.get(a)?.boats ?? 0,
|
|
400
|
+
block_id: blocks.has(a.stored.block_id ?? "") ? a.stored.block_id : null,
|
|
419
401
|
})),
|
|
420
402
|
};
|
|
421
403
|
}
|
|
422
404
|
/**
|
|
423
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are
|
|
424
|
-
*
|
|
405
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
|
|
406
|
+
* block of its busiest travel day.
|
|
425
407
|
*/
|
|
426
|
-
bookTrips(
|
|
408
|
+
bookTrips(blocks, scheduled) {
|
|
427
409
|
const { ctx } = this;
|
|
428
410
|
for (const trip of ctx.data.plan_trips) {
|
|
429
411
|
const offering = ctx.offeringsById.get(trip.offering_id);
|
|
430
412
|
if (offering === undefined || !ctx.inYear(offering))
|
|
431
413
|
continue;
|
|
432
|
-
const travel =
|
|
433
|
-
const
|
|
414
|
+
const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
|
|
415
|
+
const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
|
|
416
|
+
const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
|
|
417
|
+
if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
|
|
418
|
+
const first = scheduled
|
|
419
|
+
.filter(travel)
|
|
420
|
+
.map((a) => a.date)
|
|
421
|
+
.sort()[0];
|
|
422
|
+
this.warnOnce("trip_without_staff", `${trip.name} has no staff on a travel day's block, so no coach's airfare or lodging is charged`, trip.key, { version_id: trip.version_id, ...(first !== undefined && { date: first }) });
|
|
423
|
+
}
|
|
434
424
|
const lodging = ctx.rates?.lodging_per_night_cents ?? null;
|
|
435
425
|
const mileage = ctx.rates?.mileage_rate_cents ?? null;
|
|
436
426
|
if ((trip.nights ?? 0) > 0 && lodging === null) {
|
|
@@ -460,37 +450,56 @@ export class Staffing {
|
|
|
460
450
|
}
|
|
461
451
|
}
|
|
462
452
|
}
|
|
463
|
-
|
|
464
|
-
|
|
453
|
+
const byDate = (blocks) => [...blocks.values()].sort((x, y) => x.row.date.localeCompare(y.row.date));
|
|
454
|
+
/** The first start and last end among `list`; both null when one has no times. */
|
|
455
|
+
function timeSpan(list) {
|
|
456
|
+
if (list.length === 0 || list.some((a) => a.start_minutes === null || a.end_minutes === null)) {
|
|
457
|
+
return { start: null, end: null };
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
start: Math.min(...list.map((a) => a.start_minutes)),
|
|
461
|
+
end: Math.max(...list.map((a) => a.end_minutes)),
|
|
462
|
+
};
|
|
465
463
|
}
|
|
466
|
-
/** Whether two
|
|
464
|
+
/** Whether two blocks' times overlap. One with no times overlaps everything on its date. */
|
|
467
465
|
function overlaps(x, y) {
|
|
468
|
-
|
|
469
|
-
if (xs === null || xe === null || ys === null || ye === null)
|
|
466
|
+
if (x.start === null || x.end === null || y.start === null || y.end === null)
|
|
470
467
|
return true;
|
|
471
|
-
return
|
|
468
|
+
return x.start < y.end && y.start < x.end;
|
|
472
469
|
}
|
|
473
470
|
/**
|
|
474
|
-
*
|
|
475
|
-
*
|
|
471
|
+
* The most of something out at one moment, and the blocks that had it then. A block with no times
|
|
472
|
+
* is out all day.
|
|
473
|
+
*/
|
|
474
|
+
function mostAtOnce(out) {
|
|
475
|
+
let best = { total: 0, at: [] };
|
|
476
|
+
for (const candidate of out) {
|
|
477
|
+
// The peak starts when some block starts, so each block's start is a moment to check.
|
|
478
|
+
const at = out.filter((o) => o === candidate || overlapsAt(o.block, candidate.block));
|
|
479
|
+
const total = sum(at.map((o) => o.n));
|
|
480
|
+
if (total > best.total)
|
|
481
|
+
best = { total, at };
|
|
482
|
+
}
|
|
483
|
+
return best;
|
|
484
|
+
}
|
|
485
|
+
/** Whether `x` is out at the moment `y` starts. */
|
|
486
|
+
function overlapsAt(x, y) {
|
|
487
|
+
if (x.start === null || x.end === null || y.start === null)
|
|
488
|
+
return true;
|
|
489
|
+
return x.start <= y.start && y.start < x.end;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Minutes another coach would cover among one position's blocks on one date: at each moment it's on
|
|
493
|
+
* n blocks, n − 1. Blocks with no times add nothing.
|
|
476
494
|
*/
|
|
477
495
|
function extraCoachMinutes(list) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
start: Math.min(span?.start ?? Infinity, s.a.start_minutes),
|
|
486
|
-
end: Math.max(span?.end ?? -Infinity, s.a.end_minutes),
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
const edges = [...spans.values()]
|
|
490
|
-
.flatMap(({ start, end }) => [
|
|
491
|
-
{ at: start, step: 1 },
|
|
492
|
-
{ at: end, step: -1 },
|
|
493
|
-
])
|
|
496
|
+
const edges = list
|
|
497
|
+
.flatMap((b) => b.start === null || b.end === null
|
|
498
|
+
? []
|
|
499
|
+
: [
|
|
500
|
+
{ at: b.start, step: 1 },
|
|
501
|
+
{ at: b.end, step: -1 },
|
|
502
|
+
])
|
|
494
503
|
.sort((x, y) => x.at - y.at || x.step - y.step);
|
|
495
504
|
let on = 0;
|
|
496
505
|
let last = 0;
|