@cyc-seattle/platform 1.7.0 → 1.9.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/dist/planning/activities.d.ts +18 -0
- package/dist/planning/activities.js +33 -0
- package/dist/planning/bonus.d.ts +6 -0
- package/dist/planning/bonus.js +49 -0
- package/dist/planning/context.d.ts +54 -0
- package/dist/planning/context.js +84 -0
- package/dist/planning/dates.d.ts +12 -0
- package/dist/planning/dates.js +31 -0
- package/dist/planning/engine.d.ts +10 -0
- package/dist/planning/engine.js +134 -0
- package/dist/planning/index.d.ts +2 -0
- package/dist/planning/index.js +1 -0
- package/dist/planning/lines.d.ts +19 -0
- package/dist/planning/lines.js +91 -0
- package/dist/planning/measures.d.ts +7 -0
- package/dist/planning/measures.js +31 -0
- package/dist/planning/payroll.d.ts +3 -0
- package/dist/planning/payroll.js +31 -0
- package/dist/planning/pools.d.ts +7 -0
- package/dist/planning/pools.js +39 -0
- package/dist/planning/revenue.d.ts +6 -0
- package/dist/planning/revenue.js +51 -0
- package/dist/planning/salaried.d.ts +9 -0
- package/dist/planning/salaried.js +75 -0
- package/dist/planning/staffing.d.ts +48 -0
- package/dist/planning/staffing.js +360 -0
- package/dist/planning/types.d.ts +318 -0
- package/dist/planning/types.js +3 -0
- package/package.json +5 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mtdc } from "./measures.js";
|
|
2
|
+
/**
|
|
3
|
+
* Divides everything in each pool among all of the plan year's offerings by MTDC, keeping each
|
|
4
|
+
* amount's account and function. Shared costs never count toward MTDC, so one pool's split never shifts
|
|
5
|
+
* another's.
|
|
6
|
+
*/
|
|
7
|
+
export function allocatePools(ctx) {
|
|
8
|
+
const weights = new Map(ctx.fyOfferings().map((o) => [o.key, Math.max(0, mtdc(ctx, o.key))]));
|
|
9
|
+
const total = [...weights.values()].reduce((t, w) => t + w, 0);
|
|
10
|
+
const pools = new Map(ctx.data.plan_pools.map((p) => [p.key, p]));
|
|
11
|
+
const pooled = [];
|
|
12
|
+
const kept = [];
|
|
13
|
+
for (const e of ctx.entries)
|
|
14
|
+
(e.cost_center_type === "pool" ? pooled : kept).push(e);
|
|
15
|
+
if (pooled.length === 0)
|
|
16
|
+
return;
|
|
17
|
+
ctx.entries.splice(0, ctx.entries.length, ...kept);
|
|
18
|
+
if (total <= 0) {
|
|
19
|
+
ctx.warn("no_mtdc", "No offering in the plan year has MTDC, so the shared costs stay with the organization");
|
|
20
|
+
}
|
|
21
|
+
for (const e of pooled) {
|
|
22
|
+
const pool = pools.get(e.cost_center_key ?? "");
|
|
23
|
+
const base = {
|
|
24
|
+
account_id: e.account_id,
|
|
25
|
+
function: e.function,
|
|
26
|
+
line_key: `pool:${e.cost_center_key}:${e.line_key}`,
|
|
27
|
+
explanation: `${pool?.name ?? "Pool"}: ${e.explanation}`,
|
|
28
|
+
stage: "pool",
|
|
29
|
+
mtdc_share: 0,
|
|
30
|
+
};
|
|
31
|
+
if (total <= 0) {
|
|
32
|
+
ctx.book({ ...base, cost_center_type: "org", cost_center_key: null, amount: e.amount });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const [key, w] of weights) {
|
|
36
|
+
ctx.book({ ...base, cost_center_type: "offering", cost_center_key: key, amount: (e.amount * w) / total });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
import type { ComputedActivity } from "./types.ts";
|
|
3
|
+
/** Counts each offering's days: the dates it has an activity other than travel. */
|
|
4
|
+
export declare function countDays(ctx: Context, activities: ComputedActivity[]): void;
|
|
5
|
+
/** Records each offering's revenue, price × enrollment, with its COGS and card fees. */
|
|
6
|
+
export declare function bookRevenue(ctx: Context): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Counts each offering's days: the dates it has an activity other than travel. */
|
|
2
|
+
export function countDays(ctx, activities) {
|
|
3
|
+
const dates = new Map();
|
|
4
|
+
for (const a of activities) {
|
|
5
|
+
if (a.kind === "travel")
|
|
6
|
+
continue;
|
|
7
|
+
dates.set(a.offering_key, (dates.get(a.offering_key) ?? new Set()).add(a.date));
|
|
8
|
+
}
|
|
9
|
+
for (const [key, set] of dates)
|
|
10
|
+
ctx.days.set(key, set.size);
|
|
11
|
+
}
|
|
12
|
+
/** Records each offering's revenue, price × enrollment, with its COGS and card fees. */
|
|
13
|
+
export function bookRevenue(ctx) {
|
|
14
|
+
for (const offering of ctx.fyOfferings()) {
|
|
15
|
+
const product = ctx.products.get(offering.product_id);
|
|
16
|
+
if (product === undefined) {
|
|
17
|
+
ctx.warn("unknown_product", `Offering ${offering.key} names a missing product`, offering.key);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const enrollment = ctx.enrollment.get(offering.id) ?? 0;
|
|
21
|
+
const price = offering.price_cents ?? 0;
|
|
22
|
+
const total = price * enrollment;
|
|
23
|
+
if (total === 0)
|
|
24
|
+
continue;
|
|
25
|
+
if (product.revenue_account_id == null) {
|
|
26
|
+
ctx.warn("no_revenue_account", `Product ${product.name} has no revenue account`, product.id);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const book = (account, amount, line, explanation) => ctx.book({
|
|
30
|
+
cost_center_type: "offering",
|
|
31
|
+
cost_center_key: offering.key,
|
|
32
|
+
account_id: account,
|
|
33
|
+
function: ctx.functionFor(account, null, "offering"),
|
|
34
|
+
amount,
|
|
35
|
+
line_key: `${line}:${offering.key}`,
|
|
36
|
+
explanation,
|
|
37
|
+
});
|
|
38
|
+
book(product.revenue_account_id, total, "revenue", `${price}¢ × ${enrollment} enrolled`);
|
|
39
|
+
if (offering.cogs_rate != null && offering.cogs_rate > 0) {
|
|
40
|
+
const cogs = ctx.costAccount("cogs");
|
|
41
|
+
if (cogs !== undefined)
|
|
42
|
+
book(cogs, total * offering.cogs_rate, "cogs", `${offering.cogs_rate} × revenue`);
|
|
43
|
+
}
|
|
44
|
+
const cardFeeRate = ctx.rates?.card_fee_rate ?? 0;
|
|
45
|
+
if (cardFeeRate > 0) {
|
|
46
|
+
const fees = ctx.costAccount("card_fees");
|
|
47
|
+
if (fees !== undefined)
|
|
48
|
+
book(fees, total * cardFeeRate, "card_fees", `${cardFeeRate} × revenue`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
import type { CostCenterBooker } from "./lines.ts";
|
|
3
|
+
import type { SalariedCoaching } from "./staffing.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Charges each salaried position's salary: its coaching and management hours at its hourly
|
|
6
|
+
* equivalent go to what they served, and the rest, including its `ga_share` off the top, goes to its
|
|
7
|
+
* remainder pool, or the organization if it has none.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bookSalaries(ctx: Context, coaching: SalariedCoaching[], booker: CostCenterBooker): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charges each salaried position's salary: its coaching and management hours at its hourly
|
|
3
|
+
* equivalent go to what they served, and the rest, including its `ga_share` off the top, goes to its
|
|
4
|
+
* remainder pool, or the organization if it has none.
|
|
5
|
+
*/
|
|
6
|
+
export function bookSalaries(ctx, coaching, booker) {
|
|
7
|
+
if (ctx.data.plan_salaried_positions.length === 0)
|
|
8
|
+
return;
|
|
9
|
+
const account = ctx.costAccount("salaries");
|
|
10
|
+
if (account === undefined)
|
|
11
|
+
return;
|
|
12
|
+
const pools = new Map(ctx.data.plan_pools.map((p) => [p.id, p]));
|
|
13
|
+
const known = new Set(ctx.data.plan_salaried_positions.map((p) => p.key));
|
|
14
|
+
for (const c of coaching) {
|
|
15
|
+
if (!known.has(c.position_key)) {
|
|
16
|
+
ctx.warn("unknown_position", `A block rule names missing salaried position ${c.position_key}`, c.position_key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const position of ctx.data.plan_salaried_positions) {
|
|
20
|
+
const salary = position.annual_salary_cents;
|
|
21
|
+
const rate = position.paid_hours_per_year != null && position.paid_hours_per_year > 0
|
|
22
|
+
? salary / position.paid_hours_per_year
|
|
23
|
+
: null;
|
|
24
|
+
const charges = [
|
|
25
|
+
...coaching
|
|
26
|
+
.filter((c) => c.position_key === position.key)
|
|
27
|
+
.map((c) => ({
|
|
28
|
+
cost_center_type: "offering",
|
|
29
|
+
cost_center_key: c.offering_key,
|
|
30
|
+
hours: c.hours,
|
|
31
|
+
line: "coaching",
|
|
32
|
+
})),
|
|
33
|
+
...ctx.data.plan_salaried_bookings
|
|
34
|
+
.filter((b) => b.position_id === position.id)
|
|
35
|
+
.map((b) => ({
|
|
36
|
+
cost_center_type: "product",
|
|
37
|
+
cost_center_key: b.product_id,
|
|
38
|
+
hours: b.hours,
|
|
39
|
+
line: b.kind,
|
|
40
|
+
})),
|
|
41
|
+
];
|
|
42
|
+
if (rate === null && charges.length > 0) {
|
|
43
|
+
ctx.warn("no_paid_hours", `${position.name} has hours charged to programs but no paid_hours_per_year, so they cost nothing`, position.key);
|
|
44
|
+
}
|
|
45
|
+
const available = salary * (1 - (position.ga_share ?? 0));
|
|
46
|
+
const demand = charges.reduce((t, c) => t + c.hours * (rate ?? 0), 0);
|
|
47
|
+
const scale = demand > available && demand > 0 ? available / demand : 1;
|
|
48
|
+
if (scale < 1) {
|
|
49
|
+
ctx.warn("position_overbooked", `${position.name}'s hours charged to programs cost more than its salary after ga_share, so they're scaled down`, position.key);
|
|
50
|
+
}
|
|
51
|
+
for (const c of charges) {
|
|
52
|
+
booker.add({
|
|
53
|
+
cost_center_type: c.cost_center_type,
|
|
54
|
+
cost_center_key: c.cost_center_key,
|
|
55
|
+
account_id: account,
|
|
56
|
+
function: ctx.functionFor(account, null, "offering"),
|
|
57
|
+
amount: c.hours * (rate ?? 0) * scale,
|
|
58
|
+
line_key: `salary:${position.key}:${c.line}`,
|
|
59
|
+
explanation: `${position.name}: ${c.line} hours at the salary's hourly equivalent`,
|
|
60
|
+
wages: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const remainderPool = position.remainder_pool_id == null ? undefined : pools.get(position.remainder_pool_id);
|
|
64
|
+
booker.add({
|
|
65
|
+
cost_center_type: remainderPool === undefined ? "org" : "pool",
|
|
66
|
+
cost_center_key: remainderPool?.key ?? null,
|
|
67
|
+
account_id: account,
|
|
68
|
+
function: ctx.functionFor(account, null, "pool"),
|
|
69
|
+
amount: salary - demand * scale,
|
|
70
|
+
line_key: `salary:${position.key}:remainder`,
|
|
71
|
+
explanation: `${position.name}: salary not charged to coaching or bookings`,
|
|
72
|
+
wages: true,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
import type { ScheduledActivity } from "./activities.ts";
|
|
3
|
+
import type { ComputedActivity, Id, PlanOffering } from "./types.ts";
|
|
4
|
+
/** Hours a salaried position coached, by offering key, for the salaried rule. */
|
|
5
|
+
export interface SalariedCoaching {
|
|
6
|
+
position_key: Id;
|
|
7
|
+
offering_key: Id;
|
|
8
|
+
hours: number;
|
|
9
|
+
}
|
|
10
|
+
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
11
|
+
export declare function enrollmentOf(offering: PlanOffering): number;
|
|
12
|
+
export declare class Staffing {
|
|
13
|
+
private readonly ctx;
|
|
14
|
+
private readonly boatClasses;
|
|
15
|
+
private readonly offeringBoatClasses;
|
|
16
|
+
private readonly covers;
|
|
17
|
+
private readonly warned;
|
|
18
|
+
constructor(ctx: Context);
|
|
19
|
+
private warnOnce;
|
|
20
|
+
/** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
|
|
21
|
+
qualifies(held: Id[], required: Id): boolean;
|
|
22
|
+
private levelFor;
|
|
23
|
+
private sailorsByClass;
|
|
24
|
+
private boatsFor;
|
|
25
|
+
private initial;
|
|
26
|
+
private blockFor;
|
|
27
|
+
/**
|
|
28
|
+
* Staffs a block's activities on one date as one group, then gives each activity its share. A
|
|
29
|
+
* count an activity fixes keeps it out of that part of the group.
|
|
30
|
+
*/
|
|
31
|
+
private staffBlock;
|
|
32
|
+
/**
|
|
33
|
+
* One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
|
|
34
|
+
* it from resources excluded from MTDC, such as rentals.
|
|
35
|
+
*/
|
|
36
|
+
private ribRate;
|
|
37
|
+
/** Staffs every activity, and records what its coaches, RIBs, boats and resources cost. */
|
|
38
|
+
run(scheduled: ScheduledActivity[]): {
|
|
39
|
+
activities: ComputedActivity[];
|
|
40
|
+
coaching: SalariedCoaching[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are those of its
|
|
44
|
+
* travel days; with none, the offering's coaching ratio sets them.
|
|
45
|
+
*/
|
|
46
|
+
private bookTrips;
|
|
47
|
+
}
|
|
48
|
+
export declare function round2(n: number): number;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { isoWeekday, within } from "./dates.js";
|
|
2
|
+
/** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
|
|
3
|
+
export function enrollmentOf(offering) {
|
|
4
|
+
if (offering.target_enrollment != null)
|
|
5
|
+
return Math.round(offering.target_enrollment);
|
|
6
|
+
return Math.round((offering.capacity ?? 0) * (offering.fill_rate ?? 0));
|
|
7
|
+
}
|
|
8
|
+
function sum(values) {
|
|
9
|
+
let total = 0;
|
|
10
|
+
for (const v of values)
|
|
11
|
+
total += v;
|
|
12
|
+
return total;
|
|
13
|
+
}
|
|
14
|
+
/** Coaches for `enrollment` sailors: at least `min_coaches`, and one per `sailors_per_coach`. */
|
|
15
|
+
function coachesFor(offering, enrollment) {
|
|
16
|
+
const ratio = offering.sailors_per_coach;
|
|
17
|
+
const byRatio = ratio != null && ratio > 0 ? Math.ceil(enrollment / ratio) : 0;
|
|
18
|
+
return Math.max(offering.min_coaches ?? 0, byRatio);
|
|
19
|
+
}
|
|
20
|
+
export class Staffing {
|
|
21
|
+
ctx;
|
|
22
|
+
boatClasses;
|
|
23
|
+
offeringBoatClasses = new Map();
|
|
24
|
+
covers = new Map();
|
|
25
|
+
warned = new Set();
|
|
26
|
+
constructor(ctx) {
|
|
27
|
+
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
|
+
for (const c of ctx.data.qualification_covers) {
|
|
33
|
+
this.covers.set(c.qualification_id, new Set([...(this.covers.get(c.qualification_id) ?? []), c.covers_id]));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
warnOnce(code, message, key) {
|
|
37
|
+
if (this.warned.has(`${code}|${key}`))
|
|
38
|
+
return;
|
|
39
|
+
this.warned.add(`${code}|${key}`);
|
|
40
|
+
this.ctx.warn(code, message, key);
|
|
41
|
+
}
|
|
42
|
+
/** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
|
|
43
|
+
qualifies(held, required) {
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const stack = [...held];
|
|
46
|
+
while (stack.length > 0) {
|
|
47
|
+
const q = stack.pop();
|
|
48
|
+
if (q === required)
|
|
49
|
+
return true;
|
|
50
|
+
if (seen.has(q))
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(q);
|
|
53
|
+
stack.push(...(this.covers.get(q) ?? []));
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
levelFor(offering, block) {
|
|
58
|
+
const levels = this.ctx.data.plan_staff_levels;
|
|
59
|
+
const blockLevel = block?.coach_level_id == null ? undefined : levels.find((l) => l.id === block.coach_level_id);
|
|
60
|
+
if (blockLevel !== undefined)
|
|
61
|
+
return blockLevel;
|
|
62
|
+
if (offering.coach_level_key == null)
|
|
63
|
+
return undefined;
|
|
64
|
+
return levels.find((l) => l.key === offering.coach_level_key);
|
|
65
|
+
}
|
|
66
|
+
sailorsByClass(offering, enrollment) {
|
|
67
|
+
const onClubBoats = enrollment * (1 - (offering.byob_share ?? 0));
|
|
68
|
+
const classes = this.offeringBoatClasses.get(offering.id) ?? [];
|
|
69
|
+
if (classes.length === 0)
|
|
70
|
+
return new Map([["", onClubBoats]]);
|
|
71
|
+
return new Map(classes.map((c) => [c.boat_class_id, onClubBoats * c.share]));
|
|
72
|
+
}
|
|
73
|
+
boatsFor(sailors, offeringKey) {
|
|
74
|
+
const boats = new Map();
|
|
75
|
+
for (const [classId, n] of sailors) {
|
|
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
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
boats.set(classId, Math.ceil(n / perBoat));
|
|
86
|
+
}
|
|
87
|
+
return boats;
|
|
88
|
+
}
|
|
89
|
+
initial(a) {
|
|
90
|
+
const { offering, stored } = a;
|
|
91
|
+
const enrollment = this.ctx.enrollment.get(offering.id) ?? 0;
|
|
92
|
+
const fixedCoaches = stored.fixed_coaches ?? offering.fixed_coaches;
|
|
93
|
+
const fixedRibs = stored.fixed_ribs ?? offering.fixed_ribs;
|
|
94
|
+
const fixedBoats = stored.fixed_boats ?? offering.fixed_boats;
|
|
95
|
+
const coaches = fixedCoaches ?? coachesFor(offering, enrollment);
|
|
96
|
+
const travel = a.kind === "travel";
|
|
97
|
+
const ribs = fixedRibs ?? (travel ? 0 : Math.ceil(coaches * (offering.ribs_per_coach ?? 0)));
|
|
98
|
+
const sailors = a.uses_boats ? this.sailorsByClass(offering, enrollment) : new Map();
|
|
99
|
+
let boats;
|
|
100
|
+
if (!a.uses_boats)
|
|
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]));
|
|
105
|
+
}
|
|
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
|
+
level: undefined,
|
|
119
|
+
block: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
blockFor(a) {
|
|
123
|
+
if (a.kind === "travel")
|
|
124
|
+
return null;
|
|
125
|
+
return (this.ctx.data.plan_activity_block_rules.find((b) => b.offering_keys.includes(a.offering.key) &&
|
|
126
|
+
within(a.date, b.starts_on, b.ends_on) &&
|
|
127
|
+
(b.weekdays == null || b.weekdays.length === 0 || b.weekdays.includes(isoWeekday(a.date)))) ?? null);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Staffs a block's activities on one date as one group, then gives each activity its share. A
|
|
131
|
+
* count an activity fixes keeps it out of that part of the group.
|
|
132
|
+
*/
|
|
133
|
+
staffBlock(block, group) {
|
|
134
|
+
for (const s of group)
|
|
135
|
+
s.block = block;
|
|
136
|
+
const byEnrollment = (members, total, s) => sum(members.map((m) => m.enrollment)) > 0
|
|
137
|
+
? (total * s.enrollment) / sum(members.map((m) => m.enrollment))
|
|
138
|
+
: total / members.length;
|
|
139
|
+
const coached = group.filter((s) => !s.fixed.coaches);
|
|
140
|
+
let coachTotal = 0;
|
|
141
|
+
if (coached.length > 0) {
|
|
142
|
+
const load = sum(coached.map((s) => {
|
|
143
|
+
const ratio = s.a.offering.sailors_per_coach;
|
|
144
|
+
return ratio != null && ratio > 0 ? s.enrollment / ratio : 0;
|
|
145
|
+
}));
|
|
146
|
+
coachTotal = Math.max(Math.ceil(load - 1e-9), ...coached.map((s) => s.a.offering.min_coaches ?? 0));
|
|
147
|
+
for (const s of coached)
|
|
148
|
+
s.coaches = byEnrollment(coached, coachTotal, s);
|
|
149
|
+
}
|
|
150
|
+
const ribbed = group.filter((s) => !s.fixed.ribs);
|
|
151
|
+
if (ribbed.length > 0) {
|
|
152
|
+
const coaches = sum(ribbed.map((s) => s.coaches));
|
|
153
|
+
const ratio = Math.max(...ribbed.map((s) => s.a.offering.ribs_per_coach ?? 0));
|
|
154
|
+
const ribTotal = Math.ceil(coaches * ratio - 1e-9);
|
|
155
|
+
for (const s of ribbed)
|
|
156
|
+
s.ribs = coaches > 0 ? (ribTotal * s.coaches) / coaches : 0;
|
|
157
|
+
}
|
|
158
|
+
const boated = group.filter((s) => !s.fixed.boats && s.a.uses_boats);
|
|
159
|
+
const sailorsByClass = new Map();
|
|
160
|
+
for (const s of boated) {
|
|
161
|
+
for (const [c, n] of s.sailors)
|
|
162
|
+
sailorsByClass.set(c, (sailorsByClass.get(c) ?? 0) + n);
|
|
163
|
+
}
|
|
164
|
+
const shared = this.boatsFor(sailorsByClass, group[0]?.a.offering.key ?? "");
|
|
165
|
+
for (const s of boated) {
|
|
166
|
+
s.boats = new Map([...s.sailors].map(([c, n]) => {
|
|
167
|
+
const totalSailors = sailorsByClass.get(c) ?? 0;
|
|
168
|
+
return [c, totalSailors > 0 ? ((shared.get(c) ?? 0) * n) / totalSailors : 0];
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
// The block's salaried positions take its first coaching slots, so fewer hourly coaches are paid.
|
|
172
|
+
const positions = block.salaried_positions ?? [];
|
|
173
|
+
if (positions.length > 0 && coachTotal > 0) {
|
|
174
|
+
const salariedTotal = Math.min(positions.length, coachTotal);
|
|
175
|
+
for (const s of coached) {
|
|
176
|
+
s.salaried = (salariedTotal * s.coaches) / coachTotal;
|
|
177
|
+
s.positions = positions.map((key) => ({ key, coaches: s.salaried / positions.length }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
|
|
183
|
+
* it from resources excluded from MTDC, such as rentals.
|
|
184
|
+
*/
|
|
185
|
+
ribRate() {
|
|
186
|
+
const kind = this.ctx.data.rib_resource_kind_id;
|
|
187
|
+
if (kind == null)
|
|
188
|
+
return undefined;
|
|
189
|
+
const ribs = this.ctx.data.plan_resources.filter((r) => r.resource_kind_id === kind);
|
|
190
|
+
if (ribs.length === 0)
|
|
191
|
+
return undefined;
|
|
192
|
+
const weight = (r) => r.quantity ?? 1;
|
|
193
|
+
const totalWeight = sum(ribs.map(weight));
|
|
194
|
+
const average = (rows, cost) => sum(rows.map((r) => weight(r) * cost(r))) / totalWeight;
|
|
195
|
+
const rentals = ribs.filter((r) => r.excluded_from_mtdc);
|
|
196
|
+
return {
|
|
197
|
+
hourly: average(ribs, hourlyCost),
|
|
198
|
+
daily: average(ribs, (r) => r.cost_per_day_cents ?? 0),
|
|
199
|
+
hourlyRental: average(rentals, hourlyCost),
|
|
200
|
+
rental: average(rentals, (r) => r.cost_per_day_cents ?? 0),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Staffs every activity, and records what its coaches, RIBs, boats and resources cost. */
|
|
204
|
+
run(scheduled) {
|
|
205
|
+
const { ctx } = this;
|
|
206
|
+
const staffed = scheduled.map((a) => this.initial(a));
|
|
207
|
+
const groups = new Map();
|
|
208
|
+
for (const s of staffed) {
|
|
209
|
+
const block = this.blockFor(s.a);
|
|
210
|
+
if (block === null)
|
|
211
|
+
continue;
|
|
212
|
+
const id = `${block.id}|${s.a.date}`;
|
|
213
|
+
const group = groups.get(id) ?? { block, members: [] };
|
|
214
|
+
group.members.push(s);
|
|
215
|
+
groups.set(id, group);
|
|
216
|
+
}
|
|
217
|
+
for (const { block, members } of groups.values())
|
|
218
|
+
this.staffBlock(block, members);
|
|
219
|
+
const coaching = [];
|
|
220
|
+
const resourcesByActivity = new Map();
|
|
221
|
+
for (const r of ctx.data.plan_activity_resources) {
|
|
222
|
+
resourcesByActivity.set(r.activity_id, [...(resourcesByActivity.get(r.activity_id) ?? []), r]);
|
|
223
|
+
}
|
|
224
|
+
const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
|
|
225
|
+
const ribRate = this.ribRate();
|
|
226
|
+
for (const s of staffed) {
|
|
227
|
+
const { a } = s;
|
|
228
|
+
const offering = a.offering;
|
|
229
|
+
const key = offering.key;
|
|
230
|
+
const paidHours = a.paid_minutes / 60;
|
|
231
|
+
const waterHours = a.on_water_minutes / 60;
|
|
232
|
+
const book = (type, amount, line, explanation, mtdcShare = 1) => {
|
|
233
|
+
if (amount === 0)
|
|
234
|
+
return;
|
|
235
|
+
const account = ctx.costAccount(type);
|
|
236
|
+
if (account === undefined)
|
|
237
|
+
return;
|
|
238
|
+
ctx.book({
|
|
239
|
+
cost_center_type: "offering",
|
|
240
|
+
cost_center_key: key,
|
|
241
|
+
account_id: account,
|
|
242
|
+
function: ctx.functionFor(account, null, "offering"),
|
|
243
|
+
amount,
|
|
244
|
+
line_key: `${line}:${key}`,
|
|
245
|
+
explanation,
|
|
246
|
+
mtdc_share: mtdcShare,
|
|
247
|
+
wages: type === "coach_wages",
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
s.level = this.levelFor(offering, s.block);
|
|
251
|
+
const hourly = s.coaches - s.salaried;
|
|
252
|
+
if (offering.required_qualification != null && s.level !== undefined) {
|
|
253
|
+
if (!this.qualifies(s.level.qualifications ?? [], offering.required_qualification)) {
|
|
254
|
+
this.warnOnce("unqualified_level", `Offering ${key}'s coach level ${s.level.name} lacks its required qualification`, key);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (hourly > 0 && s.level === undefined) {
|
|
258
|
+
this.warnOnce("no_coach_level", `Offering ${key} has coaches but no coach level, so they cost nothing`, key);
|
|
259
|
+
}
|
|
260
|
+
const volunteer = s.level?.pay_type === "volunteer";
|
|
261
|
+
const rate = volunteer ? 0 : (s.level?.hourly_rate_cents ?? 0);
|
|
262
|
+
const wages = hourly * paidHours * rate;
|
|
263
|
+
book("coach_wages", wages, "coach_wages", "Hourly coaches × paid hours × the coach level's rate");
|
|
264
|
+
for (const p of s.positions) {
|
|
265
|
+
coaching.push({ position_key: p.key, offering_key: key, hours: p.coaches * paidHours });
|
|
266
|
+
}
|
|
267
|
+
if (s.ribs > 0 && waterHours > 0) {
|
|
268
|
+
if (ribRate === undefined) {
|
|
269
|
+
this.warnOnce("no_rib_rate", "No RIB resource is in the plan, so RIBs cost nothing", "ribs");
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
const cost = s.ribs * (waterHours * ribRate.hourly + ribRate.daily);
|
|
273
|
+
const rental = s.ribs * (waterHours * ribRate.hourlyRental + ribRate.rental);
|
|
274
|
+
book("ribs", cost, "ribs", "RIBs × on-water hours × the RIB rate, plus a day rate", cost > 0 ? 1 - rental / cost : 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const [classId, boats] of s.boats) {
|
|
278
|
+
if (boats <= 0 || waterHours <= 0)
|
|
279
|
+
continue;
|
|
280
|
+
const boatRate = ctx.data.plan_boat_rates.find((r) => r.boat_class_id === classId);
|
|
281
|
+
if (boatRate === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
book("boat_maintenance", boats * waterHours * boatRate.maintenance_per_boat_hour_cents, `boats:${classId}`, "Boats × on-water hours × the class's maintenance rate");
|
|
284
|
+
}
|
|
285
|
+
for (const use of resourcesByActivity.get(a.stored.id) ?? []) {
|
|
286
|
+
const resource = resources.get(use.resource_key);
|
|
287
|
+
if (resource === undefined) {
|
|
288
|
+
this.warnOnce("unknown_resource", `Activity uses missing resource ${use.resource_key}`, use.resource_key);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const hours = use.hours ?? paidHours;
|
|
292
|
+
book("resources", use.quantity * (hours * hourlyCost(resource) + (resource.cost_per_day_cents ?? 0)), `resource:${use.resource_key}`, `${resource.name}: quantity × hours × hourly cost, plus a day rate`, resource.excluded_from_mtdc ? 0 : 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.bookTrips(staffed);
|
|
296
|
+
return {
|
|
297
|
+
coaching,
|
|
298
|
+
activities: staffed.map((s) => ({
|
|
299
|
+
offering_id: s.a.offering.id,
|
|
300
|
+
offering_key: s.a.offering.key,
|
|
301
|
+
trip_id: s.a.trip?.id ?? null,
|
|
302
|
+
activity_id: s.a.stored.id,
|
|
303
|
+
date: s.a.date,
|
|
304
|
+
kind: s.a.kind,
|
|
305
|
+
paid_minutes: s.a.paid_minutes,
|
|
306
|
+
on_water_minutes: s.a.on_water_minutes,
|
|
307
|
+
uses_boats: s.a.uses_boats,
|
|
308
|
+
coaches: s.coaches,
|
|
309
|
+
ribs: s.ribs,
|
|
310
|
+
boats: sum(s.boats.values()),
|
|
311
|
+
block_rule_id: s.block?.id ?? null,
|
|
312
|
+
})),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are those of its
|
|
317
|
+
* travel days; with none, the offering's coaching ratio sets them.
|
|
318
|
+
*/
|
|
319
|
+
bookTrips(staffed) {
|
|
320
|
+
const { ctx } = this;
|
|
321
|
+
for (const trip of ctx.data.plan_trips) {
|
|
322
|
+
const offering = ctx.offeringsById.get(trip.offering_id);
|
|
323
|
+
if (offering === undefined || !ctx.inYear(offering))
|
|
324
|
+
continue;
|
|
325
|
+
const travel = staffed.find((s) => s.a.trip?.id === trip.id);
|
|
326
|
+
const coaches = travel?.coaches ?? offering.fixed_coaches ?? coachesFor(offering, ctx.enrollment.get(offering.id) ?? 0);
|
|
327
|
+
const lodging = ctx.rates?.lodging_per_night_cents ?? null;
|
|
328
|
+
const mileage = ctx.rates?.mileage_rate_cents ?? null;
|
|
329
|
+
if ((trip.nights ?? 0) > 0 && lodging === null) {
|
|
330
|
+
this.warnOnce("no_rate", "Trips have nights but no lodging_per_night_cents rate", "lodging_per_night_cents");
|
|
331
|
+
}
|
|
332
|
+
if ((trip.miles ?? 0) > 0 && mileage === null) {
|
|
333
|
+
this.warnOnce("no_rate", "Trips have miles but no mileage_rate_cents rate", "mileage_rate_cents");
|
|
334
|
+
}
|
|
335
|
+
const amount = coaches * (trip.airfare_cents ?? 0) +
|
|
336
|
+
coaches * (trip.nights ?? 0) * (lodging ?? 0) +
|
|
337
|
+
(trip.miles ?? 0) * (mileage ?? 0);
|
|
338
|
+
if (amount === 0)
|
|
339
|
+
continue;
|
|
340
|
+
const account = ctx.costAccount("travel");
|
|
341
|
+
if (account === undefined)
|
|
342
|
+
continue;
|
|
343
|
+
ctx.book({
|
|
344
|
+
cost_center_type: "offering",
|
|
345
|
+
cost_center_key: offering.key,
|
|
346
|
+
account_id: account,
|
|
347
|
+
function: ctx.functionFor(account, null, "offering"),
|
|
348
|
+
amount,
|
|
349
|
+
line_key: `trip:${trip.key}`,
|
|
350
|
+
explanation: `${trip.name}: ${round2(coaches)} coaches, ${trip.nights ?? 0} nights, ${trip.miles ?? 0} miles`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function hourlyCost(r) {
|
|
356
|
+
return (r.cost_per_hour_cents ?? 0) + (r.fuel_per_hour_cents ?? 0) + (r.maintenance_per_hour_cents ?? 0);
|
|
357
|
+
}
|
|
358
|
+
export function round2(n) {
|
|
359
|
+
return Math.round(n * 100) / 100;
|
|
360
|
+
}
|