@cyc-seattle/platform 1.49.0 → 1.51.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 CHANGED
@@ -101,20 +101,20 @@ deployer needs first.
101
101
  authenticates with Workload Identity Federation and stores no credentials. Each job runs in a
102
102
  GitHub environment that holds its identity and state bucket:
103
103
 
104
- | Event | Jobs, in order |
105
- | -------------------------- | ---------------------------------------------------------------------------------------------- |
106
- | Pull request | `ci` |
107
- | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`, then `mise run integration`; `dev-passed` |
108
- | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
109
- | Run by hand, from a branch | `ci`, then `dev` |
110
- | Run by hand, from `main` | `prod-preview`, then `prod` |
111
-
112
- Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy and
113
- test as one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
114
- tests them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy and the
115
- integration tests pass, and `main` gets the exact commit the queue tested, in one push. If the
116
- newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. To try a
117
- branch on dev before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
104
+ | Event | Jobs, in order |
105
+ | -------------------------- | ----------------------------------------------------------------------- |
106
+ | Pull request | `ci` |
107
+ | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`; `dev-passed` |
108
+ | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
109
+ | Run by hand, from a branch | `ci`, then `dev` |
110
+ | Run by hand, from `main` | `prod-preview`, then `prod` |
111
+
112
+ Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy as
113
+ one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
114
+ deploys them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy passes, and `main` gets the exact commit the queue tested, in one push. If the
115
+ newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. The queue
116
+ doesn't run the integration tests (#161); a run by hand does. To try a
117
+ branch on dev and test it before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
118
118
 
119
119
  `prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
120
120
  `prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
@@ -1,10 +1,12 @@
1
1
  import type { Context } from "./context.ts";
2
- import type { Id } from "./types.ts";
2
+ import { type OfferingTerms } from "./terms.ts";
3
+ import type { BonusSummary } from "./types.ts";
3
4
  /**
4
- * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
5
- * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
6
- * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
7
- * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
8
- * position key.
5
+ * Records each bonus owner's `bonus_rate` × its products' net in each term, above zero, after the
6
+ * pools or before them. A term's loss doesn't lower another term's bonus. Offerings in no term, and
7
+ * anything not on an offering, make one more base, for the year. Every base is worked out before any
8
+ * bonus is booked, so one bonus never counts in another's base. Each bonus is divided among that
9
+ * base's offerings by revenue, with the year's payroll tax on it, and neither is counted in MTDC.
10
+ * Returns each bonus, by owner and term.
9
11
  */
10
- export declare function bookBonuses(ctx: Context): Map<Id, number>;
12
+ export declare function bookBonuses(ctx: Context, terms: OfferingTerms): BonusSummary[];
@@ -1,77 +1,99 @@
1
1
  import { revenue } from "./measures.js";
2
+ import { compareTerms } from "./terms.js";
2
3
  /**
3
- * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
4
- * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
5
- * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
6
- * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
7
- * position key.
4
+ * Records each bonus owner's `bonus_rate` × its products' net in each term, above zero, after the
5
+ * pools or before them. A term's loss doesn't lower another term's bonus. Offerings in no term, and
6
+ * anything not on an offering, make one more base, for the year. Every base is worked out before any
7
+ * bonus is booked, so one bonus never counts in another's base. Each bonus is divided among that
8
+ * base's offerings by revenue, with the year's payroll tax on it, and neither is counted in MTDC.
9
+ * Returns each bonus, by owner and term.
8
10
  */
9
- export function bookBonuses(ctx) {
10
- const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, base: baseOf(ctx, owner) }));
11
+ export function bookBonuses(ctx, terms) {
12
+ const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, bases: basesOf(ctx, owner, terms) }));
11
13
  const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
12
14
  const taxAccount = taxRate > 0 && bonuses.length > 0 ? ctx.costAccount("payroll_tax") : undefined;
