@cyc-seattle/platform 1.52.0 → 1.54.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 +12 -1
- package/dist/planning/context.d.ts +3 -1
- package/dist/planning/context.js +3 -1
- package/dist/planning/engine.js +69 -8
- package/dist/planning/in-force.d.ts +9 -0
- package/dist/planning/in-force.js +14 -0
- package/dist/planning/index.d.ts +2 -0
- package/dist/planning/index.js +1 -0
- package/dist/planning/lines.js +25 -0
- package/dist/planning/staffing.d.ts +16 -4
- package/dist/planning/staffing.js +108 -49
- package/dist/planning/types.d.ts +13 -0
- package/dist/schema/index.d.ts +9 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,6 +4,9 @@ Pulumi stacks for CYC Community Sailing Center's shared Google Cloud platform: t
|
|
|
4
4
|
app owns. The apps that run on it live in their own repos, such as
|
|
5
5
|
[isthmia](https://github.com/cyc-seattle/isthmia).
|
|
6
6
|
|
|
7
|
+
The [docs site](https://cyc-seattle.github.io/platform/) publishes [`docs/`](docs) from `main`: how the
|
|
8
|
+
platform is built, its data model, and the planning engine.
|
|
9
|
+
|
|
7
10
|
## Stacks
|
|
8
11
|
|
|
9
12
|
| Stack | Holds | Applied by |
|
|
@@ -164,9 +167,17 @@ login only when it isn't already set, so Actions' credentials win.
|
|
|
164
167
|
mise run ci
|
|
165
168
|
```
|
|
166
169
|
|
|
167
|
-
Runs the type check, Prettier, ESLint, and the
|
|
170
|
+
Runs the type check, Prettier, ESLint, the unit tests, and the docs site's build. Tests mock Pulumi with
|
|
168
171
|
`pulumi.runtime.setMocks` and never call a live API.
|
|
169
172
|
|
|
173
|
+
```sh
|
|
174
|
+
mise run docs-dev
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Serves the docs site, from `docs-site/`, as you edit `docs/`. A page in `docs/` takes its title from
|
|
178
|
+
its first heading, and a link to another file in the repo goes to GitHub. The build fails on a broken
|
|
179
|
+
link. `.github/workflows/docs.yml` publishes it to GitHub Pages on every push to `main`.
|
|
180
|
+
|
|
170
181
|
```sh
|
|
171
182
|
mise run integration
|
|
172
183
|
```
|
|
@@ -35,8 +35,10 @@ export declare class Context {
|
|
|
35
35
|
readonly planOfVersion: Map<Id, Plan>;
|
|
36
36
|
readonly offeringsById: Map<Id, PlanOffering>;
|
|
37
37
|
readonly offeringsByKey: Map<Id, PlanOffering>;
|
|
38
|
-
/**
|
|
38
|
+
/** Expected enrollment per offering id, which the costs and revenue use. */
|
|
39
39
|
readonly enrollment: Map<string, number>;
|
|
40
|
+
/** The most sailors each offering id takes, which the checks use. */
|
|
41
|
+
readonly maxEnrollment: Map<string, number>;
|
|
40
42
|
/** Days per offering key: the dates it has an activity other than travel. */
|
|
41
43
|
readonly days: Map<string, number>;
|
|
42
44
|
readonly rates: PlanRates | undefined;
|
package/dist/planning/context.js
CHANGED
|
@@ -28,8 +28,10 @@ export class Context {
|
|
|
28
28
|
planOfVersion;
|
|
29
29
|
offeringsById;
|
|
30
30
|
offeringsByKey;
|
|
31
|
-
/**
|
|
31
|
+
/** Expected enrollment per offering id, which the costs and revenue use. */
|
|
32
32
|
enrollment = new Map();
|
|
33
|
+
/** The most sailors each offering id takes, which the checks use. */
|
|
34
|
+
maxEnrollment = new Map();
|
|
33
35
|
/** Days per offering key: the dates it has an activity other than travel. */
|
|
34
36
|
days = new Map();
|
|
35
37
|
rates;
|
package/dist/planning/engine.js
CHANGED
|
@@ -9,18 +9,77 @@ import { allocatePools } from "./pools.js";
|
|
|
9
9
|
import { bookRevenue, countDays } from "./revenue.js";
|
|
10
10
|
import { bookSalaries } from "./salaried.js";
|
|
11
11
|
import { compareTerms, offeringTerms } from "./terms.js";
|
|
12
|
-
import { enrollmentOf, round2, Staffing } from "./staffing.js";
|
|
12
|
+
import { enrollmentOf, maxEnrollmentOf, round2, Staffing, } from "./staffing.js";
|
|
13
13
|
/**
|
|
14
14
|
* Calculates the plan year's P&L from a plan's rows. The steps run in the order
|
|
15
15
|
* docs/planning-engine.md gives, each reading only what earlier steps recorded.
|
|
16
16
|
*/
|
|
17
17
|
export function computePlan(data) {
|
|
18
|
+
const output = calculate(data, new Map());
|
|
19
|
+
for (const row of output.pnl) {
|
|
20
|
+
if (row.level !== "offering")
|
|
21
|
+
continue;
|
|
22
|
+
const offering = data.plan_offerings.find((o) => o.key === row.key);
|
|
23
|
+
if (offering !== undefined)
|
|
24
|
+
row.breakeven_enrollment = breakeven(data, offering, row.net_cents);
|
|
25
|
+
}
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The fewest sailors, from 1 up to the offering's capacity, at which its net after shared costs is
|
|
30
|
+
* zero or more. Each number is tried in turn, since coaches and boats cost in steps. Recalculating the
|
|
31
|
+
* whole plan for each would take seconds, so only the offering's own slice is, and whatever lies
|
|
32
|
+
* outside it, such as its share of shared costs, stays as planned.
|
|
33
|
+
*/
|
|
34
|
+
function breakeven(data, offering, planned) {
|
|
35
|
+
if (offering.capacity == null)
|
|
36
|
+
return null;
|
|
37
|
+
const slice = sliceOf(data, offering);
|
|
38
|
+
const netAt = (n) => calculate(slice, new Map([[offering.id, n]])).pnl.find((p) => p.level === "offering" && p.key === offering.key)
|
|
39
|
+
?.net_cents ?? 0;
|
|
40
|
+
const outside = planned - netAt(enrollmentOf(offering));
|
|
41
|
+
for (let n = 1; n <= offering.capacity; n++)
|
|
42
|
+
if (netAt(n) + outside >= 0)
|
|
43
|
+
return n;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The rows that change with an offering's sailors: it, its activities, trips and lines, and its
|
|
48
|
+
* blocks with the activities that share them. Pools, bonuses and bookings are left out.
|
|
49
|
+
*/
|
|
50
|
+
function sliceOf(data, offering) {
|
|
51
|
+
const own = data.plan_activities.filter((a) => a.offering_id === offering.id);
|
|
52
|
+
const blockIds = new Set(own.flatMap((a) => (a.block_id == null ? [] : [a.block_id])));
|
|
53
|
+
const activities = data.plan_activities.filter((a) => a.offering_id === offering.id || (a.block_id != null && blockIds.has(a.block_id)));
|
|
54
|
+
const offeringIds = new Set(activities.map((a) => a.offering_id).concat(offering.id));
|
|
55
|
+
const inBlocks = (rows) => rows.filter((r) => blockIds.has(r.block_id));
|
|
56
|
+
const onOffering = (rows) => rows.filter((r) => r.cost_center_type === "offering" && r.cost_center_key === offering.key);
|
|
57
|
+
return {
|
|
58
|
+
...data,
|
|
59
|
+
plan_offerings: data.plan_offerings.filter((o) => offeringIds.has(o.id)),
|
|
60
|
+
plan_activities: activities,
|
|
61
|
+
plan_trips: data.plan_trips.filter((t) => t.offering_id === offering.id),
|
|
62
|
+
plan_activity_blocks: data.plan_activity_blocks.filter((b) => blockIds.has(b.id)),
|
|
63
|
+
plan_block_staff: inBlocks(data.plan_block_staff),
|
|
64
|
+
plan_block_boats: inBlocks(data.plan_block_boats),
|
|
65
|
+
plan_labor_lines: onOffering(data.plan_labor_lines),
|
|
66
|
+
plan_budget_lines: onOffering(data.plan_budget_lines),
|
|
67
|
+
plan_salaried_bookings: [],
|
|
68
|
+
plan_pools: [],
|
|
69
|
+
plan_bonus_owners: [],
|
|
70
|
+
plan_bonus_owner_products: [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The plan with each offering's expected sailors, or the number `enrollment` gives it instead. */
|
|
74
|
+
function calculate(data, enrollment) {
|
|
18
75
|
const ctx = new Context(data);
|
|
19
|
-
for (const o of data.plan_offerings)
|
|
20
|
-
ctx.enrollment.set(o.id, enrollmentOf(o));
|
|
76
|
+
for (const o of data.plan_offerings) {
|
|
77
|
+
ctx.enrollment.set(o.id, enrollment.get(o.id) ?? enrollmentOf(o));
|
|
78
|
+
ctx.maxEnrollment.set(o.id, maxEnrollmentOf(o));
|
|
79
|
+
}
|
|
21
80
|
checkAreas(ctx);
|
|
22
81
|
const terms = offeringTerms(ctx);
|
|
23
|
-
const { activities, coaching, doubleBooked } = new Staffing(ctx).run(scheduleActivities(ctx));
|
|
82
|
+
const { activities, coaching, doubleBooked, holds } = new Staffing(ctx).run(scheduleActivities(ctx));
|
|
24
83
|
countDays(ctx, activities);
|
|
25
84
|
bookRevenue(ctx);
|
|
26
85
|
const booker = new CostCenterBooker(ctx);
|
|
@@ -43,7 +102,7 @@ export function computePlan(data) {
|
|
|
43
102
|
const organization = organizationTotals(ctx, lines);
|
|
44
103
|
return {
|
|
45
104
|
lines,
|
|
46
|
-
pnl: summarize(ctx, lines, mtdcByOffering, terms),
|
|
105
|
+
pnl: summarize(ctx, lines, mtdcByOffering, holds, terms),
|
|
47
106
|
organization,
|
|
48
107
|
activities,
|
|
49
108
|
positions: summarizePositions(ctx, coaching, charged, bonusByPosition, doubleBooked),
|
|
@@ -183,8 +242,8 @@ function sumLines(ctx) {
|
|
|
183
242
|
.filter((l) => l.amount_cents !== 0);
|
|
184
243
|
}
|
|
185
244
|
/** Each plan-year offering's P&L, then each product's, then each program area's, then each area's terms. */
|
|
186
|
-
function summarize(ctx, lines, mtdcByOffering, terms) {
|
|
187
|
-
const pnl = (level, key, own, counts) => {
|
|
245
|
+
function summarize(ctx, lines, mtdcByOffering, holds, terms) {
|
|
246
|
+
const pnl = (level, key, own, counts, limits = { max_enrollment: null, allocation_holds: null }) => {
|
|
188
247
|
const byAccount = new Map();
|
|
189
248
|
let revenue = 0;
|
|
190
249
|
let expense = 0;
|
|
@@ -207,13 +266,15 @@ function summarize(ctx, lines, mtdcByOffering, terms) {
|
|
|
207
266
|
.map(([account_id, amount_cents]) => ({ account_id, amount_cents }))
|
|
208
267
|
.sort((a, b) => a.account_id.localeCompare(b.account_id)),
|
|
209
268
|
...counts,
|
|
269
|
+
...limits,
|
|
270
|
+
breakeven_enrollment: null,
|
|
210
271
|
};
|
|
211
272
|
};
|
|
212
273
|
const offerings = [...ctx.data.plan_offerings].sort((a, b) => a.key.localeCompare(b.key));
|
|
213
274
|
const offeringPnl = offerings.map((o) => {
|
|
214
275
|
const enrollment = ctx.enrollment.get(o.id) ?? 0;
|
|
215
276
|
const days = ctx.days.get(o.key) ?? 0;
|
|
216
|
-
return pnl("offering", o.key, lines.filter((l) => l.cost_center_type === "offering" && l.cost_center_key === o.key), { mtdc_cents: Math.round(mtdcByOffering.get(o.key) ?? 0), enrollment, days, sailor_days: enrollment * days });
|
|
277
|
+
return pnl("offering", o.key, lines.filter((l) => l.cost_center_type === "offering" && l.cost_center_key === o.key), { mtdc_cents: Math.round(mtdcByOffering.get(o.key) ?? 0), enrollment, days, sailor_days: enrollment * days }, { max_enrollment: ctx.maxEnrollment.get(o.id) ?? 0, allocation_holds: holds.get(o.id) ?? null });
|
|
217
278
|
});
|
|
218
279
|
const productIds = new Set(offerings.map((o) => o.product_id));
|
|
219
280
|
for (const l of lines)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A Financial Plan, as far as which one is in force: the date it's active from, if it's active. */
|
|
2
|
+
export interface Activatable {
|
|
3
|
+
effective_from: string | null;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The plan in force on `on` (`YYYY-MM-DD`): of one organization's year's Financial Plans, the one with
|
|
7
|
+
* the latest date on or before it. None before the first one's date.
|
|
8
|
+
*/
|
|
9
|
+
export declare function planInForce<T extends Activatable>(sets: readonly T[], on: string): T | undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan in force on `on` (`YYYY-MM-DD`): of one organization's year's Financial Plans, the one with
|
|
3
|
+
* the latest date on or before it. None before the first one's date.
|
|
4
|
+
*/
|
|
5
|
+
export function planInForce(sets, on) {
|
|
6
|
+
let inForce;
|
|
7
|
+
for (const set of sets) {
|
|
8
|
+
if (set.effective_from === null || set.effective_from > on)
|
|
9
|
+
continue;
|
|
10
|
+
if (inForce === undefined || set.effective_from > inForce.effective_from)
|
|
11
|
+
inForce = set;
|
|
12
|
+
}
|
|
13
|
+
return inForce;
|
|
14
|
+
}
|
package/dist/planning/index.d.ts
CHANGED
package/dist/planning/index.js
CHANGED
package/dist/planning/lines.js
CHANGED
|
@@ -55,6 +55,29 @@ function checkCostCenter(ctx, type, key, line) {
|
|
|
55
55
|
ctx.warn("unknown_cost_center", `Line ${line} is charged to ${type.replace("_", " ")} ${key ?? ""}, which isn't in the plan`, line);
|
|
56
56
|
return known;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Warns about a Program Plan line that moves a cost off its area's programs, for a Plan Admin to
|
|
60
|
+
* look over before making the plan active (cyc-seattle/platform#251). Only a Plan Admin changes the
|
|
61
|
+
* Operations Plan, so its lines never get these. Capital charged anywhere but the
|
|
62
|
+
* organization already has its own warning, `capital_off_org`.
|
|
63
|
+
*/
|
|
64
|
+
function warnCostShift(ctx, line) {
|
|
65
|
+
if (ctx.planOfVersion.get(line.version_id)?.kind !== "program_area")
|
|
66
|
+
return;
|
|
67
|
+
const warn = (message) => ctx.warn("cost_off_programs", message, line.key, { version_id: line.version_id });
|
|
68
|
+
const org = ctx.data.organization.name;
|
|
69
|
+
const capital = ctx.accountType(line.account_id) === "asset";
|
|
70
|
+
if (capital && line.cost_center_type === "org") {
|
|
71
|
+
warn(`${line.name} is capital, so ${org} pays for it, not this area's programs`);
|
|
72
|
+
}
|
|
73
|
+
else if (line.cost_center_type === "org")
|
|
74
|
+
warn(`${line.name} is charged to ${org}, not to this area's programs`);
|
|
75
|
+
else if (line.cost_center_type === "pool")
|
|
76
|
+
warn(`${line.name} is charged to shared costs, which every program area pays`);
|
|
77
|
+
if (line.in_mtdc === false) {
|
|
78
|
+
warn(`${line.name} is left out of the split of shared costs, so the other program areas pay more of them`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
58
81
|
/**
|
|
59
82
|
* Records the plan year's `plan_budget_lines`. Capital, a line on an asset account, is the
|
|
60
83
|
* organization's: fundraising pays for it, so it never reaches a program area or an offering.
|
|
@@ -67,6 +90,7 @@ export function bookBudgetLines(ctx, booker) {
|
|
|
67
90
|
continue;
|
|
68
91
|
const capital = ctx.accountType(line.account_id) === "asset";
|
|
69
92
|
const onOrg = line.cost_center_type === "org" || capital;
|
|
93
|
+
warnCostShift(ctx, line);
|
|
70
94
|
if (capital && line.cost_center_type !== "org") {
|
|
71
95
|
ctx.warn("capital_off_org", `${line.name} is capital, so it's charged to ${ctx.data.organization.name}, which pays for all capital`, line.key);
|
|
72
96
|
}
|
|
@@ -89,6 +113,7 @@ export function bookLaborLines(ctx, booker) {
|
|
|
89
113
|
for (const line of ctx.data.plan_labor_lines) {
|
|
90
114
|
if (!checkCostCenter(ctx, line.cost_center_type, line.cost_center_key, line.key))
|
|
91
115
|
continue;
|
|
116
|
+
warnCostShift(ctx, line);
|
|
92
117
|
const level = levels.get(line.staff_level_key);
|
|
93
118
|
if (level === undefined) {
|
|
94
119
|
ctx.warn("unknown_staff_level", `Labor line ${line.key} has a staff level that isn't in the Operations Plan, so it's left out`, line.key);
|
|
@@ -18,8 +18,10 @@ export interface DoubleBooked {
|
|
|
18
18
|
/** Dates it's on two blocks at once. */
|
|
19
19
|
dates: number;
|
|
20
20
|
}
|
|
21
|
-
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
21
|
+
/** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
22
22
|
export declare function enrollmentOf(offering: PlanOffering): number;
|
|
23
|
+
/** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
|
|
24
|
+
export declare function maxEnrollmentOf(offering: PlanOffering): number;
|
|
23
25
|
export declare class Staffing {
|
|
24
26
|
private readonly ctx;
|
|
25
27
|
private readonly warned;
|
|
@@ -37,12 +39,20 @@ export declare class Staffing {
|
|
|
37
39
|
/** Each program's settings, by its product. */
|
|
38
40
|
private programs;
|
|
39
41
|
/**
|
|
40
|
-
* Warns when a block's sailors are more than its staff or its boats can
|
|
42
|
+
* Warns when a block's sailors, with every offering full, are more than its staff or its boats can
|
|
43
|
+
* safely take, and records the most sailors of each offering the block can take. Each program's
|
|
41
44
|
* sailors count against its own sailors per coach, and every coach on the block counts, at any
|
|
42
45
|
* staff level or salaried: the ratio is required, a coach's level isn't (cyc-seattle/sail-team#82).
|
|
43
46
|
* Boats count the ones sailors bring.
|
|
44
47
|
*/
|
|
45
48
|
private checkCapacity;
|
|
49
|
+
/**
|
|
50
|
+
* The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
|
|
51
|
+
* coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
|
|
52
|
+
* only the others are cut, each staff row by the same share. When a program has no sailors per
|
|
53
|
+
* coach, the block pays all it's given.
|
|
54
|
+
*/
|
|
55
|
+
private coachesExpected;
|
|
46
56
|
/** Books a cost on a block, split among its activities by their offerings' sailors. */
|
|
47
57
|
private book;
|
|
48
58
|
/**
|
|
@@ -66,10 +76,12 @@ export declare class Staffing {
|
|
|
66
76
|
activities: ComputedActivity[];
|
|
67
77
|
coaching: SalariedCoaching[];
|
|
68
78
|
doubleBooked: Map<Id, DoubleBooked>;
|
|
79
|
+
/** The most sailors each offering id's blocks can take. */
|
|
80
|
+
holds: Map<Id, number>;
|
|
69
81
|
};
|
|
70
82
|
/**
|
|
71
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the
|
|
72
|
-
* block of its busiest travel day.
|
|
83
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
|
|
84
|
+
* expected sailors need on the block of its busiest travel day.
|
|
73
85
|
*/
|
|
74
86
|
private bookTrips;
|
|
75
87
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
1
|
+
/** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
2
2
|
export function enrollmentOf(offering) {
|
|
3
3
|
if (offering.target_enrollment != null)
|
|
4
4
|
return Math.round(offering.target_enrollment);
|
|
5
5
|
return Math.round((offering.capacity ?? 0) * (offering.fill_rate ?? 0));
|
|
6
6
|
}
|
|
7
|
+
/** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
|
|
8
|
+
export function maxEnrollmentOf(offering) {
|
|
9
|
+
return offering.capacity ?? enrollmentOf(offering);
|
|
10
|
+
}
|
|
11
|
+
/** `n` rounded up, so a share like 5.0000001 boats stays 5. */
|
|
12
|
+
const ceil = (n) => Math.ceil(n - 1e-9);
|
|
7
13
|
function sum(values) {
|
|
8
14
|
let total = 0;
|
|
9
15
|
for (const v of values)
|
|
@@ -93,6 +99,7 @@ export class Staffing {
|
|
|
93
99
|
const byOffering = groupBy(list, (a) => a.offering.id);
|
|
94
100
|
const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
|
|
95
101
|
const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
|
|
102
|
+
const most = sum([...byOffering.keys()].map((o) => this.ctx.maxEnrollment.get(o) ?? 0));
|
|
96
103
|
result.set(id, {
|
|
97
104
|
row,
|
|
98
105
|
members: list,
|
|
@@ -104,6 +111,7 @@ export class Staffing {
|
|
|
104
111
|
staff: staff.get(id) ?? [],
|
|
105
112
|
boats: boats.get(id) ?? [],
|
|
106
113
|
shares: new Map(list.map((a) => [a, share(a)])),
|
|
114
|
+
fill: most > 0 ? Math.min(1, total / most) : 1,
|
|
107
115
|
});
|
|
108
116
|
}
|
|
109
117
|
return result;
|
|
@@ -127,61 +135,98 @@ export class Staffing {
|
|
|
127
135
|
return new Map(this.ctx.data.plan_programs.map((p) => [p.product_id, p]));
|
|
128
136
|
}
|
|
129
137
|
/**
|
|
130
|
-
* Warns when a block's sailors are more than its staff or its boats can
|
|
138
|
+
* Warns when a block's sailors, with every offering full, are more than its staff or its boats can
|
|
139
|
+
* safely take, and records the most sailors of each offering the block can take. Each program's
|
|
131
140
|
* sailors count against its own sailors per coach, and every coach on the block counts, at any
|
|
132
141
|
* staff level or salaried: the ratio is required, a coach's level isn't (cyc-seattle/sail-team#82).
|
|
133
142
|
* Boats count the ones sailors bring.
|
|
134
143
|
*/
|
|
135
|
-
checkCapacity(block, programs) {
|
|
144
|
+
checkCapacity(block, programs, holds) {
|
|
136
145
|
const { ctx } = this;
|
|
137
146
|
const offerings = [
|
|
138
147
|
...new Map(block.members.filter((a) => a.kind !== "travel").map((a) => [a.offering.id, a.offering])).values(),
|
|
139
148
|
];
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const most = (o) => ctx.maxEnrollment.get(o.id) ?? 0;
|
|
150
|
+
const sailors = sum(offerings.map(most));
|
|
151
|
+
const warns = sailors > 0;
|
|
143
152
|
const scope = { version_id: block.row.version_id, date: block.row.date };
|
|
144
|
-
// The block's offerings, with the
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// The block's offerings, with the verbs that agree with them: "Guppies Week 2 is for", "has".
|
|
154
|
+
const several = new Set(block.members.map((a) => a.offering.id)).size > 1;
|
|
155
|
+
const is = `${offeringsOf(block)} ${several ? "are" : "is"} for up to`;
|
|
156
|
+
const has = several ? "have" : "has";
|
|
157
|
+
// Coaches each program's full number of sailors needs.
|
|
158
|
+
const needs = new Map();
|
|
159
|
+
const perCoach = new Map();
|
|
149
160
|
for (const o of offerings) {
|
|
150
161
|
const settings = programs.get(o.product_id);
|
|
151
162
|
if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
|
|
152
|
-
|
|
163
|
+
if (warns) {
|
|
164
|
+
this.warnOnce("no_program_settings", `${o.name}'s program has no staff level or sailors per coach. Add both to check that it has enough coaches`, o.product_id, { version_id: o.version_id, date: block.row.date });
|
|
165
|
+
}
|
|
153
166
|
continue;
|
|
154
167
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
counted += n;
|
|
168
|
+
needs.set(o.id, most(o) / settings.sailors_per_coach);
|
|
169
|
+
perCoach.set(o.id, settings.sailors_per_coach);
|
|
158
170
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
const coaches = sum(block.staff.map((s) => s.count));
|
|
172
|
+
const load = sum(needs.values());
|
|
173
|
+
const counted = sum(offerings.filter((o) => needs.has(o.id)).map(most));
|
|
174
|
+
if (warns && counted > 0 && load > coaches + 1e-9) {
|
|
175
|
+
ctx.warn("over_capacity", coaches === 0
|
|
176
|
+
? `${is} ${countOf(counted, "sailor")} and ${has} no coach`
|
|
177
|
+
: `${is} ${countOf(counted, "sailor")}, but ${has} enough coaches for only ${Math.floor((coaches * counted) / load + 1e-9)}`, block.row.key, scope);
|
|
166
178
|
}
|
|
167
|
-
|
|
168
|
-
return;
|
|
169
|
-
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
179
|
+
const sails = block.members.some((a) => a.uses_boats);
|
|
170
180
|
let seats = 0;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
if (sails) {
|
|
182
|
+
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
183
|
+
for (const b of block.boats) {
|
|
184
|
+
// A coach boat's class takes 0, without a warning.
|
|
185
|
+
const perBoat = classes.get(b.boat_class_id)?.max_capacity;
|
|
186
|
+
if (perBoat == null) {
|
|
187
|
+
if (warns) {
|
|
188
|
+
this.warnOnce("no_max_capacity", `${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no max capacity. Add it to check that the boats hold every sailor`, b.boat_class_id, scope);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
seats += (b.count + (b.chartered ?? 0) + (b.sailor_owned ?? 0)) * perBoat;
|
|
193
|
+
}
|
|
194
|
+
if (warns && sailors > seats) {
|
|
195
|
+
ctx.warn("over_capacity", seats === 0
|
|
196
|
+
? `${is} ${countOf(sailors, "sailor")} and ${has} no boats`
|
|
197
|
+
: `${is} ${countOf(sailors, "sailor")}, but the boats hold only ${seats}`, block.row.key, scope);
|
|
177
198
|
}
|
|
178
|
-
seats += (b.count + (b.chartered ?? 0) + (b.sailor_owned ?? 0)) * perBoat;
|
|
179
199
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
// What's left for each offering once the others in the block are full.
|
|
201
|
+
for (const o of offerings) {
|
|
202
|
+
const own = needs.get(o.id);
|
|
203
|
+
const byCoaches = own === undefined ? Infinity : Math.floor((coaches - (load - own)) * perCoach.get(o.id) + 1e-9);
|
|
204
|
+
const byBoats = sails ? seats - (sailors - most(o)) : Infinity;
|
|
205
|
+
const n = Math.min(byCoaches, byBoats);
|
|
206
|
+
if (n === Infinity)
|
|
207
|
+
continue;
|
|
208
|
+
holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
|
|
213
|
+
* coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
|
|
214
|
+
* only the others are cut, each staff row by the same share. When a program has no sailors per
|
|
215
|
+
* coach, the block pays all it's given.
|
|
216
|
+
*/
|
|
217
|
+
coachesExpected(block, programs) {
|
|
218
|
+
const given = sum(block.staff.map((s) => s.count));
|
|
219
|
+
const salaried = sum(block.staff.filter((s) => s.position_key != null && s.pay !== "hourly").map((s) => s.count));
|
|
220
|
+
let load = 0;
|
|
221
|
+
for (const o of new Map(block.members.map((a) => [a.offering.id, a.offering])).values()) {
|
|
222
|
+
const perCoach = programs.get(o.product_id)?.sailors_per_coach;
|
|
223
|
+
if (perCoach == null || perCoach <= 0)
|
|
224
|
+
return { coaches: given, share: 1 };
|
|
225
|
+
load += (this.ctx.enrollment.get(o.id) ?? 0) / perCoach;
|
|
184
226
|
}
|
|
227
|
+
const others = given - salaried;
|
|
228
|
+
const kept = Math.min(others, Math.max(0, ceil(load) - salaried));
|
|
229
|
+
return { coaches: salaried + kept, share: others > 0 ? kept / others : 1 };
|
|
185
230
|
}
|
|
186
231
|
/** Books a cost on a block, split among its activities by their offerings' sailors. */
|
|
187
232
|
book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
|
|
@@ -212,7 +257,11 @@ export class Staffing {
|
|
|
212
257
|
const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
213
258
|
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
214
259
|
for (const list of groupBy(blocks, (b) => b.row.date).values()) {
|
|
215
|
-
const chartered = list.flatMap((block) => block.boats.flatMap((b) =>
|
|
260
|
+
const chartered = list.flatMap((block) => block.boats.flatMap((b) => {
|
|
261
|
+
// The boats the expected sailors use, as for its own boats.
|
|
262
|
+
const n = ceil((b.chartered ?? 0) * block.fill);
|
|
263
|
+
return n > 0 ? [{ block, id: b.boat_class_id, n }] : [];
|
|
264
|
+
}));
|
|
216
265
|
for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
|
|
217
266
|
const name = classes.get(id)?.name ?? id;
|
|
218
267
|
const rate = rates.get(id)?.charter_per_day_cents;
|
|
@@ -307,9 +356,10 @@ export class Staffing {
|
|
|
307
356
|
const blocks = this.blocks(scheduled);
|
|
308
357
|
this.checkUnallocated(scheduled, blocks);
|
|
309
358
|
const programs = this.programs();
|
|
359
|
+
const holds = new Map();
|
|
310
360
|
// By date, so a warning given once names the first date it's seen on.
|
|
311
361
|
for (const block of byDate(blocks))
|
|
312
|
-
this.checkCapacity(block, programs);
|
|
362
|
+
this.checkCapacity(block, programs, holds);
|
|
313
363
|
this.checkInventory(byDate(blocks));
|
|
314
364
|
const doubleBooked = this.checkDoubleBooked([...blocks.values()]);
|
|
315
365
|
const coaching = [];
|
|
@@ -318,10 +368,13 @@ export class Staffing {
|
|
|
318
368
|
const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
319
369
|
const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
320
370
|
const given = new Map();
|
|
371
|
+
const tripCoaches = new Map();
|
|
321
372
|
for (const block of byDate(blocks)) {
|
|
322
373
|
const scope = { version_id: block.row.version_id, date: block.row.date };
|
|
323
374
|
const paidHours = block.paid_minutes / 60;
|
|
324
375
|
const waterHours = block.on_water_minutes / 60;
|
|
376
|
+
const expected = this.coachesExpected(block, programs);
|
|
377
|
+
tripCoaches.set(block.row.id, expected.coaches);
|
|
325
378
|
const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
|
|
326
379
|
let wages = 0;
|
|
327
380
|
for (const s of block.staff) {
|
|
@@ -329,8 +382,8 @@ export class Staffing {
|
|
|
329
382
|
continue;
|
|
330
383
|
if (s.position_key != null) {
|
|
331
384
|
const position = positions.get(s.position_key);
|
|
332
|
-
const hours = s.count * paidHours;
|
|
333
385
|
if (s.pay === "hourly") {
|
|
386
|
+
const hours = s.count * expected.share * paidHours;
|
|
334
387
|
const rate = position?.hourly_coaching_rate_cents;
|
|
335
388
|
if (position !== undefined && rate == null) {
|
|
336
389
|
this.warnOnce("no_hourly_rate", `${position.name} is paid by the hour to coach but has no own hourly coaching rate, so those hours cost nothing`, position.key, scope);
|
|
@@ -347,6 +400,7 @@ export class Staffing {
|
|
|
347
400
|
}
|
|
348
401
|
}
|
|
349
402
|
else {
|
|
403
|
+
const hours = s.count * paidHours;
|
|
350
404
|
for (const [a, share] of block.shares) {
|
|
351
405
|
coaching.push({
|
|
352
406
|
position_key: s.position_key,
|
|
@@ -364,16 +418,19 @@ export class Staffing {
|
|
|
364
418
|
this.warnOnce("unknown_staff_level", `${block.row.name} has staff at a staff level that isn't in the Operations Plan, so they cost nothing`, s.staff_level_key ?? s.key, scope);
|
|
365
419
|
continue;
|
|
366
420
|
}
|
|
367
|
-
if (level.pay_type !== "volunteer")
|
|
368
|
-
wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
|
|
421
|
+
if (level.pay_type !== "volunteer") {
|
|
422
|
+
wages += s.count * expected.share * paidHours * (level.hourly_rate_cents ?? 0);
|
|
423
|
+
}
|
|
369
424
|
}
|
|
370
|
-
book("coach_wages", wages, "coach_wages", "Coach wages", "
|
|
425
|
+
book("coach_wages", wages, "coach_wages", "Coach wages", "Coaches the expected sailors need × paid hours × each one's hourly rate");
|
|
371
426
|
for (const b of block.boats) {
|
|
372
427
|
const rate = boatRates.get(b.boat_class_id);
|
|
373
428
|
if (rate === undefined || b.count <= 0 || waterHours <= 0)
|
|
374
429
|
continue;
|
|
375
430
|
const boatClass = boatClasses.get(b.boat_class_id);
|
|
376
|
-
this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), b.count *
|
|
431
|
+
this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), ceil(b.count * block.fill) *
|
|
432
|
+
waterHours *
|
|
433
|
+
(rate.maintenance_per_boat_hour_cents + (rate.fuel_per_hour_cents ?? 0)), `boats:${b.boat_class_id}`, `Boats: ${boatClass?.name ?? "boats"}`, "The organization's boats the expected sailors use × on-water hours × the class's fuel and upkeep");
|
|
377
434
|
}
|
|
378
435
|
const coaches = sum(block.staff.map((s) => s.count));
|
|
379
436
|
const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
|
|
@@ -382,10 +439,11 @@ export class Staffing {
|
|
|
382
439
|
}
|
|
383
440
|
}
|
|
384
441
|
this.bookCharters(byDate(blocks));
|
|
385
|
-
this.bookTrips(blocks, scheduled);
|
|
442
|
+
this.bookTrips(blocks, scheduled, tripCoaches);
|
|
386
443
|
return {
|
|
387
444
|
coaching,
|
|
388
445
|
doubleBooked,
|
|
446
|
+
holds,
|
|
389
447
|
activities: scheduled.map((a) => ({
|
|
390
448
|
offering_id: a.offering.id,
|
|
391
449
|
offering_key: a.offering.key,
|
|
@@ -403,10 +461,10 @@ export class Staffing {
|
|
|
403
461
|
};
|
|
404
462
|
}
|
|
405
463
|
/**
|
|
406
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the
|
|
407
|
-
* block of its busiest travel day.
|
|
464
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
|
|
465
|
+
* expected sailors need on the block of its busiest travel day.
|
|
408
466
|
*/
|
|
409
|
-
bookTrips(blocks, scheduled) {
|
|
467
|
+
bookTrips(blocks, scheduled, coachesOf) {
|
|
410
468
|
const { ctx } = this;
|
|
411
469
|
for (const trip of ctx.data.plan_trips) {
|
|
412
470
|
const offering = ctx.offeringsById.get(trip.offering_id);
|
|
@@ -414,8 +472,9 @@ export class Staffing {
|
|
|
414
472
|
continue;
|
|
415
473
|
const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
|
|
416
474
|
const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
|
|
417
|
-
const coaches = Math.max(0, ...staffed.map((b) =>
|
|
418
|
-
|
|
475
|
+
const coaches = Math.max(0, ...staffed.map((b) => coachesOf.get(b.row.id) ?? 0));
|
|
476
|
+
const given = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
|
|
477
|
+
if (given === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
|
|
419
478
|
const first = scheduled
|
|
420
479
|
.filter(travel)
|
|
421
480
|
.map((a) => a.date)
|
package/dist/planning/types.d.ts
CHANGED
|
@@ -367,6 +367,19 @@ export interface Pnl extends Totals {
|
|
|
367
367
|
days: number;
|
|
368
368
|
/** Enrollment × days. */
|
|
369
369
|
sailor_days: number;
|
|
370
|
+
/** The most sailors an offering takes: its `capacity`, or else its enrollment. Null for a product or area. */
|
|
371
|
+
max_enrollment: number | null;
|
|
372
|
+
/**
|
|
373
|
+
* The most sailors an offering's blocks can take, with every other offering in them full. Null when
|
|
374
|
+
* it's in no block that limits it, and for a product or area.
|
|
375
|
+
*/
|
|
376
|
+
allocation_holds: number | null;
|
|
377
|
+
/**
|
|
378
|
+
* The fewest sailors, from 1 up to its capacity, at which an offering's net after shared costs is
|
|
379
|
+
* zero or more, with its share of shared costs as planned. Null when it isn't, even when full, when
|
|
380
|
+
* it has no capacity, and for a product or area.
|
|
381
|
+
*/
|
|
382
|
+
breakeven_enrollment: number | null;
|
|
370
383
|
}
|
|
371
384
|
/** An activity with the staffing the engine gave it. */
|
|
372
385
|
export interface ComputedActivity {
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -882,6 +882,10 @@ export interface PlanResultPnl {
|
|
|
882
882
|
term_name: string | null;
|
|
883
883
|
/** The term's first day, which puts the terms in order. */
|
|
884
884
|
term_starts_on: string | null;
|
|
885
|
+
/** The most sailors an offering's blocks can take, with every other offering in them full. Empty for a product or program area. */
|
|
886
|
+
allocation_holds: number | null;
|
|
887
|
+
/** The fewest sailors, up to its capacity, at which an offering pays for itself, with its share of shared costs as planned. Empty when it doesn't even when full, and for a product or program area. */
|
|
888
|
+
breakeven_enrollment: number | null;
|
|
885
889
|
}
|
|
886
890
|
/** Each salaried position's pay for the year, in a Financial Plan. A draft's are worked out again a moment after each change to it, and a saved one's when it's saved, and then never change. */
|
|
887
891
|
export interface PlanResultPositions {
|
|
@@ -1242,8 +1246,8 @@ export interface VersionSets {
|
|
|
1242
1246
|
name: string;
|
|
1243
1247
|
/** What it was for, such as "Reviewed with the board". You can change it after it's saved. */
|
|
1244
1248
|
message: string | null;
|
|
1245
|
-
/** Set it to saved to save the Financial Plan and all its plans. A saved Financial Plan never changes, apart from its name
|
|
1246
|
-
status: "draft" | "saved"
|
|
1249
|
+
/** Set it to saved to save the Financial Plan and all its plans. A saved Financial Plan never changes, apart from its name, its note, the date it's active from, and whether it's archived. */
|
|
1250
|
+
status: "draft" | "saved";
|
|
1247
1251
|
/** The Financial Plan this one started as a copy of. Pick it when you add this one, and its plans are copied in. */
|
|
1248
1252
|
parent_id: string | VersionSets | null;
|
|
1249
1253
|
/** Set when the Financial Plan is saved. */
|
|
@@ -1252,7 +1256,9 @@ export interface VersionSets {
|
|
|
1252
1256
|
saved_at: string | null;
|
|
1253
1257
|
/** The version of the planning engine that calculated its numbers. */
|
|
1254
1258
|
platform_version: string | null;
|
|
1255
|
-
/**
|
|
1259
|
+
/** The day the Financial Plan is in force from. Leave it empty for one that isn't active. On any day, the plan in force is the one for the year with the latest date on or before that day. A draft can be active. No two Financial Plans for a year can share a date. */
|
|
1260
|
+
effective_from: string | null;
|
|
1261
|
+
/** Set when the Financial Plan is archived, which hides it from Planning's list. Clear it to bring the plan back. The plan in force can't be archived. */
|
|
1256
1262
|
archived_at: string | null;
|
|
1257
1263
|
/** Each plan in the Financial Plan. Add or remove one only while it's a draft. */
|
|
1258
1264
|
members: (string | VersionSetMembers)[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyc-seattle/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.54.0",
|
|
4
4
|
"description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
|
|
5
5
|
"author": "CYC Community Sailing Center",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"@types/js-yaml": "4.0.9",
|
|
82
82
|
"@types/luxon": "^3.7.6",
|
|
83
83
|
"@types/node": "^22.19.1",
|
|
84
|
+
"commander": "13.1.0",
|
|
84
85
|
"esbuild": "0.28.2",
|
|
85
86
|
"eslint": "^9.39.5",
|
|
86
87
|
"google-auth-library": "^11.3.0",
|