chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Chargeability calculator — pure functions for FTE-weighted chargeability reporting.
|
||||
*
|
||||
* Derives forecast chargeability from assignments + SAH.
|
||||
* No DB imports — all data passed in as arguments.
|
||||
*/
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResourceForecastInput {
|
||||
fte: number;
|
||||
targetPercentage: number; // from management level group (0-1)
|
||||
/** Assignments active in the period, with their utilization category code. */
|
||||
assignments: AssignmentSlice[];
|
||||
/** SAH for the period (net working hours). */
|
||||
sah: number;
|
||||
}
|
||||
|
||||
export interface AssignmentSlice {
|
||||
/** Hours per day for this assignment in the period. */
|
||||
hoursPerDay: number;
|
||||
/** Working days this assignment covers within the period. */
|
||||
workingDays: number;
|
||||
/** Utilization category code (e.g. "Chg", "BD", "MD&I", "M&O", "PD&R"). */
|
||||
categoryCode: string;
|
||||
}
|
||||
|
||||
export interface ResourceForecast {
|
||||
chg: number; // chargeability ratio (0-1)
|
||||
bd: number; // business development ratio
|
||||
mdi: number; // MD&I ratio
|
||||
mo: number; // M&O ratio
|
||||
pdr: number; // PD&R ratio
|
||||
absence: number; // absence ratio
|
||||
unassigned: number; // remaining ratio
|
||||
}
|
||||
|
||||
export interface GroupChargeabilityInput {
|
||||
fte: number;
|
||||
chargeability: number; // 0-1
|
||||
}
|
||||
|
||||
export interface GroupTargetInput {
|
||||
fte: number;
|
||||
targetPercentage: number; // 0-1
|
||||
}
|
||||
|
||||
// ─── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive forecast chargeability breakdown for a single resource in a period.
|
||||
* Returns ratios (0-1) for each utilization category.
|
||||
*/
|
||||
export function deriveResourceForecast(input: ResourceForecastInput): ResourceForecast {
|
||||
const { assignments, sah } = input;
|
||||
|
||||
if (sah <= 0) {
|
||||
return { chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 };
|
||||
}
|
||||
|
||||
// Sum hours per category
|
||||
const categoryHours: Record<string, number> = {};
|
||||
for (const a of assignments) {
|
||||
const hours = a.hoursPerDay * a.workingDays;
|
||||
const key = a.categoryCode.toLowerCase();
|
||||
categoryHours[key] = (categoryHours[key] ?? 0) + hours;
|
||||
}
|
||||
|
||||
const chgHours = categoryHours["chg"] ?? 0;
|
||||
const bdHours = categoryHours["bd"] ?? 0;
|
||||
const mdiHours = categoryHours["md&i"] ?? categoryHours["mdi"] ?? 0;
|
||||
const moHours = categoryHours["m&o"] ?? categoryHours["mo"] ?? 0;
|
||||
const pdrHours = categoryHours["pd&r"] ?? categoryHours["pdr"] ?? 0;
|
||||
const absenceHours = categoryHours["absence"] ?? 0;
|
||||
|
||||
const totalAssigned = chgHours + bdHours + mdiHours + moHours + pdrHours + absenceHours;
|
||||
const unassignedHours = Math.max(0, sah - totalAssigned);
|
||||
|
||||
return {
|
||||
chg: Math.min(1, chgHours / sah),
|
||||
bd: Math.min(1, bdHours / sah),
|
||||
mdi: Math.min(1, mdiHours / sah),
|
||||
mo: Math.min(1, moHours / sah),
|
||||
pdr: Math.min(1, pdrHours / sah),
|
||||
absence: Math.min(1, absenceHours / sah),
|
||||
unassigned: Math.min(1, unassignedHours / sah),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FTE-weighted chargeability for a group of resources.
|
||||
* Returns a ratio (0-1). Empty group returns 0.
|
||||
*/
|
||||
export function calculateGroupChargeability(resources: GroupChargeabilityInput[]): number {
|
||||
if (resources.length === 0) return 0;
|
||||
|
||||
let weightedSum = 0;
|
||||
let totalFte = 0;
|
||||
for (const r of resources) {
|
||||
weightedSum += r.fte * r.chargeability;
|
||||
totalFte += r.fte;
|
||||
}
|
||||
|
||||
return totalFte > 0 ? weightedSum / totalFte : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* FTE-weighted target for a group of resources.
|
||||
* Returns a ratio (0-1). Empty group returns 0.
|
||||
*/
|
||||
export function calculateGroupTarget(resources: GroupTargetInput[]): number {
|
||||
if (resources.length === 0) return 0;
|
||||
|
||||
let weightedSum = 0;
|
||||
let totalFte = 0;
|
||||
for (const r of resources) {
|
||||
weightedSum += r.fte * r.targetPercentage;
|
||||
totalFte += r.fte;
|
||||
}
|
||||
|
||||
return totalFte > 0 ? weightedSum / totalFte : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum FTE for a group of resources.
|
||||
*/
|
||||
export function sumFte(resources: { fte: number }[]): number {
|
||||
return resources.reduce((sum, r) => sum + r.fte, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start and end dates for a month (UTC).
|
||||
*/
|
||||
export function getMonthRange(year: number, month: number): { start: Date; end: Date } {
|
||||
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||
const end = new Date(Date.UTC(year, month, 0)); // last day of month
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a list of month keys (e.g. "2026-01", "2026-02") for a date range.
|
||||
*/
|
||||
export function getMonthKeys(startDate: Date, endDate: Date): string[] {
|
||||
const keys: string[] = [];
|
||||
const cursor = new Date(startDate);
|
||||
cursor.setUTCDate(1);
|
||||
|
||||
while (cursor <= endDate) {
|
||||
const y = cursor.getUTCFullYear();
|
||||
const m = cursor.getUTCMonth() + 1;
|
||||
keys.push(`${y}-${String(m).padStart(2, "0")}`);
|
||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count working days in a date range that overlap with an assignment period.
|
||||
* Excludes weekends. Does NOT exclude holidays (caller should adjust SAH for that).
|
||||
*/
|
||||
export function countWorkingDaysInOverlap(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
assignmentStart: Date,
|
||||
assignmentEnd: Date,
|
||||
): number {
|
||||
const start = new Date(Math.max(periodStart.getTime(), assignmentStart.getTime()));
|
||||
const end = new Date(Math.min(periodEnd.getTime(), assignmentEnd.getTime()));
|
||||
|
||||
if (start > end) return 0;
|
||||
|
||||
let count = 0;
|
||||
const cursor = new Date(start);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(end);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= endNorm) {
|
||||
const day = cursor.getUTCDay();
|
||||
if (day !== 0 && day !== 6) count++;
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
deriveResourceForecast,
|
||||
calculateGroupChargeability,
|
||||
calculateGroupTarget,
|
||||
sumFte,
|
||||
getMonthRange,
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
} from "./calculator.js";
|
||||
|
||||
export type {
|
||||
ResourceForecastInput,
|
||||
AssignmentSlice,
|
||||
ResourceForecast,
|
||||
GroupChargeabilityInput,
|
||||
GroupTargetInput,
|
||||
} from "./calculator.js";
|
||||
Reference in New Issue
Block a user