@cyc-seattle/platform 1.54.0 → 1.55.1
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 +15 -17
- package/dist/planning/activities.js +2 -2
- package/dist/planning/bonus.js +1 -1
- package/dist/planning/context.d.ts +4 -4
- package/dist/planning/context.js +5 -9
- package/dist/planning/engine.js +1 -1
- package/dist/planning/lines.js +2 -2
- package/dist/planning/salaried.js +7 -2
- package/dist/planning/staffing.d.ts +4 -11
- package/dist/planning/staffing.js +26 -62
- package/dist/planning/types.d.ts +32 -40
- package/dist/schema/index.d.ts +131 -162
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@ platform is built, its data model, and the planning engine.
|
|
|
13
13
|
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
14
14
|
| [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, the org's GitHub repos and settings, and Firebase on the project | A human with project Owner and `gh` `admin:org`. Never CI. |
|
|
15
15
|
| [`infra`](stacks/infra) | The VPC, the cluster subnet and its Cloud NAT on a static address, the private services peering, the prod and dev Cloud SQL instances, the DNS zones, Artifact Registry and its cleanup policy, the GKE Autopilot cluster, the shared Gateway, its address, certificates, and dev's DNS records, the `dev` and `prod` namespaces with their quotas and network policies, dev's RBAC, the prod Job alerts, the log exclusions, the public website's Firebase Hosting site, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
|
|
16
|
-
| [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each
|
|
17
|
-
| [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev
|
|
18
|
-
| [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev
|
|
16
|
+
| [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each merged PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
|
|
17
|
+
| [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `infra`. |
|
|
18
|
+
| [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `backend`. |
|
|
19
19
|
|
|
20
20
|
## @cyc-seattle/platform
|
|
21
21
|
|
|
@@ -104,20 +104,18 @@ deployer needs first.
|
|
|
104
104
|
authenticates with Workload Identity Federation and stores no credentials. Each job runs in a
|
|
105
105
|
GitHub environment that holds its identity and state bucket:
|
|
106
106
|
|
|
107
|
-
| Event
|
|
108
|
-
|
|
|
109
|
-
| Pull request
|
|
110
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
doesn't run the integration tests (#161); a run by hand does. To try a
|
|
120
|
-
branch on dev and test it before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
|
|
107
|
+
| Event | Jobs, in order |
|
|
108
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| Pull request | `ci` |
|
|
110
|
+
| Push to `main` | `dev`: `mise run deploy` as `deploy-dev`, then `integration` and `prod-preview`, then `prod`, which waits for the `maintainers` approval |
|
|
111
|
+
| Run by hand, from `main` | The same as a push to `main` |
|
|
112
|
+
|
|
113
|
+
Dev is shared and takes only `main` (#280), so a PR's push runs only CI, and each push to `main`
|
|
114
|
+
deploys dev before prod. `integration` starts `integration.yml`, which runs the integration tests
|
|
115
|
+
against dev in a run of its own, one at a time. A newer run replaces one that's waiting, and a
|
|
116
|
+
waiting deploy goes ahead of it, so it tests whatever is newest on dev. A failure opens or comments
|
|
117
|
+
on the `Integration tests failed on dev` issue, and a green run closes it. `nightly.yml` still
|
|
118
|
+
deploys and tests `main` each night, with its own issue.
|
|
121
119
|
|
|
122
120
|
`prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
|
|
123
121
|
`prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
|
|
@@ -8,7 +8,7 @@ export function scheduleActivities(ctx) {
|
|
|
8
8
|
const closed = new Map();
|
|
9
9
|
for (const c of ctx.data.plan_closed_dates ?? []) {
|
|
10
10
|
// The closed days are the Operations Plan's, not an area's.
|
|
11
|
-
if (ctx.
|
|
11
|
+
if (ctx.planOf.get(c.plan_id)?.kind === "program_area") {
|
|
12
12
|
ctx.warn("closed_date_in_area_plan", `${c.name} is a closed day in a Program Plan. Only the Operations Plan's closed days count`, c.key);
|
|
13
13
|
continue;
|
|
14
14
|
}
|
|
@@ -31,7 +31,7 @@ export function scheduleActivities(ctx) {
|
|
|
31
31
|
const closedFor = closed.get(activity.date);
|
|
32
32
|
if (closedFor !== undefined && activity.kind !== "travel") {
|
|
33
33
|
ctx.warn("closed_date", `${offering.name}'s ${activity.kind} is on ${closedFor}, when ${ctx.data.organization.name} is closed`, activity.key, {
|
|
34
|
-
|
|
34
|
+
plan_id: activity.plan_id,
|
|
35
35
|
date: activity.date,
|
|
36
36
|
});
|
|
37
37
|
}
|
package/dist/planning/bonus.js
CHANGED
|
@@ -94,7 +94,7 @@ function areaOf(ctx, owner) {
|
|
|
94
94
|
* order and then no term. An area plan's bonus counts only that area's products, and warns about any other.
|
|
95
95
|
*/
|
|
96
96
|
function basesOf(ctx, owner, terms) {
|
|
97
|
-
const plan = ctx.
|
|
97
|
+
const plan = ctx.planOf.get(owner.plan_id);
|
|
98
98
|
const products = new Set();
|
|
99
99
|
for (const row of ctx.data.plan_bonus_owner_products) {
|
|
100
100
|
if (row.bonus_owner_id !== owner.id)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Account, AccountFunction, CostCenterType, CostType, DateString, EngineWarning, Id, PlanData, PlanOffering, Plan, PlanRates, Product, ProgramArea } from "./types.ts";
|
|
2
|
-
/** Where a warning applies: a plan
|
|
2
|
+
/** Where a warning applies: a plan, and a date. */
|
|
3
3
|
export interface WarningScope {
|
|
4
|
-
|
|
4
|
+
plan_id?: Id;
|
|
5
5
|
date?: DateString;
|
|
6
6
|
}
|
|
7
7
|
/** The step that recorded an entry. Only `direct` entries count toward MTDC. */
|
|
@@ -31,8 +31,8 @@ export declare class Context {
|
|
|
31
31
|
readonly accounts: Map<Id, Account>;
|
|
32
32
|
readonly products: Map<Id, Product>;
|
|
33
33
|
readonly areas: Map<Id, ProgramArea>;
|
|
34
|
-
/** Each
|
|
35
|
-
readonly
|
|
34
|
+
/** Each plan, by its id. */
|
|
35
|
+
readonly planOf: 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. */
|
package/dist/planning/context.js
CHANGED
|
@@ -24,8 +24,8 @@ export class Context {
|
|
|
24
24
|
accounts;
|
|
25
25
|
products;
|
|
26
26
|
areas;
|
|
27
|
-
/** Each
|
|
28
|
-
|
|
27
|
+
/** Each plan, by its id. */
|
|
28
|
+
planOf;
|
|
29
29
|
offeringsById;
|
|
30
30
|
offeringsByKey;
|
|
31
31
|
/** Expected enrollment per offering id, which the costs and revenue use. */
|
|
@@ -42,11 +42,7 @@ export class Context {
|
|
|
42
42
|
this.accounts = new Map(data.accounts.map((a) => [a.id, a]));
|
|
43
43
|
this.products = new Map(data.products.map((p) => [p.id, p]));
|
|
44
44
|
this.areas = new Map(data.program_areas.map((a) => [a.id, a]));
|
|
45
|
-
|
|
46
|
-
this.planOfVersion = new Map(data.plan_versions.flatMap((v) => {
|
|
47
|
-
const plan = plans.get(v.plan_id);
|
|
48
|
-
return plan === undefined ? [] : [[v.id, plan]];
|
|
49
|
-
}));
|
|
45
|
+
this.planOf = new Map(data.plans.map((p) => [p.id, p]));
|
|
50
46
|
this.offeringsById = new Map(data.plan_offerings.map((o) => [o.id, o]));
|
|
51
47
|
this.offeringsByKey = new Map(data.plan_offerings.map((o) => [o.key, o]));
|
|
52
48
|
this.rates = data.plan_rates[0];
|
|
@@ -65,7 +61,7 @@ export class Context {
|
|
|
65
61
|
return account;
|
|
66
62
|
}
|
|
67
63
|
warn(code, message, key, scope = {}) {
|
|
68
|
-
const same = (w) => w.code === code && w.message === message && w.
|
|
64
|
+
const same = (w) => w.code === code && w.message === message && w.plan_id === scope.plan_id;
|
|
69
65
|
if (this.warnings.some(same))
|
|
70
66
|
return;
|
|
71
67
|
const category = ALLOCATION_WARNINGS.has(code) ? "allocation" : "data";
|
|
@@ -74,7 +70,7 @@ export class Context {
|
|
|
74
70
|
message,
|
|
75
71
|
category,
|
|
76
72
|
...(key !== undefined && { key }),
|
|
77
|
-
...(scope.
|
|
73
|
+
...(scope.plan_id !== undefined && { plan_id: scope.plan_id }),
|
|
78
74
|
...(scope.date !== undefined && { date: scope.date }),
|
|
79
75
|
});
|
|
80
76
|
}
|
package/dist/planning/engine.js
CHANGED
|
@@ -127,7 +127,7 @@ function checkAreas(ctx) {
|
|
|
127
127
|
else if (product.kind !== "program" && product.program_area_id != null) {
|
|
128
128
|
ctx.warn("area_on_non_program", `${product.name} isn't a program but has a program area`, product.id);
|
|
129
129
|
}
|
|
130
|
-
const plan = ctx.
|
|
130
|
+
const plan = ctx.planOf.get(offering.plan_id);
|
|
131
131
|
if (plan === undefined)
|
|
132
132
|
continue;
|
|
133
133
|
const wrong = plan.kind === "operations"
|
package/dist/planning/lines.js
CHANGED
|
@@ -62,9 +62,9 @@ function checkCostCenter(ctx, type, key, line) {
|
|
|
62
62
|
* organization already has its own warning, `capital_off_org`.
|
|
63
63
|
*/
|
|
64
64
|
function warnCostShift(ctx, line) {
|
|
65
|
-
if (ctx.
|
|
65
|
+
if (ctx.planOf.get(line.plan_id)?.kind !== "program_area")
|
|
66
66
|
return;
|
|
67
|
-
const warn = (message) => ctx.warn("cost_off_programs", message, line.key, {
|
|
67
|
+
const warn = (message) => ctx.warn("cost_off_programs", message, line.key, { plan_id: line.plan_id });
|
|
68
68
|
const org = ctx.data.organization.name;
|
|
69
69
|
const capital = ctx.accountType(line.account_id) === "asset";
|
|
70
70
|
if (capital && line.cost_center_type === "org") {
|
|
@@ -34,12 +34,17 @@ export function bookSalaries(ctx, coaching, booker) {
|
|
|
34
34
|
}
|
|
35
35
|
return [
|
|
36
36
|
b.product_id != null
|
|
37
|
-
? {
|
|
37
|
+
? {
|
|
38
|
+
cost_center_type: "product",
|
|
39
|
+
cost_center_key: b.product_id,
|
|
40
|
+
hours: b.hours,
|
|
41
|
+
line: "management",
|
|
42
|
+
}
|
|
38
43
|
: {
|
|
39
44
|
cost_center_type: "program_area",
|
|
40
45
|
cost_center_key: b.program_area_id,
|
|
41
46
|
hours: b.hours,
|
|
42
|
-
line:
|
|
47
|
+
line: "management",
|
|
43
48
|
},
|
|
44
49
|
];
|
|
45
50
|
}),
|
|
@@ -18,7 +18,7 @@ export interface DoubleBooked {
|
|
|
18
18
|
/** Dates it's on two blocks at once. */
|
|
19
19
|
dates: number;
|
|
20
20
|
}
|
|
21
|
-
/** Whole kids expected: `target_enrollment
|
|
21
|
+
/** Whole kids expected: `target_enrollment`, or none when it's empty. */
|
|
22
22
|
export declare function enrollmentOf(offering: PlanOffering): number;
|
|
23
23
|
/** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
|
|
24
24
|
export declare function maxEnrollmentOf(offering: PlanOffering): number;
|
|
@@ -26,7 +26,7 @@ export declare class Staffing {
|
|
|
26
26
|
private readonly ctx;
|
|
27
27
|
private readonly warned;
|
|
28
28
|
constructor(ctx: Context);
|
|
29
|
-
/** Warns once for each row and plan
|
|
29
|
+
/** Warns once for each row and plan, about the first date it's seen on. */
|
|
30
30
|
private warnOnce;
|
|
31
31
|
/**
|
|
32
32
|
* Gathers each block's activities, times and allocations. A block's paid time runs from its first
|
|
@@ -46,13 +46,6 @@ export declare class Staffing {
|
|
|
46
46
|
* Boats count the ones sailors bring.
|
|
47
47
|
*/
|
|
48
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;
|
|
56
49
|
/** Books a cost on a block, split among its activities by their offerings' sailors. */
|
|
57
50
|
private book;
|
|
58
51
|
/**
|
|
@@ -80,8 +73,8 @@ export declare class Staffing {
|
|
|
80
73
|
holds: Map<Id, number>;
|
|
81
74
|
};
|
|
82
75
|
/**
|
|
83
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the
|
|
84
|
-
*
|
|
76
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
|
|
77
|
+
* block of its busiest travel day.
|
|
85
78
|
*/
|
|
86
79
|
private bookTrips;
|
|
87
80
|
}
|
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
/** Whole kids expected: `target_enrollment
|
|
1
|
+
/** Whole kids expected: `target_enrollment`, or none when it's empty. */
|
|
2
2
|
export function enrollmentOf(offering) {
|
|
3
|
-
|
|
4
|
-
return Math.round(offering.target_enrollment);
|
|
5
|
-
return Math.round((offering.capacity ?? 0) * (offering.fill_rate ?? 0));
|
|
3
|
+
return Math.round(offering.target_enrollment ?? 0);
|
|
6
4
|
}
|
|
7
5
|
/** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
|
|
8
6
|
export function maxEnrollmentOf(offering) {
|
|
9
7
|
return offering.capacity ?? enrollmentOf(offering);
|
|
10
8
|
}
|
|
11
|
-
/** `n` rounded up, so a share like 5.0000001 boats stays 5. */
|
|
12
|
-
const ceil = (n) => Math.ceil(n - 1e-9);
|
|
13
9
|
function sum(values) {
|
|
14
10
|
let total = 0;
|
|
15
11
|
for (const v of values)
|
|
@@ -51,9 +47,9 @@ export class Staffing {
|
|
|
51
47
|
constructor(ctx) {
|
|
52
48
|
this.ctx = ctx;
|
|
53
49
|
}
|
|
54
|
-
/** Warns once for each row and plan
|
|
50
|
+
/** Warns once for each row and plan, about the first date it's seen on. */
|
|
55
51
|
warnOnce(code, message, key, scope = {}) {
|
|
56
|
-
const id = `${code}|${key}|${scope.
|
|
52
|
+
const id = `${code}|${key}|${scope.plan_id ?? ""}`;
|
|
57
53
|
if (this.warned.has(id))
|
|
58
54
|
return;
|
|
59
55
|
this.warned.add(id);
|
|
@@ -80,7 +76,7 @@ export class Staffing {
|
|
|
80
76
|
continue;
|
|
81
77
|
}
|
|
82
78
|
if (row.date !== a.date) {
|
|
83
|
-
this.ctx.warn("block_date_mismatch", `${a.offering.name}'s ${a.kind} is in ${row.name}, which is on ${dayOf(row.date)}`, a.stored.key, {
|
|
79
|
+
this.ctx.warn("block_date_mismatch", `${a.offering.name}'s ${a.kind} is in ${row.name}, which is on ${dayOf(row.date)}`, a.stored.key, { plan_id: a.stored.plan_id, date: a.date });
|
|
84
80
|
}
|
|
85
81
|
members.set(id, [...(members.get(id) ?? []), a]);
|
|
86
82
|
}
|
|
@@ -99,7 +95,6 @@ export class Staffing {
|
|
|
99
95
|
const byOffering = groupBy(list, (a) => a.offering.id);
|
|
100
96
|
const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
|
|
101
97
|
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));
|
|
103
98
|
result.set(id, {
|
|
104
99
|
row,
|
|
105
100
|
members: list,
|
|
@@ -111,7 +106,6 @@ export class Staffing {
|
|
|
111
106
|
staff: staff.get(id) ?? [],
|
|
112
107
|
boats: boats.get(id) ?? [],
|
|
113
108
|
shares: new Map(list.map((a) => [a, share(a)])),
|
|
114
|
-
fill: most > 0 ? Math.min(1, total / most) : 1,
|
|
115
109
|
});
|
|
116
110
|
}
|
|
117
111
|
return result;
|
|
@@ -125,7 +119,7 @@ export class Staffing {
|
|
|
125
119
|
if (block !== undefined && sum(block.staff.map((s) => s.count)) > 0)
|
|
126
120
|
continue;
|
|
127
121
|
this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} has no staff`, a.stored.key, {
|
|
128
|
-
|
|
122
|
+
plan_id: a.stored.plan_id,
|
|
129
123
|
date: a.date,
|
|
130
124
|
});
|
|
131
125
|
}
|
|
@@ -149,7 +143,7 @@ export class Staffing {
|
|
|
149
143
|
const most = (o) => ctx.maxEnrollment.get(o.id) ?? 0;
|
|
150
144
|
const sailors = sum(offerings.map(most));
|
|
151
145
|
const warns = sailors > 0;
|
|
152
|
-
const scope = {
|
|
146
|
+
const scope = { plan_id: block.row.plan_id, date: block.row.date };
|
|
153
147
|
// The block's offerings, with the verbs that agree with them: "Guppies Week 2 is for", "has".
|
|
154
148
|
const several = new Set(block.members.map((a) => a.offering.id)).size > 1;
|
|
155
149
|
const is = `${offeringsOf(block)} ${several ? "are" : "is"} for up to`;
|
|
@@ -161,7 +155,7 @@ export class Staffing {
|
|
|
161
155
|
const settings = programs.get(o.product_id);
|
|
162
156
|
if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
|
|
163
157
|
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, {
|
|
158
|
+
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, { plan_id: o.plan_id, date: block.row.date });
|
|
165
159
|
}
|
|
166
160
|
continue;
|
|
167
161
|
}
|
|
@@ -208,26 +202,6 @@ export class Staffing {
|
|
|
208
202
|
holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
|
|
209
203
|
}
|
|
210
204
|
}
|
|
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;
|
|
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 };
|
|
230
|
-
}
|
|
231
205
|
/** Books a cost on a block, split among its activities by their offerings' sailors. */
|
|
232
206
|
book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
|
|
233
207
|
if (amount === 0 || account === undefined)
|
|
@@ -257,17 +231,13 @@ export class Staffing {
|
|
|
257
231
|
const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
258
232
|
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
259
233
|
for (const list of groupBy(blocks, (b) => b.row.date).values()) {
|
|
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
|
-
}));
|
|
234
|
+
const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
|
|
265
235
|
for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
|
|
266
236
|
const name = classes.get(id)?.name ?? id;
|
|
267
237
|
const rate = rates.get(id)?.charter_per_day_cents;
|
|
268
238
|
if (rate == null) {
|
|
269
239
|
for (const { block } of uses) {
|
|
270
|
-
this.warnOnce("no_charter_rate", `${block.row.name} charters ${pluralOf(name)}, but the Operations Plan has no charter rate for them, so they cost nothing`, id, {
|
|
240
|
+
this.warnOnce("no_charter_rate", `${block.row.name} charters ${pluralOf(name)}, but the Operations Plan has no charter rate for them, so they cost nothing`, id, { plan_id: block.row.plan_id, date: block.row.date });
|
|
271
241
|
}
|
|
272
242
|
continue;
|
|
273
243
|
}
|
|
@@ -305,9 +275,9 @@ export class Staffing {
|
|
|
305
275
|
const message = peak.at.length === 1
|
|
306
276
|
? `${count} out at once for ${offeringsOf(peak.at[0].block)}, but ${organization} has ${only}`
|
|
307
277
|
: `${count} out at once, but ${organization} has ${only}: ${listOf(peak.at.map(({ block, n }) => `${n} for ${offeringsOf(block)}`))}`;
|
|
308
|
-
for (const
|
|
278
|
+
for (const plan of new Set(peak.at.map(({ block }) => block.row.plan_id))) {
|
|
309
279
|
ctx.warn(code, message, what, {
|
|
310
|
-
|
|
280
|
+
plan_id: plan,
|
|
311
281
|
date: date,
|
|
312
282
|
});
|
|
313
283
|
}
|
|
@@ -343,9 +313,9 @@ export class Staffing {
|
|
|
343
313
|
const hours = extraCoachMinutes(list) / 60;
|
|
344
314
|
const total = result.get(key) ?? { hours: 0, dates: 0 };
|
|
345
315
|
result.set(key, { hours: total.hours + hours, dates: total.dates + 1 });
|
|
346
|
-
for (const
|
|
316
|
+
for (const plan of new Set(list.map((b) => b.row.plan_id))) {
|
|
347
317
|
this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time` +
|
|
348
|
-
(hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, {
|
|
318
|
+
(hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { plan_id: plan, date });
|
|
349
319
|
}
|
|
350
320
|
}
|
|
351
321
|
return result;
|
|
@@ -368,13 +338,10 @@ export class Staffing {
|
|
|
368
338
|
const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
369
339
|
const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
370
340
|
const given = new Map();
|
|
371
|
-
const tripCoaches = new Map();
|
|
372
341
|
for (const block of byDate(blocks)) {
|
|
373
|
-
const scope = {
|
|
342
|
+
const scope = { plan_id: block.row.plan_id, date: block.row.date };
|
|
374
343
|
const paidHours = block.paid_minutes / 60;
|
|
375
344
|
const waterHours = block.on_water_minutes / 60;
|
|
376
|
-
const expected = this.coachesExpected(block, programs);
|
|
377
|
-
tripCoaches.set(block.row.id, expected.coaches);
|
|
378
345
|
const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
|
|
379
346
|
let wages = 0;
|
|
380
347
|
for (const s of block.staff) {
|
|
@@ -383,7 +350,7 @@ export class Staffing {
|
|
|
383
350
|
if (s.position_key != null) {
|
|
384
351
|
const position = positions.get(s.position_key);
|
|
385
352
|
if (s.pay === "hourly") {
|
|
386
|
-
const hours = s.count *
|
|
353
|
+
const hours = s.count * paidHours;
|
|
387
354
|
const rate = position?.hourly_coaching_rate_cents;
|
|
388
355
|
if (position !== undefined && rate == null) {
|
|
389
356
|
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);
|
|
@@ -419,18 +386,16 @@ export class Staffing {
|
|
|
419
386
|
continue;
|
|
420
387
|
}
|
|
421
388
|
if (level.pay_type !== "volunteer") {
|
|
422
|
-
wages += s.count *
|
|
389
|
+
wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
|
|
423
390
|
}
|
|
424
391
|
}
|
|
425
|
-
book("coach_wages", wages, "coach_wages", "Coach wages", "
|
|
392
|
+
book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
|
|
426
393
|
for (const b of block.boats) {
|
|
427
394
|
const rate = boatRates.get(b.boat_class_id);
|
|
428
395
|
if (rate === undefined || b.count <= 0 || waterHours <= 0)
|
|
429
396
|
continue;
|
|
430
397
|
const boatClass = boatClasses.get(b.boat_class_id);
|
|
431
|
-
this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"),
|
|
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");
|
|
398
|
+
this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), b.count * waterHours * (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 × on-water hours × the class's fuel and upkeep");
|
|
434
399
|
}
|
|
435
400
|
const coaches = sum(block.staff.map((s) => s.count));
|
|
436
401
|
const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
|
|
@@ -439,7 +404,7 @@ export class Staffing {
|
|
|
439
404
|
}
|
|
440
405
|
}
|
|
441
406
|
this.bookCharters(byDate(blocks));
|
|
442
|
-
this.bookTrips(blocks, scheduled
|
|
407
|
+
this.bookTrips(blocks, scheduled);
|
|
443
408
|
return {
|
|
444
409
|
coaching,
|
|
445
410
|
doubleBooked,
|
|
@@ -461,10 +426,10 @@ export class Staffing {
|
|
|
461
426
|
};
|
|
462
427
|
}
|
|
463
428
|
/**
|
|
464
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the
|
|
465
|
-
*
|
|
429
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
|
|
430
|
+
* block of its busiest travel day.
|
|
466
431
|
*/
|
|
467
|
-
bookTrips(blocks, scheduled
|
|
432
|
+
bookTrips(blocks, scheduled) {
|
|
468
433
|
const { ctx } = this;
|
|
469
434
|
for (const trip of ctx.data.plan_trips) {
|
|
470
435
|
const offering = ctx.offeringsById.get(trip.offering_id);
|
|
@@ -472,14 +437,13 @@ export class Staffing {
|
|
|
472
437
|
continue;
|
|
473
438
|
const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
|
|
474
439
|
const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
|
|
475
|
-
const coaches = Math.max(0, ...staffed.map((b) =>
|
|
476
|
-
|
|
477
|
-
if (given === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
|
|
440
|
+
const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
|
|
441
|
+
if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
|
|
478
442
|
const first = scheduled
|
|
479
443
|
.filter(travel)
|
|
480
444
|
.map((a) => a.date)
|
|
481
445
|
.sort()[0];
|
|
482
|
-
this.warnOnce("trip_without_staff", `${trip.name} has no staff on its travel days, so the plan has no airfare or lodging for its coaches`, trip.key, {
|
|
446
|
+
this.warnOnce("trip_without_staff", `${trip.name} has no staff on its travel days, so the plan has no airfare or lodging for its coaches`, trip.key, { plan_id: trip.plan_id, ...(first !== undefined && { date: first }) });
|
|
483
447
|
}
|
|
484
448
|
const lodging = ctx.rates?.lodging_per_night_cents ?? null;
|
|
485
449
|
const mileage = ctx.rates?.mileage_rate_cents ?? null;
|