@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,18 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
import type { ActivityKind, PlanActivity, PlanOffering, PlanTrip } from "./types.ts";
|
|
3
|
+
/** A stored activity, before staffing, with its paid time and time on the water. */
|
|
4
|
+
export interface ScheduledActivity {
|
|
5
|
+
offering: PlanOffering;
|
|
6
|
+
trip: PlanTrip | null;
|
|
7
|
+
stored: PlanActivity;
|
|
8
|
+
date: string;
|
|
9
|
+
kind: ActivityKind;
|
|
10
|
+
paid_minutes: number;
|
|
11
|
+
on_water_minutes: number;
|
|
12
|
+
uses_boats: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The plan year's activities. Paid time is start to end plus setup and put-away; on-water time is
|
|
16
|
+
* `on_water_minutes` if set, otherwise the whole span, and zero when the activity uses no boats.
|
|
17
|
+
*/
|
|
18
|
+
export declare function scheduleActivities(ctx: Context): ScheduledActivity[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { minutesOf } from "./dates.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plan year's activities. Paid time is start to end plus setup and put-away; on-water time is
|
|
4
|
+
* `on_water_minutes` if set, otherwise the whole span, and zero when the activity uses no boats.
|
|
5
|
+
*/
|
|
6
|
+
export function scheduleActivities(ctx) {
|
|
7
|
+
const trips = new Map(ctx.data.plan_trips.map((t) => [t.id, t]));
|
|
8
|
+
const result = [];
|
|
9
|
+
for (const activity of ctx.data.plan_activities) {
|
|
10
|
+
const offering = ctx.offeringsById.get(activity.offering_id);
|
|
11
|
+
if (offering === undefined) {
|
|
12
|
+
ctx.warn("unknown_offering", `Activity ${activity.key} names a missing offering`, activity.key);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (!ctx.inYear(offering))
|
|
16
|
+
continue;
|
|
17
|
+
const span = activity.start_time != null && activity.end_time != null
|
|
18
|
+
? minutesOf(activity.end_time) - minutesOf(activity.start_time)
|
|
19
|
+
: 0;
|
|
20
|
+
const usesBoats = activity.kind !== "travel" && activity.uses_boats;
|
|
21
|
+
result.push({
|
|
22
|
+
offering,
|
|
23
|
+
trip: activity.trip_id == null ? null : (trips.get(activity.trip_id) ?? null),
|
|
24
|
+
stored: activity,
|
|
25
|
+
date: activity.date,
|
|
26
|
+
kind: activity.kind,
|
|
27
|
+
paid_minutes: span + (activity.setup_minutes ?? 0) + (activity.putaway_minutes ?? 0),
|
|
28
|
+
on_water_minutes: usesBoats ? (activity.on_water_minutes ?? span) : 0,
|
|
29
|
+
uses_boats: usesBoats,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return result.sort((a, b) => a.date.localeCompare(b.date) || a.offering.key.localeCompare(b.offering.key));
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Records each bonus owner's `bonus_rate × max(0, combined net)` for the plan year, after every pool.
|
|
4
|
+
* It's divided among the owned products' offerings by revenue, and is never counted in MTDC.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bookBonuses(ctx: Context): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { revenue } from "./measures.js";
|
|
2
|
+
/**
|
|
3
|
+
* Records each bonus owner's `bonus_rate × max(0, combined net)` for the plan year, after every pool.
|
|
4
|
+
* It's divided among the owned products' offerings by revenue, and is never counted in MTDC.
|
|
5
|
+
*/
|
|
6
|
+
export function bookBonuses(ctx) {
|
|
7
|
+
for (const owner of ctx.data.plan_bonus_owners) {
|
|
8
|
+
const products = new Set(ctx.data.plan_bonus_owner_products.filter((p) => p.bonus_owner_id === owner.id).map((p) => p.product_id));
|
|
9
|
+
const offeringKeys = new Set(ctx
|
|
10
|
+
.fyOfferings()
|
|
11
|
+
.filter((o) => products.has(o.product_id))
|
|
12
|
+
.map((o) => o.key));
|
|
13
|
+
let net = 0;
|
|
14
|
+
for (const e of ctx.entries) {
|
|
15
|
+
const owned = (e.cost_center_type === "offering" && e.cost_center_key != null && offeringKeys.has(e.cost_center_key)) ||
|
|
16
|
+
(e.cost_center_type === "product" && e.cost_center_key != null && products.has(e.cost_center_key));
|
|
17
|
+
if (!owned)
|
|
18
|
+
continue;
|
|
19
|
+
const type = ctx.accountType(e.account_id);
|
|
20
|
+
if (type === "revenue")
|
|
21
|
+
net += e.amount;
|
|
22
|
+
else if (type === "expense")
|
|
23
|
+
net -= e.amount;
|
|
24
|
+
}
|
|
25
|
+
const bonus = owner.bonus_rate * Math.max(0, net);
|
|
26
|
+
if (bonus === 0)
|
|
27
|
+
continue;
|
|
28
|
+
const weights = new Map();
|
|
29
|
+
for (const key of offeringKeys) {
|
|
30
|
+
const r = revenue(ctx, key);
|
|
31
|
+
if (r > 0)
|
|
32
|
+
weights.set(key, r);
|
|
33
|
+
}
|
|
34
|
+
const total = [...weights.values()].reduce((t, w) => t + w, 0);
|
|
35
|
+
for (const [key, w] of weights) {
|
|
36
|
+
ctx.book({
|
|
37
|
+
cost_center_type: "offering",
|
|
38
|
+
cost_center_key: key,
|
|
39
|
+
account_id: owner.account_id,
|
|
40
|
+
function: ctx.functionFor(owner.account_id, null, "offering"),
|
|
41
|
+
amount: (bonus * w) / total,
|
|
42
|
+
line_key: `bonus:${owner.key}`,
|
|
43
|
+
explanation: `${owner.role_name}: ${owner.bonus_rate} × combined net of its products`,
|
|
44
|
+
stage: "bonus",
|
|
45
|
+
mtdc_share: 0,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Account, AccountFunction, CostCenterType, CostType, DateString, EngineWarning, Id, PlanData, PlanOffering, PlanRates, Product } from "./types.ts";
|
|
2
|
+
/** The step that recorded an entry. Only `direct` entries count toward MTDC. */
|
|
3
|
+
export type Stage = "direct" | "pool" | "bonus";
|
|
4
|
+
/** One amount, in fractional cents, before lines are rounded and summed. */
|
|
5
|
+
export interface Entry {
|
|
6
|
+
cost_center_type: CostCenterType;
|
|
7
|
+
cost_center_key: Id | null;
|
|
8
|
+
account_id: Id;
|
|
9
|
+
function: AccountFunction | null;
|
|
10
|
+
amount: number;
|
|
11
|
+
line_key: string;
|
|
12
|
+
explanation: string;
|
|
13
|
+
stage: Stage;
|
|
14
|
+
/** How much of the amount MTDC counts: 0 when excluded, a fraction for a mix of rented and owned RIBs. */
|
|
15
|
+
mtdc_share: number;
|
|
16
|
+
/** Whether it's wages, which payroll tax is charged on. */
|
|
17
|
+
wages: boolean;
|
|
18
|
+
}
|
|
19
|
+
export type EntryInput = Omit<Entry, "stage" | "mtdc_share" | "wages"> & Partial<Pick<Entry, "stage" | "mtdc_share" | "wages">>;
|
|
20
|
+
/** Lookups over the input, and the entries recorded so far, shared by every step. */
|
|
21
|
+
export declare class Context {
|
|
22
|
+
/** The plan year's first and last days. */
|
|
23
|
+
readonly fy: {
|
|
24
|
+
from: DateString;
|
|
25
|
+
to: DateString;
|
|
26
|
+
};
|
|
27
|
+
readonly entries: Entry[];
|
|
28
|
+
readonly warnings: EngineWarning[];
|
|
29
|
+
readonly accounts: Map<Id, Account>;
|
|
30
|
+
readonly products: Map<Id, Product>;
|
|
31
|
+
readonly offeringsById: Map<Id, PlanOffering>;
|
|
32
|
+
readonly offeringsByKey: Map<Id, PlanOffering>;
|
|
33
|
+
/** Enrollment per offering id. */
|
|
34
|
+
readonly enrollment: Map<string, number>;
|
|
35
|
+
/** Days per offering key: the dates it has an activity other than travel. */
|
|
36
|
+
readonly days: Map<string, number>;
|
|
37
|
+
readonly rates: PlanRates | undefined;
|
|
38
|
+
private readonly costAccounts;
|
|
39
|
+
readonly data: PlanData;
|
|
40
|
+
constructor(data: PlanData);
|
|
41
|
+
/** The account the organization records a calculated cost type in. */
|
|
42
|
+
costAccount(type: CostType): Id | undefined;
|
|
43
|
+
warn(code: string, message: string, key?: string): void;
|
|
44
|
+
book(entry: EntryInput): void;
|
|
45
|
+
/** An entry's function: the line's if set, otherwise its account's default, otherwise by cost center. */
|
|
46
|
+
functionFor(accountId: Id, explicit: AccountFunction | null | undefined, costCenter: CostCenterType): AccountFunction | null;
|
|
47
|
+
accountType(accountId: Id): Account["type"] | undefined;
|
|
48
|
+
/** Whether `accountId` is `ancestorId` or under it. */
|
|
49
|
+
accountUnder(accountId: Id, ancestorId: Id): boolean;
|
|
50
|
+
/** Whether the offering counts in the plan year: it does, whole, in the year it starts. */
|
|
51
|
+
inYear(offering: PlanOffering): boolean;
|
|
52
|
+
/** The plan year's offerings. */
|
|
53
|
+
fyOfferings(): PlanOffering[];
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fiscalYearDates, within } from "./dates.js";
|
|
2
|
+
/** Lookups over the input, and the entries recorded so far, shared by every step. */
|
|
3
|
+
export class Context {
|
|
4
|
+
/** The plan year's first and last days. */
|
|
5
|
+
fy;
|
|
6
|
+
entries = [];
|
|
7
|
+
warnings = [];
|
|
8
|
+
accounts;
|
|
9
|
+
products;
|
|
10
|
+
offeringsById;
|
|
11
|
+
offeringsByKey;
|
|
12
|
+
/** Enrollment per offering id. */
|
|
13
|
+
enrollment = new Map();
|
|
14
|
+
/** Days per offering key: the dates it has an activity other than travel. */
|
|
15
|
+
days = new Map();
|
|
16
|
+
rates;
|
|
17
|
+
costAccounts;
|
|
18
|
+
data;
|
|
19
|
+
constructor(data) {
|
|
20
|
+
this.data = data;
|
|
21
|
+
this.fy = fiscalYearDates(data.fiscal_year, data.organization.fiscal_year_start_month);
|
|
22
|
+
this.accounts = new Map(data.accounts.map((a) => [a.id, a]));
|
|
23
|
+
this.products = new Map(data.products.map((p) => [p.id, p]));
|
|
24
|
+
this.offeringsById = new Map(data.plan_offerings.map((o) => [o.id, o]));
|
|
25
|
+
this.offeringsByKey = new Map(data.plan_offerings.map((o) => [o.key, o]));
|
|
26
|
+
this.rates = data.plan_rates[0];
|
|
27
|
+
if (data.plan_rates.length > 1)
|
|
28
|
+
this.warn("many_rates", "More than one plan_rates row, so the first is used");
|
|
29
|
+
this.costAccounts = new Map(data.organization_cost_accounts
|
|
30
|
+
.filter((c) => c.organization_id === data.organization.id)
|
|
31
|
+
.map((c) => [c.cost_type, c.account_id]));
|
|
32
|
+
}
|
|
33
|
+
/** The account the organization records a calculated cost type in. */
|
|
34
|
+
costAccount(type) {
|
|
35
|
+
const account = this.costAccounts.get(type);
|
|
36
|
+
if (account === undefined) {
|
|
37
|
+
this.warn("no_cost_account", `The organization has no account set for ${type}, so that cost is left out`, type);
|
|
38
|
+
}
|
|
39
|
+
return account;
|
|
40
|
+
}
|
|
41
|
+
warn(code, message, key) {
|
|
42
|
+
if (this.warnings.some((w) => w.code === code && w.message === message))
|
|
43
|
+
return;
|
|
44
|
+
this.warnings.push(key === undefined ? { code, message } : { code, message, key });
|
|
45
|
+
}
|
|
46
|
+
book(entry) {
|
|
47
|
+
if (entry.amount === 0 || !Number.isFinite(entry.amount))
|
|
48
|
+
return;
|
|
49
|
+
this.entries.push({ stage: "direct", mtdc_share: 1, wages: false, ...entry });
|
|
50
|
+
}
|
|
51
|
+
/** An entry's function: the line's if set, otherwise its account's default, otherwise by cost center. */
|
|
52
|
+
functionFor(accountId, explicit, costCenter) {
|
|
53
|
+
if (explicit != null)
|
|
54
|
+
return explicit;
|
|
55
|
+
const account = this.accounts.get(accountId);
|
|
56
|
+
if (account?.default_function != null)
|
|
57
|
+
return account.default_function;
|
|
58
|
+
if (account?.type === "revenue")
|
|
59
|
+
return null;
|
|
60
|
+
return costCenter === "offering" || costCenter === "product" ? "program" : "management";
|
|
61
|
+
}
|
|
62
|
+
accountType(accountId) {
|
|
63
|
+
return this.accounts.get(accountId)?.type;
|
|
64
|
+
}
|
|
65
|
+
/** Whether `accountId` is `ancestorId` or under it. */
|
|
66
|
+
accountUnder(accountId, ancestorId) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (let a = this.accounts.get(accountId); a !== undefined && !seen.has(a.id);) {
|
|
69
|
+
if (a.id === ancestorId)
|
|
70
|
+
return true;
|
|
71
|
+
seen.add(a.id);
|
|
72
|
+
a = a.parent_id == null ? undefined : this.accounts.get(a.parent_id);
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** Whether the offering counts in the plan year: it does, whole, in the year it starts. */
|
|
77
|
+
inYear(offering) {
|
|
78
|
+
return within(offering.starts_on, this.fy.from, this.fy.to);
|
|
79
|
+
}
|
|
80
|
+
/** The plan year's offerings. */
|
|
81
|
+
fyOfferings() {
|
|
82
|
+
return this.data.plan_offerings.filter((o) => this.inYear(o));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DateString, TimeString } from "./types.ts";
|
|
2
|
+
export declare function addDays(date: DateString, days: number): DateString;
|
|
3
|
+
/** ISO weekday, 1 = Monday. */
|
|
4
|
+
export declare function isoWeekday(date: DateString): number;
|
|
5
|
+
/** Fiscal year N ends in calendar year N, so a July start makes FY2027 2026-07-01 to 2027-06-30. */
|
|
6
|
+
export declare function fiscalYearDates(fiscalYear: number, startMonth: number): {
|
|
7
|
+
from: DateString;
|
|
8
|
+
to: DateString;
|
|
9
|
+
};
|
|
10
|
+
export declare function minutesOf(time: TimeString): number;
|
|
11
|
+
/** Whether `date` falls in a window whose open ends are unbounded. */
|
|
12
|
+
export declare function within(date: DateString, from: DateString | null | undefined, to: DateString | null | undefined): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function toUtc(date) {
|
|
2
|
+
return new Date(`${date}T00:00:00Z`);
|
|
3
|
+
}
|
|
4
|
+
function fromUtc(d) {
|
|
5
|
+
return d.toISOString().slice(0, 10);
|
|
6
|
+
}
|
|
7
|
+
export function addDays(date, days) {
|
|
8
|
+
const d = toUtc(date);
|
|
9
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
10
|
+
return fromUtc(d);
|
|
11
|
+
}
|
|
12
|
+
/** ISO weekday, 1 = Monday. */
|
|
13
|
+
export function isoWeekday(date) {
|
|
14
|
+
const day = toUtc(date).getUTCDay();
|
|
15
|
+
return day === 0 ? 7 : day;
|
|
16
|
+
}
|
|
17
|
+
/** Fiscal year N ends in calendar year N, so a July start makes FY2027 2026-07-01 to 2027-06-30. */
|
|
18
|
+
export function fiscalYearDates(fiscalYear, startMonth) {
|
|
19
|
+
const startYear = startMonth === 1 ? fiscalYear : fiscalYear - 1;
|
|
20
|
+
const from = `${startYear}-${String(startMonth).padStart(2, "0")}-01`;
|
|
21
|
+
const nextYear = `${startYear + 1}-${String(startMonth).padStart(2, "0")}-01`;
|
|
22
|
+
return { from, to: addDays(nextYear, -1) };
|
|
23
|
+
}
|
|
24
|
+
export function minutesOf(time) {
|
|
25
|
+
const [h, m] = time.split(":").map(Number);
|
|
26
|
+
return h * 60 + m;
|
|
27
|
+
}
|
|
28
|
+
/** Whether `date` falls in a window whose open ends are unbounded. */
|
|
29
|
+
export function within(date, from, to) {
|
|
30
|
+
return (from == null || date >= from) && (to == null || date <= to);
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EngineOutput, Pnl, PlanData, Totals } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the plan year's P&L from a plan's rows. The steps run in the order
|
|
4
|
+
* docs/planning-engine.md gives, each reading only what earlier steps recorded.
|
|
5
|
+
*/
|
|
6
|
+
export declare function computePlan(data: PlanData): EngineOutput;
|
|
7
|
+
/** An offering's or product's P&L for one day it runs. Null when it has no days. */
|
|
8
|
+
export declare function perDay(pnl: Pnl): Totals | null;
|
|
9
|
+
/** An offering's or product's P&L for one sailor on one day. Null when it has no sailor-days. */
|
|
10
|
+
export declare function perSailorDay(pnl: Pnl): Totals | null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { scheduleActivities } from "./activities.js";
|
|
2
|
+
import { bookBonuses } from "./bonus.js";
|
|
3
|
+
import { Context } from "./context.js";
|
|
4
|
+
import { bookBudgetLines, bookLaborLines, CostCenterBooker } from "./lines.js";
|
|
5
|
+
import { mtdc } from "./measures.js";
|
|
6
|
+
import { bookPayrollTax } from "./payroll.js";
|
|
7
|
+
import { allocatePools } from "./pools.js";
|
|
8
|
+
import { bookRevenue, countDays } from "./revenue.js";
|
|
9
|
+
import { bookSalaries } from "./salaried.js";
|
|
10
|
+
import { enrollmentOf, Staffing } from "./staffing.js";
|
|
11
|
+
/**
|
|
12
|
+
* Calculates the plan year's P&L from a plan's rows. The steps run in the order
|
|
13
|
+
* docs/planning-engine.md gives, each reading only what earlier steps recorded.
|
|
14
|
+
*/
|
|
15
|
+
export function computePlan(data) {
|
|
16
|
+
const ctx = new Context(data);
|
|
17
|
+
for (const o of data.plan_offerings)
|
|
18
|
+
ctx.enrollment.set(o.id, enrollmentOf(o));
|
|
19
|
+
const { activities, coaching } = new Staffing(ctx).run(scheduleActivities(ctx));
|
|
20
|
+
countDays(ctx, activities);
|
|
21
|
+
bookRevenue(ctx);
|
|
22
|
+
const booker = new CostCenterBooker(ctx);
|
|
23
|
+
bookBudgetLines(ctx, booker);
|
|
24
|
+
bookLaborLines(ctx, booker);
|
|
25
|
+
bookSalaries(ctx, coaching, booker);
|
|
26
|
+
booker.flush();
|
|
27
|
+
bookPayrollTax(ctx);
|
|
28
|
+
const mtdcByOffering = new Map(ctx.fyOfferings().map((o) => [o.key, mtdc(ctx, o.key)]));
|
|
29
|
+
allocatePools(ctx);
|
|
30
|
+
bookBonuses(ctx);
|
|
31
|
+
const lines = sumLines(ctx);
|
|
32
|
+
return { lines, pnl: summarize(ctx, lines, mtdcByOffering), activities, warnings: ctx.warnings };
|
|
33
|
+
}
|
|
34
|
+
/** Sums entries into lines by cost center, account, function and line key, in whole cents. */
|
|
35
|
+
function sumLines(ctx) {
|
|
36
|
+
const lines = new Map();
|
|
37
|
+
for (const e of ctx.entries) {
|
|
38
|
+
const id = [e.cost_center_type, e.cost_center_key, e.account_id, e.function, e.line_key].join("|");
|
|
39
|
+
const line = lines.get(id) ?? {
|
|
40
|
+
cost_center_type: e.cost_center_type,
|
|
41
|
+
cost_center_key: e.cost_center_key,
|
|
42
|
+
account_id: e.account_id,
|
|
43
|
+
function: e.function,
|
|
44
|
+
amount_cents: 0,
|
|
45
|
+
line_key: e.line_key,
|
|
46
|
+
explanation: e.explanation,
|
|
47
|
+
exact: 0,
|
|
48
|
+
};
|
|
49
|
+
line.exact += e.amount;
|
|
50
|
+
lines.set(id, line);
|
|
51
|
+
}
|
|
52
|
+
// Rounding each line key's running total across cost centers keeps its total to the cent.
|
|
53
|
+
const carried = new Map();
|
|
54
|
+
return [...lines.values()]
|
|
55
|
+
.sort((a, b) => (a.cost_center_key ?? "").localeCompare(b.cost_center_key ?? "") ||
|
|
56
|
+
a.line_key.localeCompare(b.line_key) ||
|
|
57
|
+
a.account_id.localeCompare(b.account_id) ||
|
|
58
|
+
a.cost_center_type.localeCompare(b.cost_center_type))
|
|
59
|
+
.map(({ exact, ...line }) => {
|
|
60
|
+
const id = [line.account_id, line.function, line.line_key].join("|");
|
|
61
|
+
const before = carried.get(id) ?? 0;
|
|
62
|
+
carried.set(id, before + exact);
|
|
63
|
+
return { ...line, amount_cents: Math.round(before + exact) - Math.round(before) };
|
|
64
|
+
})
|
|
65
|
+
.filter((l) => l.amount_cents !== 0);
|
|
66
|
+
}
|
|
67
|
+
/** Each plan-year offering's P&L, then each product's. */
|
|
68
|
+
function summarize(ctx, lines, mtdcByOffering) {
|
|
69
|
+
const pnl = (level, key, own, counts) => {
|
|
70
|
+
const byAccount = new Map();
|
|
71
|
+
let revenue = 0;
|
|
72
|
+
let expense = 0;
|
|
73
|
+
for (const l of own) {
|
|
74
|
+
byAccount.set(l.account_id, (byAccount.get(l.account_id) ?? 0) + l.amount_cents);
|
|
75
|
+
const type = ctx.accountType(l.account_id);
|
|
76
|
+
if (type === "revenue")
|
|
77
|
+
revenue += l.amount_cents;
|
|
78
|
+
else if (type === "expense")
|
|
79
|
+
expense += l.amount_cents;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
level,
|
|
83
|
+
key,
|
|
84
|
+
revenue_cents: revenue,
|
|
85
|
+
expense_cents: expense,
|
|
86
|
+
net_cents: revenue - expense,
|
|
87
|
+
by_account: [...byAccount]
|
|
88
|
+
.filter(([, amount]) => amount !== 0)
|
|
89
|
+
.map(([account_id, amount_cents]) => ({ account_id, amount_cents }))
|
|
90
|
+
.sort((a, b) => a.account_id.localeCompare(b.account_id)),
|
|
91
|
+
...counts,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const offerings = ctx.fyOfferings().sort((a, b) => a.key.localeCompare(b.key));
|
|
95
|
+
const offeringPnl = offerings.map((o) => {
|
|
96
|
+
const enrollment = ctx.enrollment.get(o.id) ?? 0;
|
|
97
|
+
const days = ctx.days.get(o.key) ?? 0;
|
|
98
|
+
return pnl("offering", o.key, lines.filter((l) => l.cost_center_type === "offering" && l.cost_center_key === o.key), { mtdc_cents: Math.round(mtdcByOffering.get(o.key) ?? 0), enrollment, days, sailor_days: enrollment * days });
|
|
99
|
+
});
|
|
100
|
+
const productIds = new Set(offerings.map((o) => o.product_id));
|
|
101
|
+
for (const l of lines)
|
|
102
|
+
if (l.cost_center_type === "product" && l.cost_center_key !== null)
|
|
103
|
+
productIds.add(l.cost_center_key);
|
|
104
|
+
const productPnl = [...productIds].sort().map((id) => {
|
|
105
|
+
const own = offeringPnl.filter((s) => ctx.offeringsByKey.get(s.key)?.product_id === id);
|
|
106
|
+
const keys = new Set(own.map((s) => s.key));
|
|
107
|
+
const add = (field) => own.reduce((t, s) => t + s[field], 0);
|
|
108
|
+
return pnl("product", id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
|
|
109
|
+
(l.cost_center_type === "product" && l.cost_center_key === id)), {
|
|
110
|
+
mtdc_cents: add("mtdc_cents"),
|
|
111
|
+
enrollment: add("enrollment"),
|
|
112
|
+
days: add("days"),
|
|
113
|
+
sailor_days: add("sailor_days"),
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return [...offeringPnl, ...productPnl];
|
|
117
|
+
}
|
|
118
|
+
function divide(totals, by) {
|
|
119
|
+
if (by <= 0)
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
revenue_cents: Math.round(totals.revenue_cents / by),
|
|
123
|
+
expense_cents: Math.round(totals.expense_cents / by),
|
|
124
|
+
net_cents: Math.round(totals.net_cents / by),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** An offering's or product's P&L for one day it runs. Null when it has no days. */
|
|
128
|
+
export function perDay(pnl) {
|
|
129
|
+
return divide(pnl, pnl.days);
|
|
130
|
+
}
|
|
131
|
+
/** An offering's or product's P&L for one sailor on one day. Null when it has no sailor-days. */
|
|
132
|
+
export function perSailorDay(pnl) {
|
|
133
|
+
return divide(pnl, pnl.sailor_days);
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { computePlan, perDay, perSailorDay } from "./engine.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Context, EntryInput } from "./context.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Records amounts on their cost centers. Amounts on an offering, a pool or the organization are
|
|
4
|
+
* recorded right away. Amounts on a product wait until `flush`, then are divided among its offerings
|
|
5
|
+
* by MTDC, so every offering's own costs are in first, whatever order the lines come in.
|
|
6
|
+
*/
|
|
7
|
+
export declare class CostCenterBooker {
|
|
8
|
+
private readonly ctx;
|
|
9
|
+
private readonly weights;
|
|
10
|
+
private readonly pending;
|
|
11
|
+
constructor(ctx: Context);
|
|
12
|
+
private weightsFor;
|
|
13
|
+
add(entry: EntryInput): void;
|
|
14
|
+
flush(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Records the plan year's `plan_budget_lines`. */
|
|
17
|
+
export declare function bookBudgetLines(ctx: Context, booker: CostCenterBooker): void;
|
|
18
|
+
/** Records `plan_labor_lines`: hours × the staff level's rate, as wages. */
|
|
19
|
+
export declare function bookLaborLines(ctx: Context, booker: CostCenterBooker): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spreadWeights } from "./measures.js";
|
|
2
|
+
/**
|
|
3
|
+
* Records amounts on their cost centers. Amounts on an offering, a pool or the organization are
|
|
4
|
+
* recorded right away. Amounts on a product wait until `flush`, then are divided among its offerings
|
|
5
|
+
* by MTDC, so every offering's own costs are in first, whatever order the lines come in.
|
|
6
|
+
*/
|
|
7
|
+
export class CostCenterBooker {
|
|
8
|
+
ctx;
|
|
9
|
+
weights = new Map();
|
|
10
|
+
pending = [];
|
|
11
|
+
constructor(ctx) {
|
|
12
|
+
this.ctx = ctx;
|
|
13
|
+
}
|
|
14
|
+
weightsFor(productId) {
|
|
15
|
+
if (!this.weights.has(productId))
|
|
16
|
+
this.weights.set(productId, spreadWeights(this.ctx, productId));
|
|
17
|
+
return this.weights.get(productId) ?? null;
|
|
18
|
+
}
|
|
19
|
+
add(entry) {
|
|
20
|
+
if (entry.cost_center_type === "product" && entry.cost_center_key != null)
|
|
21
|
+
this.pending.push(entry);
|
|
22
|
+
else
|
|
23
|
+
this.ctx.book(entry);
|
|
24
|
+
}
|
|
25
|
+
flush() {
|
|
26
|
+
const pending = this.pending.splice(0);
|
|
27
|
+
const weights = pending.map((entry) => this.weightsFor(entry.cost_center_key));
|
|
28
|
+
pending.forEach((entry, i) => {
|
|
29
|
+
const w = weights[i] ?? null;
|
|
30
|
+
if (w === null) {
|
|
31
|
+
this.ctx.warn("product_without_offerings", `Product ${entry.cost_center_key} has no offering in the plan year, so ${entry.line_key} stays at the product`, entry.cost_center_key ?? undefined);
|
|
32
|
+
this.ctx.book(entry);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const [key, share] of w) {
|
|
36
|
+
this.ctx.book({ ...entry, cost_center_type: "offering", cost_center_key: key, amount: entry.amount * share });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function checkCostCenter(ctx, type, key, line) {
|
|
42
|
+
const known = type === "org" ||
|
|
43
|
+
(type === "offering" && key != null && ctx.offeringsByKey.has(key)) ||
|
|
44
|
+
(type === "product" && key != null && ctx.products.has(key)) ||
|
|
45
|
+
(type === "pool" && key != null && ctx.data.plan_pools.some((p) => p.key === key));
|
|
46
|
+
if (!known)
|
|
47
|
+
ctx.warn("unknown_cost_center", `Line ${line} is on a missing ${type} ${key ?? ""}`, line);
|
|
48
|
+
return known;
|
|
49
|
+
}
|
|
50
|
+
/** Records the plan year's `plan_budget_lines`. */
|
|
51
|
+
export function bookBudgetLines(ctx, booker) {
|
|
52
|
+
for (const line of ctx.data.plan_budget_lines) {
|
|
53
|
+
if (line.fiscal_year !== ctx.data.fiscal_year)
|
|
54
|
+
continue;
|
|
55
|
+
if (!checkCostCenter(ctx, line.cost_center_type, line.cost_center_key, line.key))
|
|
56
|
+
continue;
|
|
57
|
+
booker.add({
|
|
58
|
+
cost_center_type: line.cost_center_type,
|
|
59
|
+
cost_center_key: line.cost_center_type === "org" ? null : line.cost_center_key,
|
|
60
|
+
account_id: line.account_id,
|
|
61
|
+
function: ctx.functionFor(line.account_id, line.function, line.cost_center_type),
|
|
62
|
+
amount: line.amount_cents,
|
|
63
|
+
line_key: `budget:${line.key}`,
|
|
64
|
+
explanation: line.name,
|
|
65
|
+
mtdc_share: line.excluded_from_mtdc ? 0 : 1,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Records `plan_labor_lines`: hours × the staff level's rate, as wages. */
|
|
70
|
+
export function bookLaborLines(ctx, booker) {
|
|
71
|
+
const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
|
|
72
|
+
for (const line of ctx.data.plan_labor_lines) {
|
|
73
|
+
if (!checkCostCenter(ctx, line.cost_center_type, line.cost_center_key, line.key))
|
|
74
|
+
continue;
|
|
75
|
+
const level = levels.get(line.staff_level_key);
|
|
76
|
+
if (level === undefined) {
|
|
77
|
+
ctx.warn("unknown_staff_level", `Labor line ${line.key} names a missing staff level`, line.key);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
booker.add({
|
|
81
|
+
cost_center_type: line.cost_center_type,
|
|
82
|
+
cost_center_key: line.cost_center_type === "org" ? null : line.cost_center_key,
|
|
83
|
+
account_id: line.account_id,
|
|
84
|
+
function: ctx.functionFor(line.account_id, line.function, line.cost_center_type),
|
|
85
|
+
amount: level.pay_type === "volunteer" ? 0 : line.hours * (level.hourly_rate_cents ?? 0),
|
|
86
|
+
line_key: `labor:${line.key}`,
|
|
87
|
+
explanation: `${line.name}: hours × ${level.name}'s rate`,
|
|
88
|
+
wages: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
2
|
+
import type { Id } from "./types.ts";
|
|
3
|
+
/** Modified total direct costs: an offering's direct expenses, less those excluded from MTDC. */
|
|
4
|
+
export declare function mtdc(ctx: Context, offeringKey: Id): number;
|
|
5
|
+
export declare function revenue(ctx: Context, offeringKey: Id): number;
|
|
6
|
+
/** How a product-level amount divides among the product's plan-year offerings, by MTDC. Null when it has none. */
|
|
7
|
+
export declare function spreadWeights(ctx: Context, productId: Id): Map<Id, number> | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function counts(entry) {
|
|
2
|
+
return entry.stage === "direct";
|
|
3
|
+
}
|
|
4
|
+
/** Sums the entries an offering's base counts, weighting each by `weigh`. */
|
|
5
|
+
function sumEntries(ctx, offeringKey, weigh) {
|
|
6
|
+
let total = 0;
|
|
7
|
+
for (const e of ctx.entries) {
|
|
8
|
+
if (e.cost_center_type !== "offering" || e.cost_center_key !== offeringKey || !counts(e))
|
|
9
|
+
continue;
|
|
10
|
+
total += e.amount * weigh(e);
|
|
11
|
+
}
|
|
12
|
+
return total;
|
|
13
|
+
}
|
|
14
|
+
/** Modified total direct costs: an offering's direct expenses, less those excluded from MTDC. */
|
|
15
|
+
export function mtdc(ctx, offeringKey) {
|
|
16
|
+
return sumEntries(ctx, offeringKey, (e) => (ctx.accountType(e.account_id) === "expense" ? e.mtdc_share : 0));
|
|
17
|
+
}
|
|
18
|
+
export function revenue(ctx, offeringKey) {
|
|
19
|
+
return sumEntries(ctx, offeringKey, (e) => (ctx.accountType(e.account_id) === "revenue" ? 1 : 0));
|
|
20
|
+
}
|
|
21
|
+
/** How a product-level amount divides among the product's plan-year offerings, by MTDC. Null when it has none. */
|
|
22
|
+
export function spreadWeights(ctx, productId) {
|
|
23
|
+
const offerings = ctx.fyOfferings().filter((o) => o.product_id === productId);
|
|
24
|
+
if (offerings.length === 0)
|
|
25
|
+
return null;
|
|
26
|
+
const weights = new Map(offerings.map((o) => [o.key, Math.max(0, mtdc(ctx, o.key))]));
|
|
27
|
+
const total = [...weights.values()].reduce((t, w) => t + w, 0);
|
|
28
|
+
if (total <= 0)
|
|
29
|
+
return new Map(offerings.map((o) => [o.key, 1 / offerings.length]));
|
|
30
|
+
return new Map([...weights].map(([k, w]) => [k, w / total]));
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Records payroll tax at the year's rate on every wage so far, wherever the wage is. */
|
|
2
|
+
export function bookPayrollTax(ctx) {
|
|
3
|
+
const rate = ctx.rates?.payroll_tax_rate ?? 0;
|
|
4
|
+
if (rate <= 0)
|
|
5
|
+
return;
|
|
6
|
+
const wages = new Map();
|
|
7
|
+
for (const e of ctx.entries) {
|
|
8
|
+
if (!e.wages)
|
|
9
|
+
continue;
|
|
10
|
+
const id = [e.cost_center_type, e.cost_center_key, e.function].join("|");
|
|
11
|
+
const sum = wages.get(id) ?? { ...e, amount: 0 };
|
|
12
|
+
sum.amount += e.amount;
|
|
13
|
+
wages.set(id, sum);
|
|
14
|
+
}
|
|
15
|
+
if (wages.size === 0)
|
|
16
|
+
return;
|
|
17
|
+
const account = ctx.costAccount("payroll_tax");
|
|
18
|
+
if (account === undefined)
|
|
19
|
+
return;
|
|
20
|
+
for (const w of wages.values()) {
|
|
21
|
+
ctx.book({
|
|
22
|
+
cost_center_type: w.cost_center_type,
|
|
23
|
+
cost_center_key: w.cost_center_key,
|
|
24
|
+
account_id: account,
|
|
25
|
+
function: w.function,
|
|
26
|
+
amount: w.amount * rate,
|
|
27
|
+
line_key: "payroll_tax",
|
|
28
|
+
explanation: `${rate} × wages`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
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 declare function allocatePools(ctx: Context): void;
|