13
- const byPosition = new Map();
14
- for (const { owner, base } of bonuses) {
15
- const bonus = owner.bonus_rate * Math.max(0, base.net);
16
- if (bonus === 0)
17
- continue;
18
- const weights = new Map();
19
- for (const key of base.offeringKeys) {
20
- const r = revenue(ctx, key);
21
- if (r > 0)
22
- weights.set(key, r);
23
- }
24
- if (weights.size === 0) {
25
- if (base.offeringKeys.length === 0) {
26
- ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
27
- continue;
28
- }
29
- // Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
30
- ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
31
- for (const key of base.offeringKeys)
32
- weights.set(key, 1);
33
- }
34
- const total = [...weights.values()].reduce((t, w) => t + w, 0);
35
- if (owner.position_key != null) {
36
- byPosition.set(owner.position_key, (byPosition.get(owner.position_key) ?? 0) + bonus);
37
- }
38
- for (const [key, w] of weights) {
39
- const amount = (bonus * w) / total;
40
- ctx.book({
41
- cost_center_type: "offering",
42
- cost_center_key: key,
43
- account_id: owner.account_id,
44
- function: ctx.functionFor(owner.account_id, null, "offering"),
45
- amount,
46
- line_key: `bonus:${owner.key}`,
47
- line_name: `Bonus: ${owner.role_name}`,
48
- explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products`,
49
- stage: "bonus",
50
- mtdc_share: 0,
51
- });
52
- if (taxAccount === undefined)
53
- continue;
54
- ctx.book({
55
- cost_center_type: "offering",
56
- cost_center_key: key,
57
- account_id: taxAccount,
58
- function: ctx.functionFor(owner.account_id, null, "offering"),
59
- amount: amount * taxRate,
60
- line_key: `bonus_payroll_tax:${owner.key}`,
61
- line_name: `Payroll tax on bonus: ${owner.role_name}`,
62
- explanation: `${taxRate} × ${owner.role_name}'s bonus`,
63
- stage: "bonus",
64
- mtdc_share: 0,
15
+ const summaries = [];
16
+ for (const { owner, bases } of bonuses) {
17
+ const area = areaOf(ctx, owner);
18
+ for (const base of bases) {
19
+ const bonus = owner.bonus_rate * Math.max(0, base.net);
20
+ const booked = bonus === 0 ? 0 : bookBonus(ctx, owner, base, bonus, taxRate, taxAccount);
21
+ summaries.push({
22
+ bonus_owner_key: owner.key,
23
+ role_name: owner.role_name,
24
+ position_key: owner.position_key ?? null,
25
+ program_area_id: area,
26
+ term_key: base.term?.key ?? null,
27
+ bonus_rate: owner.bonus_rate,
28
+ net_cents: Math.round(base.net),
29
+ bonus_cents: Math.round(booked),
65
30
  });
66
31
  }
67
32
  }
68
- return byPosition;
33
+ return summaries;
34
+ }
35
+ /** Books one term's bonus on its offerings, by revenue. Returns what it booked. */
36
+ function bookBonus(ctx, owner, base, bonus, taxRate, taxAccount) {
37
+ const weights = new Map();
38
+ for (const key of base.offeringKeys) {
39
+ const r = revenue(ctx, key);
40
+ if (r > 0)
41
+ weights.set(key, r);
42
+ }
43
+ if (weights.size === 0) {
44
+ if (base.offeringKeys.length === 0) {
45
+ ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
46
+ return 0;
47
+ }
48
+ // Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
49
+ ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
50
+ for (const key of base.offeringKeys)
51
+ weights.set(key, 1);
52
+ }
53
+ const total = [...weights.values()].reduce((t, w) => t + w, 0);
54
+ for (const [key, w] of weights) {
55
+ const amount = (bonus * w) / total;
56
+ ctx.book({
57
+ cost_center_type: "offering",
58
+ cost_center_key: key,
59
+ account_id: owner.account_id,
60
+ function: ctx.functionFor(owner.account_id, null, "offering"),
61
+ amount,
62
+ line_key: `bonus:${owner.key}`,
63
+ line_name: `Bonus: ${owner.role_name}`,
64
+ explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products ${base.term === null ? "in no term" : `in ${base.term.name}`}`,
65
+ stage: "bonus",
66
+ mtdc_share: 0,
67
+ });
68
+ if (taxAccount === undefined)
69
+ continue;
70
+ ctx.book({
71
+ cost_center_type: "offering",
72
+ cost_center_key: key,
73
+ account_id: taxAccount,
74
+ function: ctx.functionFor(owner.account_id, null, "offering"),
75
+ amount: amount * taxRate,
76
+ line_key: `bonus_payroll_tax:${owner.key}`,
77
+ line_name: `Payroll tax on bonus: ${owner.role_name}`,
78
+ explanation: `${taxRate} × ${owner.role_name}'s bonus`,
79
+ stage: "bonus",
80
+ mtdc_share: 0,
81
+ });
82
+ }
83
+ return bonus;
84
+ }
85
+ /** The program area of all the owner's products, or null when they're in more than one or none. */
86
+ function areaOf(ctx, owner) {
87
+ const areas = new Set(ctx.data.plan_bonus_owner_products
88
+ .filter((row) => row.bonus_owner_id === owner.id)
89
+ .map((row) => ctx.products.get(row.product_id)?.program_area_id ?? null));
90
+ return areas.size === 1 ? ([...areas][0] ?? null) : null;
69
91
  }
70
92
  /**
71
- * The owner's products' net for the year, and their offerings. An area plan's bonus counts only that
72
- * area's products, and warns about any other.
93
+ * The owner's products' net in each term they have an offering in, with those offerings, in term
94
+ * order and then no term. An area plan's bonus counts only that area's products, and warns about any other.
73
95
  */
74
- function baseOf(ctx, owner) {
96
+ function basesOf(ctx, owner, terms) {
75
97
  const plan = ctx.planOfVersion.get(owner.version_id);
76
98
  const products = new Set();
77
99
  for (const row of ctx.data.plan_bonus_owner_products) {
@@ -84,21 +106,37 @@ function baseOf(ctx, owner) {
84
106
  }
85
107
  products.add(row.product_id);
86
108
  }
87
- const offeringKeys = new Set(ctx.data.plan_offerings.filter((o) => products.has(o.product_id)).map((o) => o.key));
109
+ const bases = new Map();
110
+ const baseFor = (term) => {
111
+ const base = bases.get(term?.key ?? null) ?? { term, net: 0, offeringKeys: [] };
112
+ bases.set(term?.key ?? null, base);
113
+ return base;
114
+ };
115
+ const owned = new Set();
116
+ for (const o of ctx.data.plan_offerings) {
117
+ if (!products.has(o.product_id))
118
+ continue;
119
+ owned.add(o.key);
120
+ baseFor(terms.get(o.key) ?? null).offeringKeys.push(o.key);
121
+ }
88
122
  const afterPools = (owner.basis ?? "after_shared_costs") === "after_shared_costs";
89
- let net = 0;
90
123
  for (const e of ctx.entries) {
91
124
  if (e.stage === "bonus" || (!afterPools && e.stage === "pool") || e.cost_center_key == null)
92
125
  continue;
93
- const owned = (e.cost_center_type === "offering" && offeringKeys.has(e.cost_center_key)) ||
94
- (e.cost_center_type === "product" && products.has(e.cost_center_key));
95
- if (!owned)
126
+ let term;
127
+ if (e.cost_center_type === "offering" && owned.has(e.cost_center_key)) {
128
+ term = terms.get(e.cost_center_key) ?? null;
129
+ }
130
+ else if (e.cost_center_type === "product" && products.has(e.cost_center_key)) {
131
+ term = null;
132
+ }
133
+ else
96
134
  continue;
97
135
  const type = ctx.accountType(e.account_id);
98
136
  if (type === "revenue")
99
- net += e.amount;
137
+ baseFor(term).net += e.amount;
100
138
  else if (type === "expense")
101
- net -= e.amount;
139
+ baseFor(term).net -= e.amount;
102
140
  }
103
- return { net, offeringKeys: [...offeringKeys] };
141
+ return [...bases.values()].sort((a, b) => compareTerms(a.term, b.term));
104
142
  }
@@ -18,7 +18,7 @@ export interface Entry {
18
18
  line_name: string;
19
19
  explanation: string;
20
20
  stage: Stage;
21
- /** How much of the amount MTDC counts: 0 when excluded, a fraction for a mix of rented and owned RIBs. */
21
+ /** How much of the amount MTDC counts: 1, or 0 when it leaves MTDC, such as a boat charter. */
22
22
  mtdc_share: number;
23
23
  /** Whether it's wages, which payroll tax is charged on. */
24
24
  wages: boolean;
@@ -1,7 +1,6 @@
1
1
  /** The warnings about what the blocks are given, or when they run. The rest are about the data. */
2
2
  const ALLOCATION_WARNINGS = new Set([
3
3
  "over_inventory_boats",
4
- "over_inventory_resource",
5
4
  "over_capacity",
6
5
  "unallocated_activity",
7
6
  "closed_date",
@@ -12,9 +11,9 @@ const COST_TYPES = {
12
11
  coach_wages: "coach wages",
13
12
  salaries: "salaries",
14
13
  payroll_tax: "payroll tax",
15
- ribs: "RIBs",
14
+ ribs: "coach boats",
16
15
  boat_maintenance: "boat maintenance",
17
- resources: "resources",
16
+ resources: "boat charters",
18
17
  travel: "travel",
19
18
  cogs: "cost of goods sold",
20
19
  };
@@ -8,6 +8,7 @@ import { bookPayrollTax } from "./payroll.js";
8
8
  import { allocatePools } from "./pools.js";
9
9
  import { bookRevenue, countDays } from "./revenue.js";
10
10
  import { bookSalaries } from "./salaried.js";
11
+ import { compareTerms, offeringTerms } from "./terms.js";
11
12
  import { enrollmentOf, round2, Staffing } from "./staffing.js";
12
13
  /**
13
14
  * Calculates the plan year's P&L from a plan's rows. The steps run in the order
@@ -18,6 +19,7 @@ export function computePlan(data) {
18
19
  for (const o of data.plan_offerings)
19
20
  ctx.enrollment.set(o.id, enrollmentOf(o));
20
21
  checkAreas(ctx);
22
+ const terms = offeringTerms(ctx);
21
23
  const { activities, coaching, doubleBooked } = new Staffing(ctx).run(scheduleActivities(ctx));
22
24
  countDays(ctx, activities);
23
25
  bookRevenue(ctx);
@@ -30,15 +32,22 @@ export function computePlan(data) {
30
32
  bookPayrollTax(ctx);
31
33
  const mtdcByOffering = new Map(ctx.data.plan_offerings.map((o) => [o.key, mtdc(ctx, o.key)]));
32
34
  allocatePools(ctx);
33
- const bonuses = bookBonuses(ctx);
35
+ const bonuses = bookBonuses(ctx, terms);
36
+ const bonusByPosition = new Map();
37
+ for (const b of bonuses) {
38
+ if (b.position_key == null)
39
+ continue;
40
+ bonusByPosition.set(b.position_key, (bonusByPosition.get(b.position_key) ?? 0) + b.bonus_cents);
41
+ }
34
42
  const lines = sumLines(ctx);
35
43
  const organization = organizationTotals(ctx, lines);
36
44
  return {
37
45
  lines,
38
- pnl: summarize(ctx, lines, mtdcByOffering),
46
+ pnl: summarize(ctx, lines, mtdcByOffering, terms),
39
47
  organization,
40
48
  activities,
41
- positions: summarizePositions(ctx, coaching, charged, bonuses, doubleBooked),
49
+ positions: summarizePositions(ctx, coaching, charged, bonusByPosition, doubleBooked),
50
+ bonuses,
42
51
  capital: rankCapital(ctx, lines, organization),
43
52
  warnings: ctx.warnings,
44
53
  };
@@ -173,8 +182,8 @@ function sumLines(ctx) {
173
182
  })
174
183
  .filter((l) => l.amount_cents !== 0);
175
184
  }
176
- /** Each plan-year offering's P&L, then each product's, then each program area's. */
177
- function summarize(ctx, lines, mtdcByOffering) {
185
+ /** 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) {
178
187
  const pnl = (level, key, own, counts) => {
179
188
  const byAccount = new Map();
180
189
  let revenue = 0;
@@ -231,24 +240,38 @@ function summarize(ctx, lines, mtdcByOffering) {
231
240
  for (const l of lines)
232
241
  if (l.cost_center_type === "program_area" && l.cost_center_key !== null)
233
242
  areaIds.add(l.cost_center_key);
234
- const areaPnl = [...areaIds].sort().map((id) => {
243
+ const areaPnl = [];
244
+ const termPnl = [];
245
+ for (const id of [...areaIds].sort()) {
235
246
  const own = offeringPnl.filter((s) => {
236
247
  const product = ctx.offeringsByKey.get(s.key)?.product_id;
237
248
  return product !== undefined && ctx.products.get(product)?.program_area_id === id;
238
249
  });
239
- const keys = new Set(own.map((s) => s.key));
240
250
  const products = new Set(own.map((s) => ctx.offeringsByKey.get(s.key)?.product_id));
241
- const add = (field) => own.reduce((t, s) => t + s[field], 0);
242
- return pnl("program_area", id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
243
- (l.cost_center_type === "product" && products.has(l.cost_center_key ?? undefined)) ||
244
- (l.cost_center_type === "program_area" && l.cost_center_key === id)), {
245
- mtdc_cents: add("mtdc_cents"),
246
- enrollment: add("enrollment"),
247
- days: add("days"),
248
- sailor_days: add("sailor_days"),
249
- });
250
- });
251
- return [...offeringPnl, ...productPnl, ...areaPnl];
251
+ // Costs left on the area or a product, because it had no offerings to divide them among, are in no term.
252
+ const rollup = (level, offerings, leftovers) => {
253
+ const keys = new Set(offerings.map((s) => s.key));
254
+ const add = (field) => offerings.reduce((t, s) => t + s[field], 0);
255
+ return pnl(level, id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
256
+ (leftovers && l.cost_center_type === "product" && products.has(l.cost_center_key ?? undefined)) ||
257
+ (leftovers && l.cost_center_type === "program_area" && l.cost_center_key === id)), {
258
+ mtdc_cents: add("mtdc_cents"),
259
+ enrollment: add("enrollment"),
260
+ days: add("days"),
261
+ sailor_days: add("sailor_days"),
262
+ });
263
+ };
264
+ areaPnl.push(rollup("program_area", own, true));
265
+ const termOf = (s) => terms.get(s.key) ?? null;
266
+ const areaTerms = [...new Map(own.map((s) => [termOf(s)?.key, termOf(s)])).values()].filter((t) => t !== null);
267
+ for (const term of [...areaTerms.sort(compareTerms), null]) {
268
+ const inTerm = own.filter((s) => termOf(s)?.key === term?.key);
269
+ const row = { ...rollup("program_area_term", inTerm, term === null), term_key: term?.key ?? null };
270
+ if (inTerm.length > 0 || row.by_account.length > 0)
271
+ termPnl.push(row);
272
+ }
273
+ }
274
+ return [...offeringPnl, ...productPnl, ...areaPnl, ...termPnl];
252
275
  }
253
276
  function divide(totals, by) {
254
277
  if (by <= 0)
@@ -43,9 +43,17 @@ export declare class Staffing {
43
43
  * Boats count the ones sailors bring.
44
44
  */
45
45
  private checkCapacity;
46
+ /** Books a cost on a block, split among its activities by their offerings' sailors. */
47
+ private book;
46
48
  /**
47
- * Warns when the organization's boats of one class, or a resource such as RIBs, are given to more
48
- * blocks at once on a date than it has. Boats sailors bring don't count.
49
+ * Charges each boat class's charter rate once a day, for the most of its boats chartered at once
50
+ * that day, shared among the blocks that charter them by how many each does. A charter is a rental,
51
+ * so it's left out of MTDC (2 CFR 200.1).
52
+ */
53
+ private bookCharters;
54
+ /**
55
+ * Warns when more of the organization's boats of one class are given to blocks at once on a date
56
+ * than it has that day on the asset list. Boats it charters, or sailors bring, don't count.
49
57
  */
50
58
  private checkInventory;
51
59
  /**
@@ -53,7 +61,7 @@ export declare class Staffing {
53
61
  * both. Returns each position's double-booked hours and dates.
54
62
  */
55
63
  private checkDoubleBooked;
56
- /** Costs every block's staff, RIBs, boats and resources, and checks them against the limits. */
64
+ /** Costs every block's staff and boats, and checks them against the limits. */
57
65
  run(scheduled: ScheduledActivity[]): {
58
66
  activities: ComputedActivity[];
59
67
  coaching: SalariedCoaching[];
@@ -61,7 +61,6 @@ export class Staffing {
61
61
  blocks(scheduled) {
62
62
  const { data } = this.ctx;
63
63
  const staff = groupBy(data.plan_block_staff, (r) => r.block_id);
64
- const resources = groupBy(data.plan_block_resources, (r) => r.block_id);
65
64
  const boats = groupBy(data.plan_block_boats, (r) => r.block_id);
66
65
  const rows = new Map(data.plan_activity_blocks.map((b) => [b.id, b]));
67
66
  const members = new Map();
@@ -103,7 +102,6 @@ export class Staffing {
103
102
  Math.max(0, ...list.map((a) => a.stored.putaway_minutes ?? 0)),
104
103
  on_water_minutes: set.length > 0 ? Math.max(...set) : sailingMinutes,
105
104
  staff: staff.get(id) ?? [],
106
- resources: resources.get(id) ?? [],
107
105
  boats: boats.get(id) ?? [],
108
106
  shares: new Map(list.map((a) => [a, share(a)])),
109
107
  });
@@ -171,12 +169,13 @@ export class Staffing {
171
169
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
172
170
  let seats = 0;
173
171
  for (const b of block.boats) {
174
- const perBoat = classes.get(b.boat_class_id)?.sailors_per_boat;
175
- if (perBoat == null || perBoat <= 0) {
176
- this.warnOnce("no_sailors_per_boat", `${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no sailors per boat. Add it to check that the boats hold every sailor`, b.boat_class_id, scope);
172
+ // A coach boat's class takes 0, without a warning.
173
+ const perBoat = classes.get(b.boat_class_id)?.max_capacity;
174
+ if (perBoat == null) {
175
+ 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);
177
176
  continue;
178
177
  }
179
- seats += (b.count + (b.sailor_owned ?? 0)) * perBoat;
178
+ seats += (b.count + (b.chartered ?? 0) + (b.sailor_owned ?? 0)) * perBoat;
180
179
  }
181
180
  if (sailors > seats) {
182
181
  ctx.warn("over_capacity", seats === 0
@@ -184,28 +183,79 @@ export class Staffing {
184
183
  : `${has} ${countOf(sailors, "sailor")}, but the boats hold only ${seats}`, block.row.key, scope);
185
184
  }
186
185
  }
186
+ /** Books a cost on a block, split among its activities by their offerings' sailors. */
187
+ book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
188
+ if (amount === 0 || account === undefined)
189
+ return;
190
+ for (const [a, share] of block.shares) {
191
+ this.ctx.book({
192
+ cost_center_type: "offering",
193
+ cost_center_key: a.offering.key,
194
+ account_id: account,
195
+ function: this.ctx.functionFor(account, null, "offering"),
196
+ amount: amount * share,
197
+ line_key: `${line}:${a.offering.key}`,
198
+ line_name: name,
199
+ explanation,
200
+ mtdc_share: mtdcShare,
201
+ wages,
202
+ });
203
+ }
204
+ }
205
+ /**
206
+ * Charges each boat class's charter rate once a day, for the most of its boats chartered at once
207
+ * that day, shared among the blocks that charter them by how many each does. A charter is a rental,
208
+ * so it's left out of MTDC (2 CFR 200.1).
209
+ */
210
+ bookCharters(blocks) {
211
+ const { ctx } = this;
212
+ const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
213
+ const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
214
+ for (const list of groupBy(blocks, (b) => b.row.date).values()) {
215
+ const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
216
+ for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
217
+ const name = classes.get(id)?.name ?? id;
218
+ const rate = rates.get(id)?.charter_per_day_cents;
219
+ if (rate == null) {
220
+ for (const { block } of uses) {
221
+ 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, { version_id: block.row.version_id, date: block.row.date });
222
+ }
223
+ continue;
224
+ }
225
+ const day = mostAtOnce(uses).total * rate;
226
+ const total = sum(uses.map((u) => u.n));
227
+ for (const { block, n } of uses) {
228
+ this.book(block, ctx.costAccount("resources"), (day * n) / total, `charters:${id}`, `Boat charters: ${name}`, "The most boats of the class chartered at once that day × the class's charter rate", 0);
229
+ }
230
+ }
231
+ }
232
+ }
187
233
  /**
188
- * Warns when the organization's boats of one class, or a resource such as RIBs, are given to more
189
- * blocks at once on a date than it has. Boats sailors bring don't count.
234
+ * Warns when more of the organization's boats of one class are given to blocks at once on a date
235
+ * than it has that day on the asset list. Boats it charters, or sailors bring, don't count.
190
236
  */
191
237
  checkInventory(blocks) {
192
238
  const { ctx } = this;
193
239
  const dates = groupBy(blocks, (b) => b.row.date);
194
240
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
195
- const fleet = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r.quantity]));
196
- const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
241
+ const assets = new Map(ctx.data.assets.map((a) => [a.id, a]));
242
+ const boats = ctx.data.boats.flatMap((b) => {
243
+ const asset = assets.get(b.asset_id);
244
+ return asset === undefined ? [] : [{ asset, boat_class_id: b.boat_class_id }];
245
+ });
197
246
  const organization = ctx.data.organization.name;
198
247
  for (const [date, list] of dates) {
248
+ const has = (a) => (a.acquired_on == null || a.acquired_on <= date) && (a.disposed_on == null || date < a.disposed_on);
199
249
  const check = (code, what, have, used) => {
200
- if (have == null)
201
- return;
202
250
  const out = list.flatMap((b) => (used(b) > 0 ? [{ block: b, n: used(b) }] : []));
203
251
  const peak = mostAtOnce(out);
204
252
  if (peak.total <= have)
205
253
  return;
254
+ const only = have === 0 ? "none" : `only ${have}`;
255
+ const count = countOf(peak.total, what);
206
256
  const message = peak.at.length === 1
207
- ? `${countOf(peak.total, what)} out at once for ${offeringsOf(peak.at[0].block)}, but ${organization} has only ${have}`
208
- : `${countOf(peak.total, what)} out at once, but ${organization} has only ${have}: ${listOf(peak.at.map(({ block, n }) => `${n} for ${offeringsOf(block)}`))}`;
257
+ ? `${count} out at once for ${offeringsOf(peak.at[0].block)}, but ${organization} has ${only}`
258
+ : `${count} out at once, but ${organization} has ${only}: ${listOf(peak.at.map(({ block, n }) => `${n} for ${offeringsOf(block)}`))}`;
209
259
  for (const version of new Set(peak.at.map(({ block }) => block.row.version_id))) {
210
260
  ctx.warn(code, message, what, {
211
261
  version_id: version,
@@ -215,17 +265,8 @@ export class Staffing {
215
265
  };
216
266
  const classIds = new Set(list.flatMap((b) => b.boats.filter((x) => x.count > 0).map((x) => x.boat_class_id)));
217
267
  for (const id of classIds) {
218
- if (fleet.get(id) == null) {
219
- for (const b of list.filter((x) => x.boats.some((y) => y.boat_class_id === id && y.count > 0))) {
220
- this.warnOnce("no_fleet_quantity", `The Operations Plan doesn't say how many ${pluralOf(classes.get(id)?.name ?? id)} ${organization} has. Add it to check that there are enough`, id, { version_id: b.row.version_id, date });
221
- }
222
- }
223
- check("over_inventory_boats", classes.get(id)?.name ?? id, fleet.get(id), (b) => sum(b.boats.filter((x) => x.boat_class_id === id).map((x) => x.count)));
224
- }
225
- const resourceKeys = new Set(list.flatMap((b) => b.resources.map((x) => x.resource_key)));
226
- for (const key of resourceKeys) {
227
- const resource = resources.get(key);
228
- check("over_inventory_resource", resource?.name ?? key, resource?.quantity, (b) => sum(b.resources.filter((x) => x.resource_key === key).map((x) => x.quantity)));
268
+ const have = boats.filter((b) => b.boat_class_id === id && has(b.asset)).length;
269
+ check("over_inventory_boats", classes.get(id)?.name ?? id, have, (b) => sum(b.boats.filter((x) => x.boat_class_id === id).map((x) => x.count)));
229
270
  }
230
271
  }
231
272
  }
@@ -260,7 +301,7 @@ export class Staffing {
260
301
  }
261
302
  return result;
262
303
  }
263
- /** Costs every block's staff, RIBs, boats and resources, and checks them against the limits. */
304
+ /** Costs every block's staff and boats, and checks them against the limits. */
264
305
  run(scheduled) {
265
306
  const { ctx } = this;
266
307
  const blocks = this.blocks(scheduled);
@@ -274,37 +315,14 @@ export class Staffing {
274
315
  const coaching = [];
275
316
  const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
276
317
  const positions = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
277
- const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
278
318
  const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
279
319
  const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
280
- const ribKind = ctx.data.rib_resource_kind_id;
281
320
  const given = new Map();
282
321
  for (const block of byDate(blocks)) {
283
322
  const scope = { version_id: block.row.version_id, date: block.row.date };
284
323
  const paidHours = block.paid_minutes / 60;
285
324
  const waterHours = block.on_water_minutes / 60;
286
- // Each cost is split among the block's activities by their offerings' sailors.
287
- const book = (type, amount, line, name, explanation, mtdcShare = 1) => {
288
- if (amount === 0)
289
- return;
290
- const account = ctx.costAccount(type);
291
- if (account === undefined)
292
- return;
293
- for (const [a, share] of block.shares) {
294
- ctx.book({
295
- cost_center_type: "offering",
296
- cost_center_key: a.offering.key,
297
- account_id: account,
298
- function: ctx.functionFor(account, null, "offering"),
299
- amount: amount * share,
300
- line_key: `${line}:${a.offering.key}`,
301
- line_name: name,
302
- explanation,
303
- mtdc_share: mtdcShare,
304
- wages: type === "coach_wages",
305
- });
306
- }
307
- };
325
+ const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
308
326
  let wages = 0;
309
327
  for (const s of block.staff) {
310
328
  if (s.count <= 0)
@@ -350,32 +368,20 @@ export class Staffing {
350
368
  wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
351
369
  }
352
370
  book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
353
- let ribs = 0;
354
- for (const use of block.resources) {
355
- const resource = resources.get(use.resource_key);
356
- if (resource === undefined) {
357
- this.warnOnce("unknown_resource", `${block.row.name} uses resource ${use.resource_key}, which isn't in the plan`, use.resource_key, scope);
358
- continue;
359
- }
360
- const rib = ribKind != null && resource.resource_kind_id === ribKind;
361
- if (rib)
362
- ribs += use.quantity;
363
- const hours = use.hours ?? (rib ? waterHours : paidHours);
364
- const cost = use.quantity * (hours * hourlyCost(resource) + (resource.cost_per_day_cents ?? 0));
365
- book(rib ? "ribs" : "resources", cost, rib ? "ribs" : `resource:${resource.key}`, rib ? "RIBs" : resource.name, `${resource.name}: quantity × hours × hourly cost, plus a day rate`, resource.excluded_from_mtdc ? 0 : 1);
366
- }
367
371
  for (const b of block.boats) {
368
372
  const rate = boatRates.get(b.boat_class_id);
369
373
  if (rate === undefined || b.count <= 0 || waterHours <= 0)
370
374
  continue;
371
- book("boat_maintenance", b.count * waterHours * rate.maintenance_per_boat_hour_cents, `boats:${b.boat_class_id}`, `Boat maintenance: ${boatClasses.get(b.boat_class_id)?.name ?? "boats"}`, "The organization's boats × on-water hours × the class's maintenance rate");
375
+ const boatClass = boatClasses.get(b.boat_class_id);
376
+ 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");
372
377
  }
373
378
  const coaches = sum(block.staff.map((s) => s.count));
374
- const boats = sum(block.boats.map((b) => b.count));
379
+ const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
375
380
  for (const [a, share] of block.shares) {
376
- given.set(a, { coaches: coaches * share, ribs: ribs * share, boats: boats * share });
381
+ given.set(a, { coaches: coaches * share, boats: boats * share });
377
382
  }
378
383
  }
384
+ this.bookCharters(byDate(blocks));
379
385
  this.bookTrips(blocks, scheduled);
380
386
  return {
381
387
  coaching,
@@ -391,7 +397,6 @@ export class Staffing {
391
397
  on_water_minutes: a.on_water_minutes,
392
398
  uses_boats: a.uses_boats,
393
399
  coaches: given.get(a)?.coaches ?? 0,
394
- ribs: given.get(a)?.ribs ?? 0,
395
400
  boats: given.get(a)?.boats ?? 0,
396
401
  block_id: blocks.has(a.stored.block_id ?? "") ? a.stored.block_id : null,
397
402
  })),
@@ -508,9 +513,6 @@ function extraCoachMinutes(list) {
508
513
  }
509
514
  return minutes;
510
515
  }
511
- function hourlyCost(r) {
512
- return (r.cost_per_hour_cents ?? 0) + (r.fuel_per_hour_cents ?? 0) + (r.maintenance_per_hour_cents ?? 0);
513
- }
514
516
  export function round2(n) {
515
517
  return Math.round(n * 100) / 100;
516
518
  }
@@ -0,0 +1,11 @@
1
+ import type { Context } from "./context.ts";
2
+ import type { Id, PlanTerm } from "./types.ts";
3
+ /** Each offering's term, by offering key, in no particular order. Null for an offering in none. */
4
+ export type OfferingTerms = Map<Id, PlanTerm | null>;
5
+ /**
6
+ * Finds each offering's term in the plan's terms. An offering whose term isn't there, such as one deleted
7
+ * from the Operations Plan, is in none, with a warning.
8
+ */
9
+ export declare function offeringTerms(ctx: Context): OfferingTerms;
10
+ /** Orders terms by their first days, then names, with no term (null) last. */
11
+ export declare function compareTerms(a: PlanTerm | null, b: PlanTerm | null): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Finds each offering's term in the plan's terms. An offering whose term isn't there, such as one deleted
3
+ * from the Operations Plan, is in none, with a warning.
4
+ */
5
+ export function offeringTerms(ctx) {
6
+ const terms = new Map((ctx.data.plan_terms ?? []).map((t) => [t.key, t]));
7
+ const byOffering = new Map();
8
+ for (const o of ctx.data.plan_offerings) {
9
+ const term = o.term_key == null ? null : (terms.get(o.term_key) ?? null);
10
+ if (o.term_key != null && term === null) {
11
+ ctx.warn("unknown_term", `${o.name} is in a term the Operations Plan doesn't have, so it counts as in no term`, o.key);
12
+ }
13
+ byOffering.set(o.key, term);
14
+ }
15
+ return byOffering;
16
+ }
17
+ /** Orders terms by their first days, then names, with no term (null) last. */
18
+ export function compareTerms(a, b) {
19
+ if (a === null || b === null)
20
+ return a === b ? 0 : a === null ? 1 : -1;
21
+ return a.starts_on.localeCompare(b.starts_on) || a.name.localeCompare(b.name) || a.key.localeCompare(b.key);
22
+ }
@@ -52,12 +52,27 @@ export interface QualificationCover {
52
52
  export interface BoatClass {
53
53
  id: Id;
54
54
  name: string;
55
- sailors_per_boat: number | null;
55
+ /** The most sailors one boat can safely take. */
56
+ max_capacity: number | null;
56
57
  footprint_sq_ft: number | null;
58
+ /** Where its own boats' fuel and upkeep are booked; empty for the boat maintenance cost type's. */
59
+ account_id?: Id | null;
57
60
  }
58
- export interface ResourceKind {
61
+ /** Something the organization owns: a sailboat, a powerboat, a motor, or other equipment. */
62
+ export interface Asset {
59
63
  id: Id;
60
- name: string;
64
+ kind: "sailboat" | "powerboat" | "motor" | "other";
65
+ name: string | null;
66
+ /** The day the organization got it, or expects to; empty counts it from the start. */
67
+ acquired_on: DateString | null;
68
+ /** The day it stopped being the organization's; empty while it still is. */
69
+ disposed_on: DateString | null;
70
+ }
71
+ /** A sailboat or powerboat asset's class. */
72
+ export interface Boat {
73
+ id: Id;
74
+ asset_id: Id;
75
+ boat_class_id: Id;
61
76
  }
62
77
  export interface Organization {
63
78
  id: Id;
@@ -134,7 +149,7 @@ export interface PlanActivity extends Versioned {
134
149
  /** The block it's staffed in, if any. */
135
150
  block_id: Id | null;
136
151
  }
137
- /** Activities on one date that share their staff, RIBs and boats. */
152
+ /** Activities on one date that share their staff and boats. */
138
153
  export interface PlanActivityBlock extends Versioned {
139
154
  date: DateString;
140
155
  name: string;
@@ -150,21 +165,13 @@ export interface PlanBlockStaff extends Versioned {
150
165
  /** How a salaried position is paid for the block: out of its salary (the default), or by the hour at its own rate. */
151
166
  pay: "salary" | "hourly" | null;
152
167
  }
153
- /** A resource on a block, such as RIBs. */
154
- export interface PlanBlockResource extends Versioned {
155
- block_id: Id;
156
- /** A `plan_resources` key. */
157
- resource_key: Id;
158
- quantity: number;
159
- /** Hours each is used; empty for the block's time on the water (RIBs) or its paid time (the rest). */
160
- hours: number | null;
161
- }
162
- /** Boats of one class on a block: the organization's, and the ones sailors bring. */
168
+ /** Boats of one class on a block: the organization's, the ones it charters, and the ones sailors bring. */
163
169
  export interface PlanBlockBoats extends Versioned {
164
170
  block_id: Id;
165
171
  boat_class_id: Id;
166
172
  count: number;
167
173
  sailor_owned: number | null;
174
+ chartered?: number | null;
168
175
  }
169
176
  export interface PlanLaborLine extends Versioned {
170
177
  name: string;
@@ -231,22 +238,15 @@ export interface PlanSalariedBooking extends Versioned {
231
238
  kind: "management";
232
239
  hours: number;
233
240
  }
234
- export interface PlanResource extends Versioned {
235
- resource_kind_id: Id;
236
- name: string;
237
- quantity: number | null;
238
- cost_per_hour_cents: number | null;
239
- cost_per_day_cents: number | null;
240
- fuel_per_hour_cents: number | null;
241
- maintenance_per_hour_cents: number | null;
242
- /** Set for a rental, whose cost leaves MTDC. */
243
- excluded_from_mtdc: boolean;
244
- }
241
+ /** A boat class's rates for the year. */
245
242
  export interface PlanBoatRate extends Versioned {
246
243
  boat_class_id: Id;
244
+ /** Upkeep for each hour one of the organization's boats of the class is on the water. */
247
245
  maintenance_per_boat_hour_cents: number;
248
- /** How many boats of the class the organization has. */
249
- quantity: number | null;
246
+ /** Fuel for each hour one is on the water. */
247
+ fuel_per_hour_cents?: number | null;
248
+ /** What one costs to charter for a day. */
249
+ charter_per_day_cents?: number | null;
250
250
  }
251
251
  /** A group of shared costs, such as G&A, divided among all of the plan's offerings by MTDC. */
252
252
  export interface PlanPool extends Versioned {
@@ -294,15 +294,15 @@ export interface PlanData {
294
294
  fiscal_year: number;
295
295
  organization: Organization;
296
296
  organization_cost_accounts: OrganizationCostAccount[];
297
- /** The resource kind whose `plan_resources` price RIB hours. */
298
- rib_resource_kind_id: Id | null;
299
297
  products: Product[];
300
298
  program_areas: ProgramArea[];
301
299
  accounts: Account[];
302
300
  qualifications: Qualification[];
303
301
  qualification_covers: QualificationCover[];
304
302
  boat_classes: BoatClass[];
305
- resource_kinds: ResourceKind[];
303
+ /** The asset list, with its boats, which the engine counts by class on each day. */
304
+ assets: Asset[];
305
+ boats: Boat[];
306
306
  /** The plans and versions being calculated, so the engine can tell which plan each offering is in. */
307
307
  plans: Plan[];
308
308
  plan_versions: PlanVersion[];
@@ -312,7 +312,6 @@ export interface PlanData {
312
312
  plan_activities: PlanActivity[];
313
313
  plan_activity_blocks: PlanActivityBlock[];
314
314
  plan_block_staff: PlanBlockStaff[];
315
- plan_block_resources: PlanBlockResource[];
316
315
  plan_block_boats: PlanBlockBoats[];
317
316
  plan_labor_lines: PlanLaborLine[];
318
317
  plan_budget_lines: PlanBudgetLine[];
@@ -321,7 +320,6 @@ export interface PlanData {
321
320
  plan_staff_levels: PlanStaffLevel[];
322
321
  plan_salaried_positions: PlanSalariedPosition[];
323
322
  plan_salaried_bookings: PlanSalariedBooking[];
324
- plan_resources: PlanResource[];
325
323
  plan_boat_rates: PlanBoatRate[];
326
324
  plan_pools: PlanPool[];
327
325
  plan_bonus_owners: PlanBonusOwner[];
@@ -351,9 +349,12 @@ export interface Totals {
351
349
  }
352
350
  /** One offering's P&L for the plan year, or a product's or program area's, which adds up its offerings. */
353
351
  export interface Pnl extends Totals {
354
- level: "offering" | "product" | "program_area";
352
+ /** `program_area_term` is a program area's offerings in one term, or in none. */
353
+ level: "offering" | "product" | "program_area" | "program_area_term";
355
354
  /** An offering key, a product id, or a program area id. */
356
355
  key: Id;
356
+ /** A `program_area_term`'s `plan_terms` key, null for its offerings in no term. Only that level has one. */
357
+ term_key?: Id | null;
357
358
  /** Each account's total, revenue and expense alike. */
358
359
  by_account: {
359
360
  account_id: Id;
@@ -378,9 +379,8 @@ export interface ComputedActivity {
378
379
  paid_minutes: number;
379
380
  on_water_minutes: number;
380
381
  uses_boats: boolean;
381
- /** Its share of its block's staff, RIBs and boats, by its sailors. */
382
+ /** Its share of its block's staff and boats, the organization's and chartered, by its sailors. */
382
383
  coaches: number;
383
- ribs: number;
384
384
  boats: number;
385
385
  block_id: Id | null;
386
386
  }
@@ -450,10 +450,26 @@ export interface PositionSummary {
450
450
  /** Dates it's on two or more groups at once. */
451
451
  double_booked_dates: number;
452
452
  }
453
+ /** A bonus owner's bonus for one term, or on its offerings in no term for the year. */
454
+ export interface BonusSummary {
455
+ bonus_owner_key: Id;
456
+ role_name: string;
457
+ position_key: Id | null;
458
+ /** The program area of all the owner's products, or null when they're in more than one. */
459
+ program_area_id: Id | null;
460
+ /** A `plan_terms` key, or null for its offerings in no term. */
461
+ term_key: Id | null;
462
+ bonus_rate: number;
463
+ /** The net the bonus is figured on, which can be below zero. */
464
+ net_cents: number;
465
+ bonus_cents: number;
466
+ }
453
467
  export interface EngineOutput {
454
468
  lines: CostLine[];
455
- /** Each offering's P&L, then each product's, then each program area's. */
469
+ /** Each offering's P&L, then each product's, then each program area's, then each area's terms. */
456
470
  pnl: Pnl[];
471
+ /** Each bonus owner's bonus in each term it has offerings in, then in no term. */
472
+ bonuses: BonusSummary[];
457
473
  organization: OrganizationTotals;
458
474
  activities: ComputedActivity[];
459
475
  /** Each salaried position's pay for the year. */
@@ -1,6 +1,8 @@
1
1
  /** Every collection, for `createDirectus<Schema>()`. */
2
2
  export interface Schema {
3
3
  accounts: Accounts[];
4
+ announcements: Announcements[];
5
+ assets: Assets[];
4
6
  audit_findings: AuditFindings[];
5
7
  boat_classes: BoatClasses[];
6
8
  boats: Boats[];
@@ -25,6 +27,7 @@ export interface Schema {
25
27
  group_policies: GroupPolicies[];
26
28
  groups: Groups[];
27
29
  households: Households[];
30
+ links: Links[];
28
31
  medical_profiles: MedicalProfiles[];
29
32
  offerings: Offerings[];
30
33
  organization_cost_accounts: OrganizationCostAccounts[];
@@ -33,7 +36,6 @@ export interface Schema {
33
36
  plan_activities: PlanActivities[];
34
37
  plan_activity_blocks: PlanActivityBlocks[];
35
38
  plan_block_boats: PlanBlockBoats[];
36
- plan_block_resources: PlanBlockResources[];
37
39
  plan_block_staff: PlanBlockStaff[];
38
40
  plan_boat_rates: PlanBoatRates[];
39
41
  plan_bonus_owner_products: PlanBonusOwnerProducts[];
@@ -46,7 +48,7 @@ export interface Schema {
46
48
  plan_pools: PlanPools[];
47
49
  plan_programs: PlanPrograms[];
48
50
  plan_rates: PlanRates[];
49
- plan_resources: PlanResources[];
51
+ plan_result_bonuses: PlanResultBonuses[];
50
52
  plan_result_capital: PlanResultCapital[];
51
53
  plan_result_lines: PlanResultLines[];
52
54
  plan_result_pnl: PlanResultPnl[];
@@ -70,7 +72,6 @@ export interface Schema {
70
72
  qualification_covers: QualificationCovers[];
71
73
  qualifications: Qualifications[];
72
74
  registrations: Registrations[];
73
- resource_kinds: ResourceKinds[];
74
75
  schools: Schools[];
75
76
  sync_leases: SyncLeases[];
76
77
  sync_runs: SyncRuns[];
@@ -115,6 +116,39 @@ export interface Accounts {
115
116
  /** Kept for history, and closed to new lines. */
116
117
  retired: boolean;
117
118
  }
119
+ /** What Sail Team's home page shows, newest first. */
120
+ export interface Announcements {
121
+ id: string;
122
+ title: string;
123
+ body: string;
124
+ /** The date the home page shows. Newest comes first. */
125
+ published_on: string;
126
+ }
127
+ /** Each boat, motor and piece of equipment CSC owns: sailboats, powerboats such as the coach boats, motors, and others such as trailers. Planning counts the boats of each class CSC has on each day. */
128
+ export interface Assets {
129
+ id: string;
130
+ /** A sailboat or powerboat also needs a row in Boats. Directus can't check that, so keep them matching. */
131
+ kind: "sailboat" | "powerboat" | "motor" | "other";
132
+ /** Its name, if it has one, such as Scooter or the van. */
133
+ name: string | null;
134
+ /** This asset's boat details, if it is a sailboat or powerboat. */
135
+ boats: (string | Boats)[];
136
+ /** Who made it, such as Zodiac or Yamaha. */
137
+ make: string | null;
138
+ /** Its model. A boat's model is its boat class, in Boats. */
139
+ model: string | null;
140
+ /** The number that identifies it: a boat's hull number (HIN), a vehicle's VIN, or a serial number. */
141
+ identifier: string | null;
142
+ /** The state registration number, such as WN 1234 AB, if it has one. */
143
+ registration_number: string | null;
144
+ /** Its book value, as in QuickBooks account 1500. In cents: 12500 is $125.00. */
145
+ value_cents: number | null;
146
+ /** The day CSC got it, or expects to. Planning counts it from this day; empty counts it from the start. */
147
+ acquired_on: string | null;
148
+ /** The day CSC sold, scrapped, or gave it away. Planning stops counting it on this day. Empty while CSC still has it. */
149
+ disposed_on: string | null;
150
+ note: string | null;
151
+ }
118
152
  /** Something a sync noticed but didn't fix. A dismissed finding stays dismissed on later runs. */
119
153
  export interface AuditFindings {
120
154
  id: string;
@@ -133,28 +167,22 @@ export interface AuditFindings {
133
167
  export interface BoatClasses {
134
168
  id: string;
135
169
  name: string;
136
- sailors_per_boat: number;
170
+ /** The most sailors one boat can safely take, such as 4 in an FJ. 0 for a coach boat. */
171
+ max_capacity: number;
137
172
  /** Dry-storage area one boat takes. */
138
173
  footprint_sq_ft: string | null;
174
+ /** Where the fuel and upkeep of CSC's own boats of the class are booked, such as Coach boats for a RIB. Empty books them to boat maintenance. */
175
+ account_id: string | Accounts | null;
139
176
  }
140
- /** Each boat CSC owns, for planning the year. Sailors' own boats aren't listed here. */
177
+ /** A boat's own details, sail or power: its class and sail number. Sailors' own boats aren't listed here. */
141
178
  export interface Boats {
142
179
  id: string;
180
+ /** The asset this boat is. Its kind must be Sailboat or Powerboat. */
181
+ asset_id: string | Assets;
182
+ /** Such as Opti, 420, or RIB. */
143
183
  boat_class_id: string | BoatClasses;
144
- /** The boat's name, if it has one. */
145
- name: string | null;
184
+ /** A sailboat's, if it has one. */
146
185
  sail_number: string | null;
147
- /** The hull identification number on the transom, if it's known. */
148
- hull_number: string | null;
149
- /** What the boat is worth. In cents: 12500 is $125.00. */
150
- value_cents: number | null;
151
- /** The day CSC got the boat. */
152
- acquired_on: string | null;
153
- /** The day the boat first sailed in a program. */
154
- in_service_on: string | null;
155
- /** The day CSC stopped using the boat. Empty while it's still in use. */
156
- retired_on: string | null;
157
- note: string | null;
158
186
  }
159
187
  /** An offering of a program that plans and Clubspot both point to, such as "J-Pod · fall practices" or "Summer camp · week 3". Each Financial Plan sets its own price, schedule, and size for it, and Clubspot's offerings are linked to it. */
160
188
  export interface CatalogOfferings {
@@ -428,6 +456,24 @@ export interface Households {
428
456
  share_contact: boolean | null;
429
457
  date_created: string | null;
430
458
  }
459
+ /** The Links page in Sail Team. Drag the rows to change their order. */
460
+ export interface Links {
461
+ id: string;
462
+ title: string;
463
+ /** The page's address, starting with https://. */
464
+ url: string;
465
+ /** A line under the title, if it needs one. */
466
+ note: string | null;
467
+ sort: number | null;
468
+ /** The page's own title, read from the page when the link is saved. */
469
+ preview_title: string | null;
470
+ /** The page's own summary, read from the page when the link is saved. */
471
+ preview_description: string | null;
472
+ /** The address of the page's own picture, read from the page when the link is saved. */
473
+ preview_image: string | null;
474
+ /** When the page was last read. Empty if it couldn't be read: save the link again to retry. */
475
+ previewed_at: string | null;
476
+ }
431
477
  /** A person's medical information, kept apart from People so only some staff see it. */
432
478
  export interface MedicalProfiles {
433
479
  id: string;
@@ -558,20 +604,8 @@ export interface PlanBlockBoats {
558
604
  count: number;
559
605
  /** How many boats sailors bring. They cost nothing and don't count against the organization's. */
560
606
  sailor_owned: number | null;
561
- }
562
- /** RIBs and other equipment a block uses. */
563
- export interface PlanBlockResources {
564
- id: string;
565
- /** The version this row belongs to. */
566
- version_id: string | PlanVersions;
567
- /** Stays the same across versions. Copy it where another row asks for this one's Key. */
568
- key: string;
569
- block_id: string | PlanActivityBlocks;
570
- /** The resource it uses: copy the Key from a resource in the Operations Plan. */
571
- resource_key: string;
572
- quantity: string;
573
- /** Hours each is used. Leave it empty for the block's time on the water (RIBs) or its paid time (other equipment). */
574
- hours: string | null;
607
+ /** How many boats of the class the block charters. They cost the class's charter rate for each day, and don't count against the organization's. */
608
+ chartered: number | null;
575
609
  }
576
610
  /** Who coaches a block: a number of staff at one staff level, or a salaried position. */
577
611
  export interface PlanBlockStaff {
@@ -598,10 +632,12 @@ export interface PlanBoatRates {
598
632
  /** Stays the same across versions. Copy it where another row asks for this one's Key. */
599
633
  key: string;
600
634
  boat_class_id: string | BoatClasses;
601
- /** In cents: 12500 is $125.00. */
635
+ /** Upkeep for each hour one of CSC's boats of the class is on the water. In cents: 12500 is $125.00. */
602
636
  maintenance_per_boat_hour_cents: number;
603
- /** How many boats of the class the organization has. */
604
- quantity: number | null;
637
+ /** Fuel for each hour one of CSC's boats of the class is on the water. In cents: 12500 is $125.00. */
638
+ fuel_per_hour_cents: number | null;
639
+ /** What one boat of the class costs to charter for a day. In cents: 12500 is $125.00. */
640
+ charter_per_day_cents: number | null;
605
641
  }
606
642
  /** The products a bonus owner owns. */
607
643
  export interface PlanBonusOwnerProducts {
@@ -760,29 +796,29 @@ export interface PlanRates {
760
796
  lodging_per_night_cents: number | null;
761
797
  /** In cents: 12500 is $125.00. */
762
798
  mileage_rate_cents: number | null;
763
- /** The kind of resource whose cost prices coach boat (RIB) hours. */
764
- rib_resource_kind_id: string | ResourceKinds | null;
765
799
  }
766
- /** Equipment programs share, such as coach boats, and what an hour or a day of it costs. */
767
- export interface PlanResources {
800
+ /** Each bonus in a Financial Plan, for each term, and for the offerings in no term on the year. 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. */
801
+ export interface PlanResultBonuses {
768
802
  id: string;
769
- /** The version this row belongs to. */
770
- version_id: string | PlanVersions;
771
- /** Stays the same across versions. Copy it where another row asks for this one's Key. */
772
- key: string;
773
- resource_kind_id: string | ResourceKinds;
803
+ /** The bonus's role, such as Director. */
774
804
  name: string;
775
- quantity: number | null;
776
- /** In cents: 12500 is $125.00. */
777
- cost_per_hour_cents: number | null;
778
- /** In cents: 12500 is $125.00. */
779
- cost_per_day_cents: number | null;
780
- /** In cents: 12500 is $125.00. */
781
- fuel_per_hour_cents: number | null;
782
- /** In cents: 12500 is $125.00. */
783
- maintenance_per_hour_cents: number | null;
784
- /** Set for a rental, whose cost is left out of modified total direct costs (MTDC). */
785
- excluded_from_mtdc: boolean;
805
+ term_key: string | null;
806
+ /** The term it's paid on. Empty for the offerings in no term, such as Adult Learn to Sail, whose bonus is worked out on the year. */
807
+ term_name: string | null;
808
+ /** The term's first day, which puts the terms in order. */
809
+ term_starts_on: string | null;
810
+ /** A share of the net. 0.05 means 5%. */
811
+ bonus_rate: string;
812
+ /** The net of its products in the term, which the bonus is figured on. In cents: 12500 is $125.00. */
813
+ net_cents: number;
814
+ /** Its rate × the net, or nothing when the net is a loss. In cents: 12500 is $125.00. */
815
+ bonus_cents: number;
816
+ bonus_owner_key: string;
817
+ program_area_key: string | null;
818
+ /** The Financial Plan these results are for. */
819
+ version_set_id: string | VersionSets;
820
+ /** The plan in the Financial Plan that pays the bonus. */
821
+ version_set_member_id: string | VersionSetMembers | null;
786
822
  }
787
823
  /** The capital list in rank order, in a Financial Plan, with what the year's money covers. 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. */
788
824
  export interface PlanResultCapital {
@@ -823,7 +859,7 @@ export interface PlanResultLines {
823
859
  /** A Financial Plan's P&L for each offering, product, and program area. 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. */
824
860
  export interface PlanResultPnl {
825
861
  id: string;
826
- level: "offering" | "product" | "program_area";
862
+ level: "offering" | "product" | "program_area" | "program_area_term";
827
863
  subject_key: string;
828
864
  name: string;
829
865
  /** In cents: 12500 is $125.00. */
@@ -841,6 +877,11 @@ export interface PlanResultPnl {
841
877
  version_set_id: string | VersionSets;
842
878
  /** The plan in the Financial Plan that holds what it's charged to. */
843
879
  version_set_member_id: string | VersionSetMembers | null;
880
+ term_key: string | null;
881
+ /** For a program area by term, the term. Empty for its offerings in no term, such as Adult Learn to Sail. */
882
+ term_name: string | null;
883
+ /** The term's first day, which puts the terms in order. */
884
+ term_starts_on: string | null;
844
885
  }
845
886
  /** 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. */
846
887
  export interface PlanResultPositions {
@@ -1121,11 +1162,6 @@ export interface Registrations {
1121
1162
  status: "confirmed" | "waitlist" | "cancelled";
1122
1163
  clubspot_entry_id: string | ClubspotRegistrationEntries;
1123
1164
  }
1124
- /** A kind of shared resource, such as RIB or trailer. */
1125
- export interface ResourceKinds {
1126
- id: string;
1127
- name: string;
1128
- }
1129
1165
  /** The schools a sailor can sail for, which families pick from. A school's other names match what families typed on their Clubspot forms. */
1130
1166
  export interface Schools {
1131
1167
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.49.0",
3
+ "version": "1.51.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",