chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@planarchy/engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./allocation": "./src/allocation/index.ts",
|
||||
"./budget": "./src/budget/index.ts",
|
||||
"./shift": "./src/shift/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@planarchy/shared": "workspace:*",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@planarchy/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectOverlaps, validateAvailability } from "../allocation/availability-validator.js";
|
||||
|
||||
const stdAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
};
|
||||
|
||||
describe("validateAvailability", () => {
|
||||
it("passes when no existing allocations", () => {
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
8,
|
||||
stdAvailability,
|
||||
[],
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects conflict when combined hours exceed availability", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 6,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
4, // 6+4=10 > 8
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.conflicts).toHaveLength(5); // All 5 working days
|
||||
});
|
||||
|
||||
it("passes when combined hours exactly match availability", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 4,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
4, // 4+4=8 ≤ 8
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores cancelled allocations", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CANCELLED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
8,
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("skips non-working days in conflict check", () => {
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-04"), // Saturday
|
||||
new Date("2025-01-05"), // Sunday
|
||||
8,
|
||||
stdAvailability,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectOverlaps", () => {
|
||||
const existingAlloc = {
|
||||
id: "alloc-1",
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
projectId: "proj-1",
|
||||
status: AllocationStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("detects overlapping allocations", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[existingAlloc],
|
||||
);
|
||||
expect(overlaps).toContain("alloc-1");
|
||||
});
|
||||
|
||||
it("returns empty for non-overlapping date ranges", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-13"), // Starts after existing ends
|
||||
new Date("2025-01-17"),
|
||||
[existingAlloc],
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes same-project allocations when excludeProjectId provided", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[existingAlloc],
|
||||
"proj-1", // Exclude this project
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores cancelled allocations", () => {
|
||||
const cancelled = { ...existingAlloc, status: AllocationStatus.CANCELLED };
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[cancelled],
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeBudgetStatus } from "../budget/monitor.js";
|
||||
|
||||
const now = new Date("2025-01-06");
|
||||
const endDate = new Date("2025-03-07");
|
||||
|
||||
const mkAlloc = (
|
||||
hoursPerDay: number,
|
||||
dailyCostCents: number,
|
||||
status: AllocationStatus = AllocationStatus.CONFIRMED,
|
||||
) => ({
|
||||
status,
|
||||
dailyCostCents,
|
||||
hoursPerDay,
|
||||
startDate: now,
|
||||
endDate,
|
||||
});
|
||||
|
||||
describe("computeBudgetStatus", () => {
|
||||
it("returns 0% utilization for no allocations", () => {
|
||||
const result = computeBudgetStatus(1000000, 100, [], now, endDate);
|
||||
expect(result.allocatedCents).toBe(0);
|
||||
expect(result.utilizationPercent).toBe(0);
|
||||
expect(result.remainingCents).toBe(1000000);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calculates correct utilization", () => {
|
||||
// 1 alloc, 10 working days, 8000 cents/day = 80000 cents
|
||||
const alloc = { ...mkAlloc(8, 8000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(400000, 100, [alloc], now, endDate);
|
||||
expect(result.confirmedCents).toBe(80000);
|
||||
expect(result.utilizationPercent).toBeCloseTo(20, 0);
|
||||
});
|
||||
|
||||
it("emits WARNING at 85% utilization", () => {
|
||||
// Budget 100000, alloc cost 87000 = 87%
|
||||
const alloc = { ...mkAlloc(8, 87000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasWarning = result.warnings.some((w) => w.code === "BUDGET_WARNING");
|
||||
expect(hasWarning).toBe(true);
|
||||
});
|
||||
|
||||
it("emits CRITICAL at 95% utilization", () => {
|
||||
const alloc = { ...mkAlloc(8, 96000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasCritical = result.warnings.some((w) => w.code === "BUDGET_CRITICAL");
|
||||
expect(hasCritical).toBe(true);
|
||||
});
|
||||
|
||||
it("emits EXCEEDED when allocations exceed budget", () => {
|
||||
const alloc = { ...mkAlloc(8, 120000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasExceeded = result.warnings.some((w) => w.code === "BUDGET_EXCEEDED");
|
||||
expect(hasExceeded).toBe(true);
|
||||
});
|
||||
|
||||
it("applies win probability weighting", () => {
|
||||
const alloc = { ...mkAlloc(8, 10000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(1000000, 50, [alloc], now, endDate);
|
||||
// allocated = 10 days × 10000 = 100000, weighted = 50% = 50000
|
||||
expect(result.winProbabilityWeightedCents).toBe(Math.round(result.allocatedCents * 0.5));
|
||||
});
|
||||
|
||||
it("separates proposed from confirmed costs", () => {
|
||||
const confirmed = { ...mkAlloc(8, 5000, AllocationStatus.CONFIRMED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
|
||||
const proposed = { ...mkAlloc(8, 5000, AllocationStatus.PROPOSED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
|
||||
const result = computeBudgetStatus(1000000, 100, [confirmed, proposed], now, endDate);
|
||||
expect(result.confirmedCents).toBeGreaterThan(0);
|
||||
expect(result.proposedCents).toBeGreaterThan(0);
|
||||
expect(result.confirmedCents).toBe(result.proposedCents);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateAllocation,
|
||||
calculateTotalCost,
|
||||
countWorkingDays,
|
||||
isWorkday,
|
||||
} from "../allocation/calculator.js";
|
||||
|
||||
const stdAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
};
|
||||
|
||||
describe("isWorkday", () => {
|
||||
it("returns true for Monday", () => {
|
||||
expect(isWorkday(new Date("2025-01-06"), stdAvailability)).toBe(true); // Monday
|
||||
});
|
||||
|
||||
it("returns false for Saturday", () => {
|
||||
expect(isWorkday(new Date("2025-01-04"), stdAvailability)).toBe(false); // Saturday
|
||||
});
|
||||
|
||||
it("returns false for Sunday", () => {
|
||||
expect(isWorkday(new Date("2025-01-05"), stdAvailability)).toBe(false); // Sunday
|
||||
});
|
||||
});
|
||||
|
||||
describe("countWorkingDays", () => {
|
||||
it("counts 5 days in a Mon-Fri week", () => {
|
||||
const start = new Date("2025-01-06"); // Monday
|
||||
const end = new Date("2025-01-10"); // Friday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(5);
|
||||
});
|
||||
|
||||
it("counts 10 days over two Mon-Fri weeks", () => {
|
||||
const start = new Date("2025-01-06"); // Monday
|
||||
const end = new Date("2025-01-17"); // Friday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(10);
|
||||
});
|
||||
|
||||
it("counts 0 for a weekend-only range", () => {
|
||||
const start = new Date("2025-01-04"); // Saturday
|
||||
const end = new Date("2025-01-05"); // Sunday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts same day as 1 if it's a workday", () => {
|
||||
const day = new Date("2025-01-06"); // Monday
|
||||
expect(countWorkingDays(day, day, stdAvailability)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateAllocation", () => {
|
||||
it("calculates cost for a full week at 8h/day and 100 EUR/h (10000 cents)", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000, // 100 EUR/h
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-06"), // Monday
|
||||
endDate: new Date("2025-01-10"), // Friday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
expect(result.totalHours).toBe(40);
|
||||
// 5 days × 8h × 10000 cents/h = 400000 cents = 4000 EUR
|
||||
expect(result.totalCostCents).toBe(400000);
|
||||
expect(result.dailyCostCents).toBe(80000);
|
||||
expect(result.dailyBreakdown).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("skips weekend days in breakdown", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-04"), // Saturday
|
||||
endDate: new Date("2025-01-12"), // Sunday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
const weekendDays = result.dailyBreakdown.filter((d) => !d.isWorkday);
|
||||
expect(weekendDays).toHaveLength(4); // 2 weekends
|
||||
});
|
||||
|
||||
it("caps hours at available hours if requested > available", () => {
|
||||
const limitedAvailability = { ...stdAvailability, monday: 4 };
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8, // Requested more than available
|
||||
startDate: new Date("2025-01-06"), // Monday only
|
||||
endDate: new Date("2025-01-06"),
|
||||
availability: limitedAvailability,
|
||||
});
|
||||
|
||||
expect(result.totalHours).toBe(4); // Capped at availability
|
||||
expect(result.totalCostCents).toBe(40000); // 4h × 10000 cents
|
||||
});
|
||||
|
||||
it("returns zero for date range with no working days", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-04"), // Saturday
|
||||
endDate: new Date("2025-01-05"), // Sunday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(0);
|
||||
expect(result.totalHours).toBe(0);
|
||||
expect(result.totalCostCents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateTotalCost", () => {
|
||||
it("calculates correctly", () => {
|
||||
// 10000 cents/h × 8h × 5 days = 400000 cents
|
||||
expect(calculateTotalCost(10000, 8, 5)).toBe(400000);
|
||||
});
|
||||
|
||||
it("returns 0 for 0 working days", () => {
|
||||
expect(calculateTotalCost(10000, 8, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveResourceForecast,
|
||||
calculateGroupChargeability,
|
||||
calculateGroupTarget,
|
||||
sumFte,
|
||||
getMonthRange,
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
} from "../chargeability/calculator.js";
|
||||
|
||||
// ─── deriveResourceForecast ──────────────────────────────────────────────────
|
||||
|
||||
describe("deriveResourceForecast", () => {
|
||||
it("returns unassigned=1 when sah=0", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [],
|
||||
sah: 0,
|
||||
});
|
||||
expect(result).toEqual({ chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 });
|
||||
});
|
||||
|
||||
it("fully assigned to chargeable work", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "Chg" }],
|
||||
sah: 160, // 8h * 20d
|
||||
});
|
||||
expect(result.chg).toBeCloseTo(1, 5);
|
||||
expect(result.unassigned).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("50% chargeable, 25% BD, rest unassigned", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [
|
||||
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" },
|
||||
{ hoursPerDay: 2, workingDays: 20, categoryCode: "BD" },
|
||||
],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.chg).toBeCloseTo(0.5, 5);
|
||||
expect(result.bd).toBeCloseTo(0.25, 5);
|
||||
expect(result.unassigned).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
it("handles case-insensitive category codes", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [
|
||||
{ hoursPerDay: 8, workingDays: 10, categoryCode: "chg" },
|
||||
{ hoursPerDay: 8, workingDays: 10, categoryCode: "CHG" },
|
||||
],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.chg).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("handles MD&I and M&O codes with ampersand", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [
|
||||
{ hoursPerDay: 4, workingDays: 20, categoryCode: "MD&I" },
|
||||
{ hoursPerDay: 4, workingDays: 20, categoryCode: "M&O" },
|
||||
],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.mdi).toBeCloseTo(0.5, 5);
|
||||
expect(result.mo).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it("handles PD&R code", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "PD&R" }],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.pdr).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("handles absence category", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [
|
||||
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" },
|
||||
{ hoursPerDay: 4, workingDays: 20, categoryCode: "Absence" },
|
||||
],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.chg).toBeCloseTo(0.5, 5);
|
||||
expect(result.absence).toBeCloseTo(0.5, 5);
|
||||
expect(result.unassigned).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("clamps ratios at 1 when over-assigned", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 1,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [{ hoursPerDay: 16, workingDays: 20, categoryCode: "Chg" }],
|
||||
sah: 160,
|
||||
});
|
||||
expect(result.chg).toBe(1);
|
||||
});
|
||||
|
||||
it("multiple assignments same category are summed", () => {
|
||||
const result = deriveResourceForecast({
|
||||
fte: 0.5,
|
||||
targetPercentage: 0.8,
|
||||
assignments: [
|
||||
{ hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" },
|
||||
{ hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" },
|
||||
],
|
||||
sah: 80,
|
||||
});
|
||||
expect(result.chg).toBeCloseTo(1, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── calculateGroupChargeability ─────────────────────────────────────────────
|
||||
|
||||
describe("calculateGroupChargeability", () => {
|
||||
it("returns 0 for empty group", () => {
|
||||
expect(calculateGroupChargeability([])).toBe(0);
|
||||
});
|
||||
|
||||
it("single resource returns its chargeability", () => {
|
||||
expect(calculateGroupChargeability([{ fte: 1, chargeability: 0.75 }])).toBeCloseTo(0.75, 5);
|
||||
});
|
||||
|
||||
it("FTE-weighted average of two resources", () => {
|
||||
const result = calculateGroupChargeability([
|
||||
{ fte: 1, chargeability: 0.8 },
|
||||
{ fte: 0.5, chargeability: 0.6 },
|
||||
]);
|
||||
// (1*0.8 + 0.5*0.6) / (1 + 0.5) = 1.1 / 1.5 = 0.7333...
|
||||
expect(result).toBeCloseTo(0.7333, 3);
|
||||
});
|
||||
|
||||
it("returns 0 when all FTEs are 0", () => {
|
||||
expect(calculateGroupChargeability([{ fte: 0, chargeability: 0.9 }])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── calculateGroupTarget ────────────────────────────────────────────────────
|
||||
|
||||
describe("calculateGroupTarget", () => {
|
||||
it("returns 0 for empty group", () => {
|
||||
expect(calculateGroupTarget([])).toBe(0);
|
||||
});
|
||||
|
||||
it("FTE-weighted target average", () => {
|
||||
const result = calculateGroupTarget([
|
||||
{ fte: 1, targetPercentage: 0.8 },
|
||||
{ fte: 1, targetPercentage: 0.6 },
|
||||
]);
|
||||
expect(result).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sumFte ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("sumFte", () => {
|
||||
it("sums FTE values", () => {
|
||||
expect(sumFte([{ fte: 1 }, { fte: 0.5 }, { fte: 0.75 }])).toBeCloseTo(2.25, 5);
|
||||
});
|
||||
|
||||
it("returns 0 for empty array", () => {
|
||||
expect(sumFte([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMonthRange ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getMonthRange", () => {
|
||||
it("January 2026", () => {
|
||||
const { start, end } = getMonthRange(2026, 1);
|
||||
expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(end.toISOString()).toBe("2026-01-31T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("February 2024 (leap year)", () => {
|
||||
const { start, end } = getMonthRange(2024, 2);
|
||||
expect(start.toISOString()).toBe("2024-02-01T00:00:00.000Z");
|
||||
expect(end.toISOString()).toBe("2024-02-29T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("February 2025 (non-leap year)", () => {
|
||||
const { start, end } = getMonthRange(2025, 2);
|
||||
expect(end.toISOString()).toBe("2025-02-28T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMonthKeys ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getMonthKeys", () => {
|
||||
it("generates keys for Q1 2026", () => {
|
||||
const keys = getMonthKeys(new Date("2026-01-01"), new Date("2026-03-31"));
|
||||
expect(keys).toEqual(["2026-01", "2026-02", "2026-03"]);
|
||||
});
|
||||
|
||||
it("single month", () => {
|
||||
const keys = getMonthKeys(new Date("2026-06-15"), new Date("2026-06-20"));
|
||||
expect(keys).toEqual(["2026-06"]);
|
||||
});
|
||||
|
||||
it("cross-year boundary", () => {
|
||||
const keys = getMonthKeys(new Date("2025-11-01"), new Date("2026-02-28"));
|
||||
expect(keys).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── countWorkingDaysInOverlap ───────────────────────────────────────────────
|
||||
|
||||
describe("countWorkingDaysInOverlap", () => {
|
||||
it("full overlap on a workweek = 5 days", () => {
|
||||
const mon = new Date("2026-01-05");
|
||||
const fri = new Date("2026-01-09");
|
||||
expect(countWorkingDaysInOverlap(mon, fri, mon, fri)).toBe(5);
|
||||
});
|
||||
|
||||
it("no overlap returns 0", () => {
|
||||
expect(
|
||||
countWorkingDaysInOverlap(
|
||||
new Date("2026-01-05"),
|
||||
new Date("2026-01-09"),
|
||||
new Date("2026-01-12"),
|
||||
new Date("2026-01-16"),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("partial overlap: assignment starts mid-week", () => {
|
||||
// Period Mon-Fri, assignment Wed-next Mon → overlap Wed-Fri = 3
|
||||
expect(
|
||||
countWorkingDaysInOverlap(
|
||||
new Date("2026-01-05"), // Mon
|
||||
new Date("2026-01-09"), // Fri
|
||||
new Date("2026-01-07"), // Wed
|
||||
new Date("2026-01-12"), // next Mon
|
||||
),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("excludes weekends from overlap", () => {
|
||||
// Period: Mon-next Fri (10 calendar days, 10 working days)
|
||||
// Assignment: entire same range
|
||||
expect(
|
||||
countWorkingDaysInOverlap(
|
||||
new Date("2026-01-05"), // Mon
|
||||
new Date("2026-01-16"), // Fri
|
||||
new Date("2026-01-05"),
|
||||
new Date("2026-01-16"),
|
||||
),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("weekend-only overlap returns 0", () => {
|
||||
expect(
|
||||
countWorkingDaysInOverlap(
|
||||
new Date("2026-01-10"), // Sat
|
||||
new Date("2026-01-11"), // Sun
|
||||
new Date("2026-01-10"),
|
||||
new Date("2026-01-11"),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeAvailableHours,
|
||||
computeBookedHours,
|
||||
computeChargeability,
|
||||
} from "../allocation/chargeability.js";
|
||||
|
||||
// Standard Mon–Fri 8h availability
|
||||
const stdAvail = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
|
||||
|
||||
// Week of 2025-01-06 (Mon) to 2025-01-10 (Fri)
|
||||
const weekStart = new Date("2025-01-06");
|
||||
const weekEnd = new Date("2025-01-10");
|
||||
|
||||
describe("computeAvailableHours", () => {
|
||||
it("Mon–Fri 8h resource: 1 calendar week = 40 available hours", () => {
|
||||
expect(computeAvailableHours(stdAvail, weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("excludes Saturday and Sunday when not in availability", () => {
|
||||
// 2025-01-04 (Sat) to 2025-01-05 (Sun)
|
||||
expect(computeAvailableHours(stdAvail, new Date("2025-01-04"), new Date("2025-01-05"))).toBe(0);
|
||||
});
|
||||
|
||||
it("counts Saturday when enabled in availability", () => {
|
||||
const avail = { ...stdAvail, saturday: 4 };
|
||||
// Mon 06 to Sat 11 Jan: 5 weekdays × 8 + 1 Saturday × 4 = 44
|
||||
expect(computeAvailableHours(avail, new Date("2025-01-06"), new Date("2025-01-11"))).toBe(44);
|
||||
});
|
||||
|
||||
it("single day period (Monday): returns daily hours", () => {
|
||||
expect(computeAvailableHours(stdAvail, weekStart, weekStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBookedHours", () => {
|
||||
it("no allocations → bookedHours = 0", () => {
|
||||
expect(computeBookedHours(stdAvail, [], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
|
||||
it("full 8h allocation covering entire period counts all working days", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("allocation partially overlapping: only overlap days counted", () => {
|
||||
// Allocation spans Wed-Sun; period is Mon-Fri → overlap is Wed-Fri (3 days)
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-08"), // Wed
|
||||
endDate: new Date("2025-01-12"), // Sun
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(24); // 3 days × 8
|
||||
});
|
||||
|
||||
it("allocation spanning weekends: weekends excluded from booked hours", () => {
|
||||
// Mon 06 to Mon 13 (spans a weekend), but weekend has 0 avail
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-13"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
// Period is Mon-Fri week only
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("Saturday with availability counted when allocation covers it", () => {
|
||||
const avail = { ...stdAvail, saturday: 4 };
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-06"), // Mon
|
||||
endDate: new Date("2025-01-11"), // Sat
|
||||
hoursPerDay: 6,
|
||||
};
|
||||
// Mon–Fri: 5 days × 6 = 30, Sat: 1 day × 6 = 6 → total 36
|
||||
expect(computeBookedHours(avail, [alloc], new Date("2025-01-06"), new Date("2025-01-11"))).toBe(36);
|
||||
});
|
||||
|
||||
it("allocation entirely before period: no hours counted", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2024-12-30"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
|
||||
it("allocation entirely after period: no hours counted", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-13"),
|
||||
endDate: new Date("2025-01-17"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeChargeability", () => {
|
||||
it("no allocations → chargeability = 0, bookedHours = 0", () => {
|
||||
const result = computeChargeability(stdAvail, [], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(0);
|
||||
expect(result.bookedHours).toBe(0);
|
||||
expect(result.availableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("full 8h allocation covering entire period → chargeability = 100", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(100);
|
||||
expect(result.bookedHours).toBe(40);
|
||||
});
|
||||
|
||||
it("50% allocation → chargeability = 50", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(50);
|
||||
expect(result.bookedHours).toBe(20);
|
||||
});
|
||||
|
||||
it("over-allocated (booked > available): chargeability capped at 100", () => {
|
||||
// Two allocations totalling 12h/day on a resource with 8h availability
|
||||
const allocs = [
|
||||
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 },
|
||||
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 },
|
||||
];
|
||||
const result = computeChargeability(stdAvail, allocs, weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(100);
|
||||
expect(result.bookedHours).toBe(60); // not capped in raw value
|
||||
});
|
||||
|
||||
it("single day period (start === end): correct result", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekStart, hoursPerDay: 4 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekStart);
|
||||
expect(result.availableHours).toBe(8);
|
||||
expect(result.bookedHours).toBe(4);
|
||||
expect(result.chargeability).toBe(50);
|
||||
});
|
||||
|
||||
it("zero available hours (all weekend period): chargeability = 0", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-04"),
|
||||
endDate: new Date("2025-01-05"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
const result = computeChargeability(stdAvail, [alloc], new Date("2025-01-04"), new Date("2025-01-05"));
|
||||
expect(result.availableHours).toBe(0);
|
||||
expect(result.chargeability).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
expandScopeToEffort,
|
||||
aggregateByDiscipline,
|
||||
type EffortRuleInput,
|
||||
type ScopeItemInput,
|
||||
} from "../estimate/effort-rules.js";
|
||||
|
||||
const STANDARD_RULES: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "3D Animation", chapter: "Animation", unitMode: "per_frame", hoursPerUnit: 0.5, sortOrder: 0 },
|
||||
{ scopeType: "SHOT", discipline: "3D Lighting", chapter: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.2, sortOrder: 1 },
|
||||
{ scopeType: "SHOT", discipline: "Compositing", chapter: "Compositing", unitMode: "per_frame", hoursPerUnit: 0.15, sortOrder: 2 },
|
||||
{ scopeType: "ASSET", discipline: "3D Modeling", chapter: "Modeling", unitMode: "per_item", hoursPerUnit: 40, sortOrder: 0 },
|
||||
{ scopeType: "ASSET", discipline: "3D Rigging", chapter: "Rigging", unitMode: "per_item", hoursPerUnit: 24, sortOrder: 1 },
|
||||
{ scopeType: "ENVIRONMENT", discipline: "3D Environment", chapter: "Environment", unitMode: "flat", hoursPerUnit: 80, sortOrder: 0 },
|
||||
];
|
||||
|
||||
describe("expandScopeToEffort", () => {
|
||||
it("expands a single shot scope item into multiple discipline lines", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 120 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(3);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.unmatchedScopeItems).toHaveLength(0);
|
||||
|
||||
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
|
||||
expect(anim.hours).toBe(60); // 120 * 0.5
|
||||
expect(anim.unitCount).toBe(120);
|
||||
expect(anim.chapter).toBe("Animation");
|
||||
|
||||
const lighting = result.lines.find((l) => l.discipline === "3D Lighting")!;
|
||||
expect(lighting.hours).toBe(24); // 120 * 0.2
|
||||
|
||||
const comp = result.lines.find((l) => l.discipline === "Compositing")!;
|
||||
expect(comp.hours).toBe(18); // 120 * 0.15
|
||||
});
|
||||
|
||||
it("expands asset scope items using per_item mode", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Hero Character", scopeType: "ASSET", itemCount: 3 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(2);
|
||||
const modeling = result.lines.find((l) => l.discipline === "3D Modeling")!;
|
||||
expect(modeling.hours).toBe(120); // 3 * 40
|
||||
const rigging = result.lines.find((l) => l.discipline === "3D Rigging")!;
|
||||
expect(rigging.hours).toBe(72); // 3 * 24
|
||||
});
|
||||
|
||||
it("uses flat hours for environment scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Forest Scene", scopeType: "ENVIRONMENT" },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0]!.hours).toBe(80); // flat
|
||||
expect(result.lines[0]!.unitCount).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to 1 when frameCount is null in per_frame mode", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot X", scopeType: "SHOT", frameCount: null },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
|
||||
expect(anim.unitCount).toBe(1);
|
||||
expect(anim.hours).toBe(0.5); // 1 * 0.5
|
||||
});
|
||||
|
||||
it("handles multiple scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
|
||||
{ name: "Prop A", scopeType: "ASSET", itemCount: 1 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
// 2 shots * 3 rules + 1 asset * 2 rules = 8 lines
|
||||
expect(result.lines).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("reports unmatched scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Audio Track", scopeType: "AUDIO" },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.unmatchedScopeItems).toContain("Audio Track");
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches scope type case-insensitively", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "shot", frameCount: 50 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
expect(result.lines).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("warns when no rules provided", () => {
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
|
||||
[],
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.warnings).toContain("No effort rules provided.");
|
||||
});
|
||||
|
||||
it("skips lines with zero hours", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "QC", unitMode: "per_frame", hoursPerUnit: 0, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
|
||||
rules,
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped"))).toBe(true);
|
||||
});
|
||||
|
||||
it("skips empty-name scope items", () => {
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "", scopeType: "SHOT", frameCount: 100 }],
|
||||
STANDARD_RULES,
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rounds hours to 2 decimal places", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "FX", unitMode: "per_frame", hoursPerUnit: 0.333, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 7 }],
|
||||
rules,
|
||||
);
|
||||
// 7 * 0.333 = 2.331
|
||||
expect(result.lines[0]!.hours).toBe(2.33);
|
||||
});
|
||||
|
||||
it("preserves sortOrder in output order", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "Z-Last", unitMode: "flat", hoursPerUnit: 1, sortOrder: 10 },
|
||||
{ scopeType: "SHOT", discipline: "A-First", unitMode: "flat", hoursPerUnit: 1, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT" }],
|
||||
rules,
|
||||
);
|
||||
expect(result.lines[0]!.discipline).toBe("A-First");
|
||||
expect(result.lines[1]!.discipline).toBe("Z-Last");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateByDiscipline", () => {
|
||||
it("sums hours across scope items per discipline", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
const anim = aggregated.find((a) => a.discipline === "3D Animation")!;
|
||||
expect(anim.totalHours).toBe(150); // (100+200) * 0.5
|
||||
expect(anim.lineCount).toBe(2);
|
||||
});
|
||||
|
||||
it("sorts by totalHours descending", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
expect(aggregated[0]!.totalHours).toBeGreaterThanOrEqual(aggregated[1]!.totalHours);
|
||||
});
|
||||
|
||||
it("keeps discipline+chapter pairs separate", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "Animation", chapter: "2D", unitMode: "flat", hoursPerUnit: 10, sortOrder: 0 },
|
||||
{ scopeType: "SHOT", discipline: "Animation", chapter: "3D", unitMode: "flat", hoursPerUnit: 20, sortOrder: 1 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT" }],
|
||||
rules,
|
||||
);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
expect(aggregated).toHaveLength(2);
|
||||
const anim2d = aggregated.find((a) => a.chapter === "2D")!;
|
||||
expect(anim2d.totalHours).toBe(10);
|
||||
const anim3d = aggregated.find((a) => a.chapter === "3D")!;
|
||||
expect(anim3d.totalHours).toBe(20);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(aggregateByDiscipline([])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
serializeEstimateExport,
|
||||
type EstimateExportSource,
|
||||
} from "../estimate/export-serializer.js";
|
||||
|
||||
const createdAt = new Date("2026-03-13T08:00:00.000Z");
|
||||
|
||||
function buildSource(): EstimateExportSource {
|
||||
return {
|
||||
estimate: {
|
||||
id: "est_1",
|
||||
projectId: "project_1",
|
||||
name: "CGI Estimate",
|
||||
opportunityId: "OP-42",
|
||||
baseCurrency: "EUR",
|
||||
status: EstimateStatus.APPROVED,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
project: {
|
||||
id: "project_1",
|
||||
shortCode: "CGI-001",
|
||||
name: "CGI Project",
|
||||
status: "ACTIVE",
|
||||
startDate: "2026-03-01T00:00:00.000Z",
|
||||
endDate: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
version: {
|
||||
id: "ver_1",
|
||||
versionNumber: 1,
|
||||
label: "Approved",
|
||||
status: EstimateVersionStatus.APPROVED,
|
||||
notes: "Ready for export",
|
||||
lockedAt: createdAt,
|
||||
projectSnapshot: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
assumptions: [
|
||||
{
|
||||
id: "assumption_1",
|
||||
category: "commercial",
|
||||
key: "pricingStructure",
|
||||
label: "Pricing Structure",
|
||||
valueType: "string",
|
||||
value: "fixed-bid",
|
||||
sortOrder: 0,
|
||||
notes: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
scopeItems: [
|
||||
{
|
||||
id: "scope_1",
|
||||
sequenceNo: 10,
|
||||
scopeType: "SHOT",
|
||||
packageCode: "PKG-1",
|
||||
name: "Shot 010",
|
||||
description: "Main hero shot",
|
||||
scene: "Scene 01",
|
||||
page: null,
|
||||
location: "Berlin",
|
||||
assumptionCategory: null,
|
||||
technicalSpec: { resolution: "4K" },
|
||||
frameCount: 120,
|
||||
itemCount: 1,
|
||||
unitMode: "shot",
|
||||
internalComments: null,
|
||||
externalComments: null,
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
demandLines: [
|
||||
{
|
||||
id: "line_1",
|
||||
scopeItemId: "scope_1",
|
||||
roleId: "role_1",
|
||||
resourceId: "resource_1",
|
||||
lineType: "LABOR",
|
||||
name: "Comp Artist",
|
||||
chapter: "Compositing",
|
||||
hours: 40,
|
||||
days: 5,
|
||||
fte: 1,
|
||||
rateSource: "resource",
|
||||
costRateCents: 5000,
|
||||
billRateCents: 8000,
|
||||
currency: "EUR",
|
||||
costTotalCents: 200000,
|
||||
priceTotalCents: 320000,
|
||||
monthlySpread: { "2026-03": 40 },
|
||||
staffingAttributes: { location: "Berlin" },
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
resourceSnapshots: [
|
||||
{
|
||||
id: "snapshot_1",
|
||||
resourceId: "resource_1",
|
||||
sourceEid: "E100",
|
||||
displayName: "Alex Artist",
|
||||
chapter: "Compositing",
|
||||
roleId: "role_1",
|
||||
currency: "EUR",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 8000,
|
||||
fte: 1,
|
||||
location: "Berlin",
|
||||
country: "DE",
|
||||
level: "Senior",
|
||||
workType: "Remote",
|
||||
attributes: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: "metric_1",
|
||||
key: "total_hours",
|
||||
label: "Total Hours",
|
||||
metricGroup: "summary",
|
||||
valueDecimal: 40,
|
||||
valueCents: null,
|
||||
currency: null,
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("estimate export serializer", () => {
|
||||
it("creates a structured JSON export payload", () => {
|
||||
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.JSON);
|
||||
|
||||
expect(payload.encoding).toBe("utf8");
|
||||
expect(payload.mimeType).toBe("application/json; charset=utf-8");
|
||||
expect(payload.summary.totalHours).toBe(40);
|
||||
expect(payload.content).toContain('"estimateId": "est_1"');
|
||||
expect(payload.previewText).toContain('"schemaVersion": 1');
|
||||
});
|
||||
|
||||
it("creates a multi-sheet xlsx export payload", () => {
|
||||
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX);
|
||||
const workbook = XLSX.read(payload.content, { type: "base64" });
|
||||
|
||||
expect(payload.encoding).toBe("base64");
|
||||
expect(payload.sheetNames).toEqual([
|
||||
"Overview",
|
||||
"Assumptions",
|
||||
"Scope",
|
||||
"DemandLines",
|
||||
"Resources",
|
||||
"Metrics",
|
||||
]);
|
||||
expect(workbook.SheetNames).toContain("DemandLines");
|
||||
expect(payload.byteLength).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeEstimateDemandLine,
|
||||
summarizeEstimateDemandLines,
|
||||
} from "../estimate/index.js";
|
||||
|
||||
describe("summarizeEstimateDemandLines", () => {
|
||||
it("aggregates hours, totals, and margin", () => {
|
||||
expect(
|
||||
summarizeEstimateDemandLines([
|
||||
{ hours: 10, costTotalCents: 100_00, priceTotalCents: 180_00 },
|
||||
{ hours: 6.5, costTotalCents: 80_00, priceTotalCents: 120_00 },
|
||||
]),
|
||||
).toEqual({
|
||||
totalHours: 16.5,
|
||||
totalCostCents: 180_00,
|
||||
totalPriceCents: 300_00,
|
||||
marginCents: 120_00,
|
||||
marginPercent: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns zero margin percent when no price exists", () => {
|
||||
expect(
|
||||
summarizeEstimateDemandLines([
|
||||
{ hours: 4, costTotalCents: 50_00, priceTotalCents: 0 },
|
||||
]),
|
||||
).toEqual({
|
||||
totalHours: 4,
|
||||
totalCostCents: 50_00,
|
||||
totalPriceCents: 0,
|
||||
marginCents: -50_00,
|
||||
marginPercent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEstimateDemandLine", () => {
|
||||
it("recomputes linked live rates and totals from the resource snapshot", () => {
|
||||
expect(
|
||||
normalizeEstimateDemandLine(
|
||||
{
|
||||
resourceId: "res_1",
|
||||
hours: 12,
|
||||
rateSource: "RESOURCE",
|
||||
costRateCents: 4000,
|
||||
billRateCents: 6500,
|
||||
currency: "EUR",
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceSnapshot: {
|
||||
resourceId: "res_1",
|
||||
currency: "USD",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 8000,
|
||||
},
|
||||
defaultCurrency: "EUR",
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
costRateCents: 5000,
|
||||
billRateCents: 8000,
|
||||
currency: "USD",
|
||||
costTotalCents: 60000,
|
||||
priceTotalCents: 96000,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: 5000,
|
||||
liveBillRateCents: 8000,
|
||||
liveCurrency: "USD",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves manual override rates while still updating trace metadata", () => {
|
||||
expect(
|
||||
normalizeEstimateDemandLine(
|
||||
{
|
||||
resourceId: "res_2",
|
||||
hours: 10,
|
||||
rateSource: "RESOURCE",
|
||||
costRateCents: 7200,
|
||||
billRateCents: 9500,
|
||||
currency: "EUR",
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "manual",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceSnapshot: {
|
||||
resourceId: "res_2",
|
||||
currency: "EUR",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
},
|
||||
defaultCurrency: "EUR",
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
costRateCents: 7200,
|
||||
billRateCents: 9000,
|
||||
costTotalCents: 72000,
|
||||
priceTotalCents: 90000,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "manual",
|
||||
billRateMode: "resource",
|
||||
liveCostRateCents: 5000,
|
||||
liveBillRateCents: 9000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findBestMatchingRule,
|
||||
applyExperienceMultipliers,
|
||||
applyExperienceMultipliersBatch,
|
||||
type ExperienceMultiplierRule,
|
||||
type RateAdjustmentInput,
|
||||
} from "../estimate/experience-multiplier.js";
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const globalRule: ExperienceMultiplierRule = {
|
||||
costMultiplier: 1.1,
|
||||
billMultiplier: 1.1,
|
||||
description: "Global 10% uplift",
|
||||
};
|
||||
|
||||
const chapterRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
costMultiplier: 1.2,
|
||||
billMultiplier: 1.3,
|
||||
};
|
||||
|
||||
const locationRule: ExperienceMultiplierRule = {
|
||||
location: "India",
|
||||
costMultiplier: 0.6,
|
||||
billMultiplier: 0.8,
|
||||
};
|
||||
|
||||
const levelRule: ExperienceMultiplierRule = {
|
||||
level: "Senior",
|
||||
costMultiplier: 1.5,
|
||||
billMultiplier: 1.6,
|
||||
};
|
||||
|
||||
const chapterLocationRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
costMultiplier: 0.7,
|
||||
billMultiplier: 0.85,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0.2,
|
||||
};
|
||||
|
||||
const chapterLevelRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
level: "Senior",
|
||||
costMultiplier: 1.4,
|
||||
billMultiplier: 1.5,
|
||||
};
|
||||
|
||||
const locationLevelRule: ExperienceMultiplierRule = {
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
costMultiplier: 0.8,
|
||||
billMultiplier: 0.9,
|
||||
};
|
||||
|
||||
const exactRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
costMultiplier: 0.9,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.3,
|
||||
additionalEffortRatio: 0.1,
|
||||
description: "Exact match rule",
|
||||
};
|
||||
|
||||
const allRules: ExperienceMultiplierRule[] = [
|
||||
globalRule,
|
||||
chapterRule,
|
||||
locationRule,
|
||||
levelRule,
|
||||
chapterLocationRule,
|
||||
chapterLevelRule,
|
||||
locationLevelRule,
|
||||
exactRule,
|
||||
];
|
||||
|
||||
// ─── findBestMatchingRule ────────────────────────────────────────────────────
|
||||
|
||||
describe("findBestMatchingRule", () => {
|
||||
it("returns null when no rules are provided", () => {
|
||||
expect(findBestMatchingRule({ chapter: "X" }, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no rule matches", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Compositing", costMultiplier: 1, billMultiplier: 1 },
|
||||
];
|
||||
expect(findBestMatchingRule({ chapter: "Animation" }, rules)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches exact chapter+location+level (specificity 7)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "India", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(exactRule);
|
||||
});
|
||||
|
||||
it("matches chapter+location (specificity 6) when level does not match exact", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "India", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterLocationRule);
|
||||
});
|
||||
|
||||
it("matches chapter+level (specificity 5) when location differs", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "Germany", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterLevelRule);
|
||||
});
|
||||
|
||||
it("matches chapter only (specificity 4)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "Germany", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterRule);
|
||||
});
|
||||
|
||||
it("matches location+level (specificity 3)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "India", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(locationLevelRule);
|
||||
});
|
||||
|
||||
it("matches location only (specificity 2)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "India", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(locationRule);
|
||||
});
|
||||
|
||||
it("matches level only (specificity 1)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "Germany", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(levelRule);
|
||||
});
|
||||
|
||||
it("falls back to global rule (specificity 0)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "Germany", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(globalRule);
|
||||
});
|
||||
|
||||
it("is case-insensitive when matching", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "ANIMATION", location: "india", costMultiplier: 1, billMultiplier: 1 },
|
||||
];
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "animation", location: "India" },
|
||||
rules,
|
||||
);
|
||||
expect(result).toBe(rules[0]);
|
||||
});
|
||||
|
||||
it("treats null / undefined input fields as wildcard-matchable", () => {
|
||||
const result = findBestMatchingRule({}, [globalRule]);
|
||||
expect(result).toBe(globalRule);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyExperienceMultipliers ─────────────────────────────────────────────
|
||||
|
||||
describe("applyExperienceMultipliers", () => {
|
||||
const baseInput: RateAdjustmentInput = {
|
||||
costRateCents: 10000,
|
||||
billRateCents: 15000,
|
||||
hours: 100,
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
};
|
||||
|
||||
it("returns unchanged values when no rules provided", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, []);
|
||||
expect(result.adjustedCostRateCents).toBe(10000);
|
||||
expect(result.adjustedBillRateCents).toBe(15000);
|
||||
expect(result.adjustedHours).toBe(100);
|
||||
expect(result.appliedRules).toHaveLength(1);
|
||||
expect(result.appliedRules[0]).toContain("No rules provided");
|
||||
});
|
||||
|
||||
it("returns unchanged values when no rule matches", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Compositing", costMultiplier: 2, billMultiplier: 2 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ ...baseInput, chapter: "Animation" },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(10000);
|
||||
expect(result.adjustedBillRateCents).toBe(15000);
|
||||
});
|
||||
|
||||
it("applies rate multipliers correctly and rounds to integer cents", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Animation", costMultiplier: 1.15, billMultiplier: 1.33 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 80, chapter: "Animation" },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(11500); // 10000 * 1.15
|
||||
expect(result.adjustedBillRateCents).toBe(19950); // 15000 * 1.33
|
||||
expect(result.adjustedHours).toBe(80); // no shoring
|
||||
});
|
||||
|
||||
it("handles rounding edge case (e.g. 3333 * 1.1 = 3666.3 => 3666)", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ costMultiplier: 1.1, billMultiplier: 1.0 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 3333, billRateCents: 5000, hours: 10 },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(3666); // Math.round(3666.3)
|
||||
});
|
||||
|
||||
it("applies shoring ratio to hours", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{
|
||||
costMultiplier: 1.0,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0,
|
||||
},
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
|
||||
rules,
|
||||
);
|
||||
// 50 onsite + 50 offshore * (1 + 0) = 100 — no additional effort means hours unchanged
|
||||
expect(result.adjustedHours).toBe(100);
|
||||
});
|
||||
|
||||
it("applies shoring ratio with additional effort factor", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{
|
||||
costMultiplier: 1.0,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0.2,
|
||||
},
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
|
||||
rules,
|
||||
);
|
||||
// onsite = 100 * 0.5 = 50, offshore = 100 * 0.5 * 1.2 = 60 => total = 110
|
||||
expect(result.adjustedHours).toBe(110);
|
||||
});
|
||||
|
||||
it("applies both rate multipliers and shoring together", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, allRules);
|
||||
// Should match exactRule (chapter=Animation, location=India, level=Senior)
|
||||
expect(result.adjustedCostRateCents).toBe(Math.round(10000 * 0.9)); // 9000
|
||||
expect(result.adjustedBillRateCents).toBe(Math.round(15000 * 1.0)); // 15000
|
||||
// shoring: 0.3, additional: 0.1
|
||||
// onsite = 100 * 0.7 = 70, offshore = 100 * 0.3 * 1.1 = 33 => 103
|
||||
expect(result.adjustedHours).toBe(103);
|
||||
expect(result.appliedRules.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("includes rule description in audit trail when present", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, allRules);
|
||||
const descRule = result.appliedRules.find((r) => r.includes("Exact match rule"));
|
||||
expect(descRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("reports no-op when multipliers are 1.0 and no shoring", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ costMultiplier: 1.0, billMultiplier: 1.0 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 5000, billRateCents: 8000, hours: 40 },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(5000);
|
||||
expect(result.adjustedBillRateCents).toBe(8000);
|
||||
expect(result.adjustedHours).toBe(40);
|
||||
expect(result.appliedRules[0]).toContain("unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyExperienceMultipliersBatch ────────────────────────────────────────
|
||||
|
||||
describe("applyExperienceMultipliersBatch", () => {
|
||||
it("processes multiple inputs and returns correct summary", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Animation", costMultiplier: 1.2, billMultiplier: 1.3 },
|
||||
{ costMultiplier: 1.0, billMultiplier: 1.0 }, // global fallback
|
||||
];
|
||||
|
||||
const inputs: RateAdjustmentInput[] = [
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 50, chapter: "Animation" },
|
||||
{ costRateCents: 8000, billRateCents: 12000, hours: 30, chapter: "Compositing" },
|
||||
];
|
||||
|
||||
const batch = applyExperienceMultipliersBatch(inputs, rules);
|
||||
expect(batch.results).toHaveLength(2);
|
||||
expect(batch.totalOriginalHours).toBe(80);
|
||||
expect(batch.totalAdjustedHours).toBe(80);
|
||||
// First line adjusted (cost * 1.2), second not (1.0)
|
||||
expect(batch.linesAdjusted).toBe(1);
|
||||
expect(batch.results[0]!.adjustedCostRateCents).toBe(12000);
|
||||
expect(batch.results[1]!.adjustedCostRateCents).toBe(8000);
|
||||
});
|
||||
|
||||
it("handles empty input array", () => {
|
||||
const batch = applyExperienceMultipliersBatch([], [globalRule]);
|
||||
expect(batch.results).toHaveLength(0);
|
||||
expect(batch.totalOriginalHours).toBe(0);
|
||||
expect(batch.totalAdjustedHours).toBe(0);
|
||||
expect(batch.linesAdjusted).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeEvenSpread,
|
||||
getEstimateMonthRange,
|
||||
rebalanceSpread,
|
||||
summarizeMonthlySpread,
|
||||
} from "../estimate/monthly-spread.js";
|
||||
|
||||
describe("getEstimateMonthRange", () => {
|
||||
it("returns months between two dates in the same year", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-03-01"), new Date("2026-06-30")),
|
||||
).toEqual(["2026-03", "2026-04", "2026-05", "2026-06"]);
|
||||
});
|
||||
|
||||
it("returns a single month when start and end are in the same month", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-05-10"), new Date("2026-05-20")),
|
||||
).toEqual(["2026-05"]);
|
||||
});
|
||||
|
||||
it("spans across years", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2025-11-01"), new Date("2026-02-28")),
|
||||
).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]);
|
||||
});
|
||||
|
||||
it("returns empty when endDate < startDate", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-06-01"), new Date("2026-03-01")),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEvenSpread", () => {
|
||||
it("distributes hours across months weighted by working days", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
// Hours should sum to exactly 100
|
||||
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
|
||||
expect(Math.round(sum * 10) / 10).toBe(100);
|
||||
// Each month should have a positive value
|
||||
for (const month of result.months) {
|
||||
expect(result.spread[month]).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a single month", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 40,
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-04"]);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
|
||||
it("handles partial first and last months", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 80,
|
||||
startDate: new Date("2026-03-15"),
|
||||
endDate: new Date("2026-04-15"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04"]);
|
||||
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
|
||||
expect(Math.round(sum * 10) / 10).toBe(80);
|
||||
// March 15-31 has fewer working days than April 1-15
|
||||
// Both should be positive
|
||||
expect(result.spread["2026-03"]).toBeGreaterThan(0);
|
||||
expect(result.spread["2026-04"]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty spread for empty date range", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-03-01"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual([]);
|
||||
expect(result.spread).toEqual({});
|
||||
});
|
||||
|
||||
it("handles zero total hours", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 0,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
for (const month of result.months) {
|
||||
expect(result.spread[month]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebalanceSpread", () => {
|
||||
it("distributes remaining hours after locked months", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
lockedMonths: { "2026-03": 50 },
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
expect(result.spread["2026-03"]).toBe(50);
|
||||
// Remaining 50 hours across April + May
|
||||
const remaining = (result.spread["2026-04"] ?? 0) + (result.spread["2026-05"] ?? 0);
|
||||
expect(Math.round(remaining * 10) / 10).toBe(50);
|
||||
});
|
||||
|
||||
it("handles all months locked", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-03": 60, "2026-04": 40 },
|
||||
});
|
||||
|
||||
expect(result.spread["2026-03"]).toBe(60);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps remaining to zero when locked exceeds total", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 50,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-03": 60 },
|
||||
});
|
||||
|
||||
expect(result.spread["2026-03"]).toBe(60);
|
||||
// Remaining is max(0, 50-60) = 0
|
||||
expect(result.spread["2026-04"]).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores locked months outside the date range", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 80,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-01": 30, "2026-03": 40 },
|
||||
});
|
||||
|
||||
// 2026-01 is outside range, so remaining = 80 - 40 = 40
|
||||
expect(result.spread["2026-03"]).toBe(40);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeMonthlySpread", () => {
|
||||
it("aggregates hours per month across multiple spreads", () => {
|
||||
const result = summarizeMonthlySpread([
|
||||
{ "2026-03": 30, "2026-04": 20 },
|
||||
{ "2026-03": 10, "2026-04": 15, "2026-05": 25 },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"2026-03": 40,
|
||||
"2026-04": 35,
|
||||
"2026-05": 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(summarizeMonthlySpread([])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRecurringDay, getRecurringHoursForDay } from "../allocation/recurrence.js";
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
|
||||
const monday = new Date("2026-03-09"); // Monday
|
||||
const tuesday = new Date("2026-03-10"); // Tuesday
|
||||
const wednesday = new Date("2026-03-11"); // Wednesday
|
||||
const friday = new Date("2026-03-13"); // Friday
|
||||
const nextMonday = new Date("2026-03-16"); // Monday (next week)
|
||||
const twoWeeksMonday = new Date("2026-03-23"); // Monday (two weeks after)
|
||||
|
||||
const allocationStart = new Date("2026-03-09"); // Monday
|
||||
|
||||
describe("isRecurringDay", () => {
|
||||
describe("WEEKLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.WEEKLY,
|
||||
weekdays: [1, 3, 5], // Mon, Wed, Fri
|
||||
};
|
||||
|
||||
it("returns true for days matching weekday list", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(wednesday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(friday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for days not in weekday list", () => {
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BIWEEKLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.BIWEEKLY,
|
||||
weekdays: [1], // Monday only
|
||||
interval: 2,
|
||||
};
|
||||
|
||||
it("returns true on the allocation start week (week 0)", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on the next week (week 1, odd)", () => {
|
||||
expect(isRecurringDay(nextMonday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true two weeks after start (week 2, even)", () => {
|
||||
expect(isRecurringDay(twoWeeksMonday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-matching weekdays even in active weeks", () => {
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MONTHLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.MONTHLY,
|
||||
monthDay: 15,
|
||||
};
|
||||
|
||||
it("returns true on the specified day of month", () => {
|
||||
expect(isRecurringDay(new Date("2026-03-15"), pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on other days", () => {
|
||||
expect(isRecurringDay(new Date("2026-03-14"), pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when monthDay is undefined", () => {
|
||||
const noDay: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.MONTHLY,
|
||||
};
|
||||
expect(isRecurringDay(new Date("2026-03-15"), noDay, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CUSTOM", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.CUSTOM,
|
||||
};
|
||||
|
||||
it("always returns true", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecurringHoursForDay", () => {
|
||||
const weeklyPattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.WEEKLY,
|
||||
weekdays: [1, 3, 5], // Mon, Wed, Fri
|
||||
};
|
||||
|
||||
it("returns default hours on recurring days", () => {
|
||||
expect(getRecurringHoursForDay(monday, weeklyPattern, 8, allocationStart)).toBe(8);
|
||||
});
|
||||
|
||||
it("returns 0 on non-recurring days", () => {
|
||||
expect(getRecurringHoursForDay(tuesday, weeklyPattern, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses pattern.hoursPerDay when set", () => {
|
||||
const patternWithHours: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
hoursPerDay: 4,
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithHours, 8, allocationStart)).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 0 before pattern startDate", () => {
|
||||
const patternWithStart: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-16"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithStart, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 after pattern endDate", () => {
|
||||
const patternWithEnd: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
endDate: new Date("2026-03-08"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithEnd, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns hours within pattern date bounds", () => {
|
||||
const bounded: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, bounded, 8, allocationStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { calculateSAH, getDailyHours } from "../sah/calculator.js";
|
||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||
|
||||
const spainRules: SpainScheduleRule = {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
};
|
||||
|
||||
describe("getDailyHours", () => {
|
||||
it("returns base hours when no schedule rules", () => {
|
||||
expect(getDailyHours(new Date("2026-03-10"), 8)).toBe(8); // Monday
|
||||
expect(getDailyHours(new Date("2026-03-13"), 9)).toBe(9); // Thursday
|
||||
});
|
||||
|
||||
it("returns Spain friday hours", () => {
|
||||
// 2026-03-13 is a Friday
|
||||
expect(getDailyHours(new Date("2026-03-13"), 8, spainRules)).toBe(6.5);
|
||||
});
|
||||
|
||||
it("returns Spain summer hours Mon-Thu", () => {
|
||||
// 2026-07-06 is a Monday in summer
|
||||
expect(getDailyHours(new Date("2026-07-06"), 8, spainRules)).toBe(6.5);
|
||||
});
|
||||
|
||||
it("returns Spain regular hours Mon-Thu outside summer", () => {
|
||||
// 2026-03-09 is a Monday, not summer
|
||||
expect(getDailyHours(new Date("2026-03-09"), 8, spainRules)).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSAH", () => {
|
||||
it("calculates basic SAH for a full-time German employee in one week", () => {
|
||||
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — 5 working days, no holidays
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.calendarDays).toBe(5);
|
||||
expect(result.weekendDays).toBe(0);
|
||||
expect(result.grossWorkingDays).toBe(5);
|
||||
expect(result.netWorkingDays).toBe(5);
|
||||
expect(result.standardAvailableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("reduces hours by FTE factor", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 0.5,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.standardAvailableHours).toBe(20);
|
||||
expect(result.effectiveHoursPerDay).toBe(4);
|
||||
});
|
||||
|
||||
it("deducts public holidays", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: ["2026-03-10"], // Tuesday is holiday
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.publicHolidayDays).toBe(1);
|
||||
expect(result.netWorkingDays).toBe(4);
|
||||
expect(result.standardAvailableHours).toBe(32);
|
||||
});
|
||||
|
||||
it("deducts absence days", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: ["2026-03-11", "2026-03-12"], // Wed + Thu
|
||||
});
|
||||
expect(result.absenceDays).toBe(2);
|
||||
expect(result.netWorkingDays).toBe(3);
|
||||
expect(result.standardAvailableHours).toBe(24);
|
||||
});
|
||||
|
||||
it("handles weekends correctly", () => {
|
||||
// 2026-03-07 (Sat) to 2026-03-13 (Fri) — includes a full weekend
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-07"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.calendarDays).toBe(7);
|
||||
expect(result.weekendDays).toBe(2);
|
||||
expect(result.grossWorkingDays).toBe(5);
|
||||
expect(result.netWorkingDays).toBe(5);
|
||||
expect(result.standardAvailableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("uses 9h for India", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 9,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.standardAvailableHours).toBe(45);
|
||||
});
|
||||
|
||||
it("handles Spain variable schedule in summer", () => {
|
||||
// 2026-07-06 (Mon) to 2026-07-10 (Fri) — all summer, includes Friday
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-07-06"),
|
||||
periodEnd: new Date("2026-07-10"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// Mon-Fri all summer: 6.5 * 5 = 32.5
|
||||
expect(result.standardAvailableHours).toBe(32.5);
|
||||
});
|
||||
|
||||
it("handles Spain variable schedule outside summer", () => {
|
||||
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — non-summer
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// Mon-Thu: 9h each = 36, Fri: 6.5h = total 42.5
|
||||
expect(result.standardAvailableHours).toBe(42.5);
|
||||
});
|
||||
|
||||
it("combines FTE with Spain schedule", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 0.5,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// (9*4 + 6.5*1) * 0.5 = 42.5 * 0.5 = 21.25
|
||||
expect(result.standardAvailableHours).toBe(21.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateAllocation } from "../allocation/calculator.js";
|
||||
import { validateAvailability } from "../allocation/availability-validator.js";
|
||||
|
||||
// Resource available Mon–Sat (6h Saturday)
|
||||
const satAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 6,
|
||||
};
|
||||
|
||||
// Week Mon 2025-01-06 to Sat 2025-01-11
|
||||
const MON = new Date("2025-01-06");
|
||||
const SAT = new Date("2025-01-11");
|
||||
|
||||
describe("calculateAllocation — includeSaturday", () => {
|
||||
it("excludes Saturday by default when resource has saturday availability", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
// includeSaturday not set → defaults to false
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5); // Mon–Fri only
|
||||
expect(result.totalHours).toBe(40);
|
||||
});
|
||||
|
||||
it("includes Saturday when includeSaturday is true", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
includeSaturday: true,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(6); // Mon–Sat
|
||||
// Saturday capped at 6h (resource availability)
|
||||
expect(result.totalHours).toBe(46); // 5×8 + 6
|
||||
expect(result.totalCostCents).toBe(460000); // 46h × 10000 cents/h
|
||||
});
|
||||
|
||||
it("includeSaturday: false explicitly still excludes Saturday", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
includeSaturday: false,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
});
|
||||
|
||||
it("does not add Saturday if resource has no saturday availability", () => {
|
||||
const noSatAvailability = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: noSatAvailability,
|
||||
includeSaturday: true,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5); // Saturday still 0 because resource unavailable
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAvailability — includeSaturday", () => {
|
||||
it("skips Saturday conflicts when includeSaturday is false", () => {
|
||||
// Resource available only on Saturday (Mon–Fri: 0)
|
||||
const satOnlyAvail = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 8 };
|
||||
const result = validateAvailability(SAT, SAT, 8, satOnlyAvail, [], false);
|
||||
|
||||
// Saturday is excluded, so no conflicts (day treated as non-working)
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.totalConflictDays).toBe(0);
|
||||
});
|
||||
|
||||
it("detects conflict on Saturday when includeSaturday is true", () => {
|
||||
const result = validateAvailability(
|
||||
SAT,
|
||||
SAT,
|
||||
8, // request 8h
|
||||
satAvailability, // Saturday: 6h available
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
// 8h requested > 6h available → conflict
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.totalConflictDays).toBe(1);
|
||||
expect(result.conflicts[0]?.overageHours).toBe(2);
|
||||
});
|
||||
|
||||
it("no conflict on Saturday within availability when includeSaturday true", () => {
|
||||
const result = validateAvailability(SAT, SAT, 4, satAvailability, [], true);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isVacationDay, getVacationDatesInRange } from "../vacation/utils.js";
|
||||
import type { VacationRange } from "../vacation/utils.js";
|
||||
|
||||
const approved = (start: string, end: string): VacationRange => ({
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
status: "APPROVED",
|
||||
});
|
||||
|
||||
const pending = (start: string, end: string): VacationRange => ({
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
describe("isVacationDay", () => {
|
||||
it("returns true when date falls within an approved vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the start date of the vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-10"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the end date of the vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-14"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when date is outside vacation range", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-09"), vacations)).toBe(false);
|
||||
expect(isVacationDay(new Date("2026-03-15"), vacations)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-APPROVED vacations", () => {
|
||||
const vacations = [pending("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty vacation array", () => {
|
||||
expect(isVacationDay(new Date("2026-03-12"), [])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles multiple overlapping vacations", () => {
|
||||
const vacations = [
|
||||
approved("2026-03-10", "2026-03-12"),
|
||||
approved("2026-03-11", "2026-03-14"),
|
||||
];
|
||||
expect(isVacationDay(new Date("2026-03-11"), vacations)).toBe(true);
|
||||
expect(isVacationDay(new Date("2026-03-13"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles mixed statuses correctly", () => {
|
||||
const vacations = [
|
||||
pending("2026-03-10", "2026-03-14"),
|
||||
approved("2026-03-20", "2026-03-21"),
|
||||
];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
|
||||
expect(isVacationDay(new Date("2026-03-20"), vacations)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVacationDatesInRange", () => {
|
||||
it("returns approved vacation dates within range", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-12")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-13"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(3); // 10, 11, 12
|
||||
expect(dates[0]!.getDate()).toBe(10);
|
||||
expect(dates[2]!.getDate()).toBe(12);
|
||||
});
|
||||
|
||||
it("clips to the query range", () => {
|
||||
const vacations = [approved("2026-03-05", "2026-03-15")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-10"),
|
||||
new Date("2026-03-12"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(3); // 10, 11, 12
|
||||
});
|
||||
|
||||
it("returns empty for no approved vacations", () => {
|
||||
const vacations = [pending("2026-03-10", "2026-03-14")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiple non-overlapping vacations", () => {
|
||||
const vacations = [
|
||||
approved("2026-03-10", "2026-03-11"),
|
||||
approved("2026-03-20", "2026-03-21"),
|
||||
];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(4); // 10, 11, 20, 21
|
||||
});
|
||||
|
||||
it("returns empty when range does not intersect vacations", () => {
|
||||
const vacations = [approved("2026-04-01", "2026-04-05")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
} from "../estimate/index.js";
|
||||
|
||||
function makeLine(overrides: Partial<VersionCompareInput["demandLines"][number]> & { id: string; name: string }) {
|
||||
return {
|
||||
hours: 0,
|
||||
costRateCents: 0,
|
||||
billRateCents: 0,
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
lineType: "ROLE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeVersion(overrides: Partial<VersionCompareInput> = {}): VersionCompareInput {
|
||||
return {
|
||||
versionNumber: 1,
|
||||
demandLines: [],
|
||||
assumptions: [],
|
||||
scopeItems: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compareEstimateVersions", () => {
|
||||
it("returns zeroed diff for identical versions", () => {
|
||||
const lines = [
|
||||
makeLine({ id: "l1", name: "Designer", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 }),
|
||||
];
|
||||
const assumptions = [{ key: "rate", label: "Day rate", value: 500 }];
|
||||
const scopeItems = [{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }];
|
||||
|
||||
const v = makeVersion({ demandLines: lines, assumptions, scopeItems });
|
||||
const diff = compareEstimateVersions(v, v);
|
||||
|
||||
expect(diff.summary.totalHoursDelta).toBe(0);
|
||||
expect(diff.summary.totalCostDelta).toBe(0);
|
||||
expect(diff.summary.totalPriceDelta).toBe(0);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.linesChanged).toBe(0);
|
||||
expect(diff.summary.assumptionsChanged).toBe(0);
|
||||
expect(diff.summary.scopeItemsAdded).toBe(0);
|
||||
expect(diff.summary.scopeItemsRemoved).toBe(0);
|
||||
|
||||
expect(diff.demandLineDiffs).toHaveLength(1);
|
||||
expect(diff.demandLineDiffs[0]!.status).toBe("unchanged");
|
||||
|
||||
expect(diff.assumptionDiffs).toHaveLength(1);
|
||||
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("detects added demand lines", () => {
|
||||
const a = makeVersion({ demandLines: [] });
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "Animator", hours: 40, costTotalCents: 200_00, priceTotalCents: 320_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(1);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.totalHoursDelta).toBe(40);
|
||||
expect(diff.summary.totalCostDelta).toBe(200_00);
|
||||
expect(diff.summary.totalPriceDelta).toBe(320_00);
|
||||
|
||||
expect(diff.demandLineDiffs).toHaveLength(1);
|
||||
expect(diff.demandLineDiffs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Animator",
|
||||
status: "added",
|
||||
hoursDelta: 40,
|
||||
costDelta: 200_00,
|
||||
priceDelta: 320_00,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects removed demand lines", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "Lighter", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({ demandLines: [] });
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(1);
|
||||
expect(diff.summary.totalHoursDelta).toBe(-20);
|
||||
|
||||
expect(diff.demandLineDiffs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Lighter",
|
||||
status: "removed",
|
||||
hoursDelta: -20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects changed hours and rates", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "TD", hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "TD", hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesChanged).toBe(1);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.totalHoursDelta).toBe(30);
|
||||
expect(diff.summary.totalCostDelta).toBe(180_00);
|
||||
expect(diff.summary.totalPriceDelta).toBe(300_00);
|
||||
|
||||
const lineDiff = diff.demandLineDiffs[0]!;
|
||||
expect(lineDiff.status).toBe("changed");
|
||||
expect(lineDiff.hoursDelta).toBe(30);
|
||||
expect(lineDiff.costDelta).toBe(180_00);
|
||||
expect(lineDiff.priceDelta).toBe(300_00);
|
||||
expect(lineDiff.a).toEqual({ hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 });
|
||||
expect(lineDiff.b).toEqual({ hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 });
|
||||
});
|
||||
|
||||
it("fuzzy-matches demand lines by name+lineType when ids differ", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "old-id", name: "Compositor", lineType: "ROLE", hours: 10, costTotalCents: 50_00, priceTotalCents: 80_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "new-id", name: "Compositor", lineType: "ROLE", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.linesChanged).toBe(1);
|
||||
expect(diff.demandLineDiffs[0]!.hoursDelta).toBe(10);
|
||||
});
|
||||
|
||||
it("detects added and removed assumptions", () => {
|
||||
const a = makeVersion({
|
||||
assumptions: [
|
||||
{ key: "overhead", label: "Overhead %", value: 15 },
|
||||
{ key: "old_key", label: "Old", value: "x" },
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
assumptions: [
|
||||
{ key: "overhead", label: "Overhead %", value: 20 },
|
||||
{ key: "new_key", label: "New", value: "y" },
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.assumptionsChanged).toBe(3);
|
||||
|
||||
const overheadDiff = diff.assumptionDiffs.find((d) => d.key === "overhead")!;
|
||||
expect(overheadDiff.status).toBe("changed");
|
||||
expect(overheadDiff.aValue).toBe(15);
|
||||
expect(overheadDiff.bValue).toBe(20);
|
||||
|
||||
expect(diff.assumptionDiffs.find((d) => d.key === "old_key")!.status).toBe("removed");
|
||||
expect(diff.assumptionDiffs.find((d) => d.key === "new_key")!.status).toBe("added");
|
||||
});
|
||||
|
||||
it("detects scope item additions and removals", () => {
|
||||
const a = makeVersion({
|
||||
scopeItems: [
|
||||
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
|
||||
{ name: "Asset X", sequenceNo: 2, scopeType: "ASSET" },
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
scopeItems: [
|
||||
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
|
||||
{ name: "Shot B", sequenceNo: 2, scopeType: "SHOT" },
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.scopeItemsAdded).toBe(1);
|
||||
expect(diff.summary.scopeItemsRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it("handles empty versions", () => {
|
||||
const diff = compareEstimateVersions(makeVersion(), makeVersion());
|
||||
|
||||
expect(diff.summary.totalHoursDelta).toBe(0);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.demandLineDiffs).toHaveLength(0);
|
||||
expect(diff.assumptionDiffs).toHaveLength(0);
|
||||
expect(diff.scopeItemDiffs).toHaveLength(0);
|
||||
expect(diff.resourceSnapshotDiffs).toHaveLength(0);
|
||||
expect(diff.chapterSubtotals).toHaveLength(0);
|
||||
expect(diff.summary.marginPercentA).toBe(0);
|
||||
expect(diff.summary.marginPercentB).toBe(0);
|
||||
});
|
||||
|
||||
it("compares complex assumption values by deep equality", () => {
|
||||
const a = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
|
||||
});
|
||||
const b = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
|
||||
|
||||
const b2 = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 30, resolution: "4K" } }],
|
||||
});
|
||||
const diff2 = compareEstimateVersions(a, b2);
|
||||
expect(diff2.assumptionDiffs[0]!.status).toBe("changed");
|
||||
});
|
||||
|
||||
describe("scope item diffs (detailed)", () => {
|
||||
it("detects changed scope item fields", () => {
|
||||
const a = makeVersion({
|
||||
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 120 }],
|
||||
});
|
||||
const b = makeVersion({
|
||||
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 200 }],
|
||||
});
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
expect(diff.scopeItemDiffs).toHaveLength(1);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("frameCount");
|
||||
expect(diff.summary.scopeItemsChanged).toBe(1);
|
||||
});
|
||||
|
||||
it("detects description changes in scope items", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "Old" }] }),
|
||||
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "New" }] }),
|
||||
);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("description");
|
||||
});
|
||||
|
||||
it("marks unchanged scope items", () => {
|
||||
const scope = { name: "Asset Hero", sequenceNo: 1, scopeType: "ASSET", frameCount: 50 };
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({ scopeItems: [scope] }),
|
||||
makeVersion({ scopeItems: [scope] }),
|
||||
);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resource snapshot diffs", () => {
|
||||
it("detects added resources", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion(),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ displayName: "John", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs).toHaveLength(1);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("added");
|
||||
expect(diff.summary.resourceSnapshotsChanged).toBe(1);
|
||||
});
|
||||
|
||||
it("detects rate changes", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5500, ucrCents: 8500 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.resourceSnapshotDiffs[0]!.lcrDelta).toBe(500);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.ucrDelta).toBe(500);
|
||||
});
|
||||
|
||||
it("detects removed resources", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ displayName: "Max", currency: "EUR", lcrCents: 4000, ucrCents: 7000 }],
|
||||
}),
|
||||
makeVersion(),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("removed");
|
||||
});
|
||||
|
||||
it("matches by resourceId for unchanged rates", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Old Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "New Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("detects location changes", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Munich" }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Berlin" }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapter subtotals", () => {
|
||||
it("groups demand lines by chapter and computes deltas", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "Anim Lead", hours: 80, costTotalCents: 480_00, priceTotalCents: 720_00, chapter: "Animation" }),
|
||||
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
|
||||
],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "Anim Lead", hours: 100, costTotalCents: 600_00, priceTotalCents: 900_00, chapter: "Animation" }),
|
||||
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(diff.chapterSubtotals).toHaveLength(2);
|
||||
const anim = diff.chapterSubtotals.find((c) => c.chapter === "Animation")!;
|
||||
expect(anim.hoursDelta).toBe(20);
|
||||
expect(anim.costDelta).toBe(120_00);
|
||||
});
|
||||
|
||||
it("uses (no chapter) fallback", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "Misc", hours: 10, costTotalCents: 30_00, priceTotalCents: 50_00 })],
|
||||
}),
|
||||
makeVersion(),
|
||||
);
|
||||
expect(diff.chapterSubtotals[0]!.chapter).toBe("(no chapter)");
|
||||
});
|
||||
|
||||
it("sorts by absolute cost delta descending", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "A", hours: 10, costTotalCents: 100_00, priceTotalCents: 200_00, chapter: "Small" }),
|
||||
makeLine({ id: "d2", name: "B", hours: 100, costTotalCents: 5000_00, priceTotalCents: 8000_00, chapter: "Big" }),
|
||||
],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "A", hours: 12, costTotalCents: 120_00, priceTotalCents: 240_00, chapter: "Small" }),
|
||||
makeLine({ id: "d2", name: "B", hours: 200, costTotalCents: 10000_00, priceTotalCents: 16000_00, chapter: "Big" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(diff.chapterSubtotals[0]!.chapter).toBe("Big");
|
||||
});
|
||||
});
|
||||
|
||||
describe("margin calculation", () => {
|
||||
it("computes margin percent and delta", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 })],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 1000_00 })],
|
||||
}),
|
||||
);
|
||||
// A: (80000-50000)/80000 = 37.5%
|
||||
expect(diff.summary.marginPercentA).toBeCloseTo(37.5, 1);
|
||||
// B: (100000-50000)/100000 = 50%
|
||||
expect(diff.summary.marginPercentB).toBeCloseTo(50, 1);
|
||||
expect(diff.summary.marginPercentDelta).toBeCloseTo(12.5, 1);
|
||||
});
|
||||
|
||||
it("handles zero price gracefully", () => {
|
||||
const diff = compareEstimateVersions(makeVersion(), makeVersion());
|
||||
expect(diff.summary.marginPercentA).toBe(0);
|
||||
expect(diff.summary.marginPercentB).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
generateWeekRange,
|
||||
distributeHoursToWeeks,
|
||||
weekKeyFromDate,
|
||||
aggregateWeeklyToMonthly,
|
||||
aggregateWeeklyByChapter,
|
||||
} from "../estimate/weekly-phasing.js";
|
||||
|
||||
describe("generateWeekRange", () => {
|
||||
it("returns a single week for a range within one week", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-03-20"); // Mon-Fri of W12
|
||||
expect(weeks).toHaveLength(1);
|
||||
expect(weeks[0]!.weekNumber).toBe(12);
|
||||
expect(weeks[0]!.year).toBe(2026);
|
||||
expect(weeks[0]!.label).toBe("W12 2026");
|
||||
});
|
||||
|
||||
it("returns multiple weeks for a multi-week range", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-04-10");
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(3);
|
||||
// Should cover W12 through at least W15
|
||||
const weekNumbers = weeks.map((w) => w.weekNumber);
|
||||
expect(weekNumbers).toContain(12);
|
||||
expect(weekNumbers).toContain(13);
|
||||
expect(weekNumbers).toContain(14);
|
||||
});
|
||||
|
||||
it("handles month boundary crossing", () => {
|
||||
const weeks = generateWeekRange("2026-03-28", "2026-04-05");
|
||||
// This spans end of March into April
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(1);
|
||||
// Each week has proper start/end dates
|
||||
for (const week of weeks) {
|
||||
expect(week.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(week.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles year boundary crossing", () => {
|
||||
const weeks = generateWeekRange("2025-12-22", "2026-01-11");
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(2);
|
||||
// Should include weeks in both years (ISO week year may differ from calendar year)
|
||||
const labels = weeks.map((w) => w.label);
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns correct ISO week numbers and labels", () => {
|
||||
const weeks = generateWeekRange("2026-01-05", "2026-01-18");
|
||||
for (const week of weeks) {
|
||||
expect(week.label).toMatch(/^W\d{2} \d{4}$/);
|
||||
expect(week.weekNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(week.weekNumber).toBeLessThanOrEqual(53);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array when endDate < startDate", () => {
|
||||
const weeks = generateWeekRange("2026-06-01", "2026-03-01");
|
||||
expect(weeks).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns week definitions with Monday start and Sunday end", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-03-22");
|
||||
const week = weeks[0]!;
|
||||
const start = new Date(week.startDate);
|
||||
const end = new Date(week.endDate);
|
||||
expect(start.getUTCDay()).toBe(1); // Monday
|
||||
expect(end.getUTCDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeHoursToWeeks", () => {
|
||||
it("distributes hours evenly across weeks", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-10",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks.length).toBeGreaterThan(0);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
// All weeks should have roughly equal hours
|
||||
const values = Object.values(result.weeklyHours);
|
||||
const avg = 100 / values.length;
|
||||
for (const h of values) {
|
||||
expect(h).toBeCloseTo(avg, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("distributes 100h over 4 weeks = 25h each", () => {
|
||||
// Find a 4-week range
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-12",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(4);
|
||||
expect(result.totalDistributedHours).toBe(100);
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(25);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles even distribution with remainder", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(3);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("front loaded: first half weeks have more hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-05-10",
|
||||
pattern: "front_loaded",
|
||||
});
|
||||
|
||||
const values = Object.values(result.weeklyHours);
|
||||
expect(values.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// First value should be greater than last value
|
||||
expect(values[0]!).toBeGreaterThan(values[values.length - 1]!);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("back loaded: last half weeks have more hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-05-10",
|
||||
pattern: "back_loaded",
|
||||
});
|
||||
|
||||
const values = Object.values(result.weeklyHours);
|
||||
expect(values.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Last value should be greater than first value
|
||||
expect(values[values.length - 1]!).toBeGreaterThan(values[0]!);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("custom: uses provided hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "custom",
|
||||
customWeeklyHours: {
|
||||
"2026-W12": 40,
|
||||
"2026-W13": 35,
|
||||
"2026-W14": 25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.weeklyHours["2026-W12"]).toBe(40);
|
||||
expect(result.weeklyHours["2026-W13"]).toBe(35);
|
||||
expect(result.weeklyHours["2026-W14"]).toBe(25);
|
||||
});
|
||||
|
||||
it("returns all zeros for zero hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 0,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(0);
|
||||
}
|
||||
expect(result.totalDistributedHours).toBe(0);
|
||||
});
|
||||
|
||||
it("handles single week range", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 40,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-03-20",
|
||||
pattern: "front_loaded",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(1);
|
||||
expect(result.totalDistributedHours).toBe(40);
|
||||
});
|
||||
|
||||
it("returns empty result for empty range", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-03-01",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(0);
|
||||
expect(result.totalDistributedHours).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to even pattern when not specified", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 80,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-12",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(4);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(80, 1);
|
||||
// Should be even
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekKeyFromDate", () => {
|
||||
it("returns correct week key for a normal date", () => {
|
||||
// March 18, 2026 is a Wednesday in W12
|
||||
expect(weekKeyFromDate("2026-03-18")).toBe("2026-W12");
|
||||
});
|
||||
|
||||
it("handles Date object input", () => {
|
||||
const date = new Date("2026-03-18");
|
||||
expect(weekKeyFromDate(date)).toBe("2026-W12");
|
||||
});
|
||||
|
||||
it("handles year boundary (Dec 31 may be W01 of next year)", () => {
|
||||
// Dec 31, 2026 is a Thursday. Let's check ISO week.
|
||||
const key = weekKeyFromDate("2026-12-31");
|
||||
// ISO week 53 or W01 of 2027 depending on the year
|
||||
expect(key).toMatch(/^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
it("handles Jan 1 which may be in previous year's last week", () => {
|
||||
// Jan 1, 2027 is a Friday
|
||||
const key = weekKeyFromDate("2027-01-01");
|
||||
expect(key).toMatch(/^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
it("returns W01 for the first Monday of a standard year", () => {
|
||||
// In 2026, Jan 5 is a Monday, should be W02 (Jan 1 is Thursday, W01 starts Dec 29)
|
||||
// Actually let's verify: Jan 1 2026 is Thursday → W01 starts Mon Dec 29 2025
|
||||
// Jan 5 2026 is Monday → W02
|
||||
expect(weekKeyFromDate("2026-01-05")).toBe("2026-W02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWeeklyToMonthly", () => {
|
||||
it("converts week keys to month keys and sums hours", () => {
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W12": 40,
|
||||
"2026-W13": 40,
|
||||
"2026-W14": 40,
|
||||
"2026-W15": 40,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
// All these weeks are in March-April 2026
|
||||
const totalMonthly = Object.values(monthly).reduce((sum, h) => sum + h, 0);
|
||||
expect(totalMonthly).toBeCloseTo(160, 1);
|
||||
});
|
||||
|
||||
it("sums hours within the same month", () => {
|
||||
// W10 and W11 of 2026 are both in March
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W10": 20,
|
||||
"2026-W11": 30,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
// Both weeks' Thursdays fall in March
|
||||
expect(monthly["2026-03"]).toBe(50);
|
||||
});
|
||||
|
||||
it("handles cross-month weeks correctly", () => {
|
||||
// W14 of 2026: Thursday April 2 → April
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W13": 25,
|
||||
"2026-W14": 25,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
const total = Object.values(monthly).reduce((sum, h) => sum + h, 0);
|
||||
expect(total).toBe(50);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(aggregateWeeklyToMonthly({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWeeklyByChapter", () => {
|
||||
it("groups lines by chapter and sums hours per week", () => {
|
||||
const lines = [
|
||||
{ chapter: "Animation", weeklyHours: { "2026-W12": 20, "2026-W13": 15 } },
|
||||
{ chapter: "Animation", weeklyHours: { "2026-W12": 10, "2026-W13": 5 } },
|
||||
{ chapter: "Lighting", weeklyHours: { "2026-W12": 8, "2026-W13": 12 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["Animation"]!["2026-W12"]).toBe(30);
|
||||
expect(result["Animation"]!["2026-W13"]).toBe(20);
|
||||
expect(result["Lighting"]!["2026-W12"]).toBe(8);
|
||||
expect(result["Lighting"]!["2026-W13"]).toBe(12);
|
||||
});
|
||||
|
||||
it("handles null chapter as (Unassigned)", () => {
|
||||
const lines = [
|
||||
{ chapter: null, weeklyHours: { "2026-W12": 10 } },
|
||||
{ weeklyHours: { "2026-W12": 5 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["(Unassigned)"]!["2026-W12"]).toBe(15);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(aggregateWeeklyByChapter([])).toEqual({});
|
||||
});
|
||||
|
||||
it("handles lines with no overlapping weeks", () => {
|
||||
const lines = [
|
||||
{ chapter: "FX", weeklyHours: { "2026-W12": 10 } },
|
||||
{ chapter: "FX", weeklyHours: { "2026-W15": 20 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["FX"]!["2026-W12"]).toBe(10);
|
||||
expect(result["FX"]!["2026-W15"]).toBe(20);
|
||||
expect(result["FX"]!["2026-W13"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Allocation, WeekdayAvailability } from "@planarchy/shared";
|
||||
import { getAvailableHoursForDate } from "./calculator.js";
|
||||
|
||||
export interface AvailabilityConflict {
|
||||
date: Date;
|
||||
requestedHours: number;
|
||||
availableHours: number;
|
||||
existingHours: number;
|
||||
overageHours: number;
|
||||
}
|
||||
|
||||
export interface AvailabilityValidationResult {
|
||||
valid: boolean;
|
||||
conflicts: AvailabilityConflict[];
|
||||
totalConflictDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a new allocation does not exceed resource availability,
|
||||
* accounting for existing allocations in the same period.
|
||||
*
|
||||
* Pure function — no DB access. All data passed as parameters.
|
||||
*/
|
||||
export function validateAvailability(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
requestedHoursPerDay: number,
|
||||
availability: WeekdayAvailability,
|
||||
existingAllocations: Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
|
||||
includeSaturday?: boolean,
|
||||
): AvailabilityValidationResult {
|
||||
const effectiveAvailability: WeekdayAvailability = includeSaturday
|
||||
? availability
|
||||
: { ...availability, saturday: 0 };
|
||||
const conflicts: AvailabilityConflict[] = [];
|
||||
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
|
||||
while (current <= end) {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
if (availableHours === 0) {
|
||||
// Non-working day — skip
|
||||
current.setDate(current.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sum hours from existing (non-cancelled) allocations on this day
|
||||
const existingHoursOnDay = existingAllocations
|
||||
.filter((a) => {
|
||||
if (!activeStatuses.has(a.status)) return false;
|
||||
const aStart = new Date(a.startDate);
|
||||
aStart.setHours(0, 0, 0, 0);
|
||||
const aEnd = new Date(a.endDate);
|
||||
aEnd.setHours(0, 0, 0, 0);
|
||||
return current >= aStart && current <= aEnd;
|
||||
})
|
||||
.reduce((sum, a) => sum + a.hoursPerDay, 0);
|
||||
|
||||
const totalRequested = existingHoursOnDay + requestedHoursPerDay;
|
||||
|
||||
if (totalRequested > availableHours) {
|
||||
conflicts.push({
|
||||
date: new Date(current),
|
||||
requestedHours: requestedHoursPerDay,
|
||||
availableHours,
|
||||
existingHours: existingHoursOnDay,
|
||||
overageHours: totalRequested - availableHours,
|
||||
});
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: conflicts.length === 0,
|
||||
conflicts,
|
||||
totalConflictDays: conflicts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for allocation overlaps — same resource, overlapping date ranges.
|
||||
*/
|
||||
export function detectOverlaps(
|
||||
newStart: Date,
|
||||
newEnd: Date,
|
||||
existingAllocations: Pick<Allocation, "id" | "startDate" | "endDate" | "projectId" | "status">[],
|
||||
excludeProjectId?: string,
|
||||
): string[] {
|
||||
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
const overlappingIds: string[] = [];
|
||||
|
||||
for (const alloc of existingAllocations) {
|
||||
if (!activeStatuses.has(alloc.status)) continue;
|
||||
if (excludeProjectId && alloc.projectId === excludeProjectId) continue;
|
||||
|
||||
const aStart = new Date(alloc.startDate);
|
||||
const aEnd = new Date(alloc.endDate);
|
||||
|
||||
// Ranges overlap if: newStart <= aEnd && newEnd >= aStart
|
||||
if (newStart <= aEnd && newEnd >= aStart) {
|
||||
overlappingIds.push(alloc.id);
|
||||
}
|
||||
}
|
||||
|
||||
return overlappingIds;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
AllocationCalculationInput,
|
||||
AllocationCalculationResult,
|
||||
DailyBreakdown,
|
||||
WeekdayAvailability,
|
||||
} from "@planarchy/shared";
|
||||
import { getRecurringHoursForDay } from "./recurrence.js";
|
||||
|
||||
/** Day-of-week index → availability key */
|
||||
const DOW_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the availability hours for a given date.
|
||||
* Returns 0 for days not in the availability map (treated as non-working).
|
||||
*/
|
||||
export function getAvailableHoursForDate(
|
||||
date: Date,
|
||||
availability: WeekdayAvailability,
|
||||
): number {
|
||||
const key = DOW_KEYS[date.getDay()];
|
||||
if (!key) return 0;
|
||||
return availability[key] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given date is a working day for this resource.
|
||||
*/
|
||||
export function isWorkday(date: Date, availability: WeekdayAvailability): boolean {
|
||||
return getAvailableHoursForDate(date, availability) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts working days between startDate and endDate (inclusive).
|
||||
*/
|
||||
export function countWorkingDays(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
availability: WeekdayAvailability,
|
||||
): number {
|
||||
let count = 0;
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= end) {
|
||||
if (isWorkday(current, availability)) {
|
||||
count++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core allocation calculator: given hours/day, LCR, and date range,
|
||||
* computes total hours, total cost, and daily breakdown.
|
||||
*
|
||||
* Monetary values always in integer cents.
|
||||
*/
|
||||
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
|
||||
const { lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday, recurrence, vacationDates } = input;
|
||||
|
||||
// When includeSaturday is not explicitly true, zero out saturday availability
|
||||
const effectiveAvailability: WeekdayAvailability = includeSaturday
|
||||
? availability
|
||||
: { ...availability, saturday: 0 };
|
||||
|
||||
// Pre-compute vacation date set (YYYY-MM-DD strings for O(1) lookup)
|
||||
const vacationDateSet = new Set<string>(
|
||||
(vacationDates ?? []).map((d) => {
|
||||
const copy = new Date(d);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy.toISOString().split("T")[0]!;
|
||||
}),
|
||||
);
|
||||
|
||||
const allocationStart = new Date(startDate);
|
||||
allocationStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const breakdown: DailyBreakdown[] = [];
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
let workingDays = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
while (current <= end) {
|
||||
const dateKey = current.toISOString().split("T")[0]!;
|
||||
const isVacation = vacationDateSet.has(dateKey);
|
||||
|
||||
let effectiveHours: number;
|
||||
let dayIsWorkday: boolean;
|
||||
|
||||
if (isVacation) {
|
||||
// Vacation always blocks the day
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
} else if (recurrence) {
|
||||
// Recurrence pattern — may override hoursPerDay or skip the day entirely
|
||||
const recurHours = getRecurringHoursForDay(current, recurrence, hoursPerDay, allocationStart);
|
||||
if (recurHours === 0) {
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(recurHours, availableHours) : 0;
|
||||
}
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(hoursPerDay, availableHours) : 0;
|
||||
}
|
||||
|
||||
// Cost = hours × lcrCents (already in cents-per-hour)
|
||||
const dayCostCents = Math.round(effectiveHours * lcrCents);
|
||||
|
||||
breakdown.push({
|
||||
date: new Date(current),
|
||||
isWorkday: dayIsWorkday,
|
||||
hours: effectiveHours,
|
||||
costCents: dayCostCents,
|
||||
});
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalCostCents = breakdown.reduce((sum, d) => sum + d.costCents, 0);
|
||||
const dailyCostCents = Math.round(hoursPerDay * lcrCents);
|
||||
|
||||
return {
|
||||
workingDays,
|
||||
totalHours,
|
||||
totalCostCents,
|
||||
dailyCostCents,
|
||||
dailyBreakdown: breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total allocation cost for a simple case (without full breakdown).
|
||||
* Useful for quick budget checks.
|
||||
*/
|
||||
export function calculateTotalCost(
|
||||
lcrCents: number,
|
||||
hoursPerDay: number,
|
||||
workingDays: number,
|
||||
): number {
|
||||
return Math.round(lcrCents * hoursPerDay * workingDays);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
|
||||
export interface ChargeabilityAllocation {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
}
|
||||
|
||||
export interface ChargeabilityResult {
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
chargeability: number; // 0-100, rounded
|
||||
}
|
||||
|
||||
// Maps JS getDay() (0=Sun..6=Sat) to WeekdayAvailability keys
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
|
||||
];
|
||||
|
||||
/** Count working hours a resource has available in [start, end] based on their schedule. */
|
||||
export function computeAvailableHours(
|
||||
availability: WeekdayAvailability,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): number {
|
||||
let hours = 0;
|
||||
const cur = new Date(start);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(end);
|
||||
endNorm.setHours(0, 0, 0, 0);
|
||||
while (cur <= endNorm) {
|
||||
const key = DAY_KEYS[cur.getDay()];
|
||||
hours += key ? (availability[key] ?? 0) : 0;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
/** Count booked hours from allocations overlapping [start, end], working days only. */
|
||||
export function computeBookedHours(
|
||||
availability: WeekdayAvailability,
|
||||
allocations: ChargeabilityAllocation[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): number {
|
||||
let hours = 0;
|
||||
const startNorm = new Date(start); startNorm.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(end); endNorm.setHours(0, 0, 0, 0);
|
||||
for (const alloc of allocations) {
|
||||
const aStart = new Date(alloc.startDate); aStart.setHours(0, 0, 0, 0);
|
||||
const aEnd = new Date(alloc.endDate); aEnd.setHours(0, 0, 0, 0);
|
||||
const overlapStart = aStart > startNorm ? aStart : startNorm;
|
||||
const overlapEnd = aEnd < endNorm ? aEnd : endNorm;
|
||||
if (overlapStart > overlapEnd) continue;
|
||||
const cur = new Date(overlapStart);
|
||||
while (cur <= overlapEnd) {
|
||||
const key = DAY_KEYS[cur.getDay()];
|
||||
if (key && (availability[key] ?? 0) > 0) {
|
||||
hours += alloc.hoursPerDay;
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
/** Compute chargeability metrics for a resource over a date range. */
|
||||
export function computeChargeability(
|
||||
availability: WeekdayAvailability,
|
||||
allocations: ChargeabilityAllocation[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): ChargeabilityResult {
|
||||
const availableHours = computeAvailableHours(availability, start, end);
|
||||
const bookedHours = computeBookedHours(availability, allocations, start, end);
|
||||
const chargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((bookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
return { availableHours, bookedHours, chargeability };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./calculator.js";
|
||||
export * from "./availability-validator.js";
|
||||
export * from "./recurrence.js";
|
||||
export * from "./chargeability.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
|
||||
/**
|
||||
* Returns the ISO week number of a date relative to a base date.
|
||||
* Used for biweekly parity checks.
|
||||
*/
|
||||
function weeksSince(base: Date, date: Date): number {
|
||||
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
const baseMonday = new Date(base);
|
||||
baseMonday.setHours(0, 0, 0, 0);
|
||||
// Normalize to start of the week containing base
|
||||
const baseDow = baseMonday.getDay();
|
||||
baseMonday.setDate(baseMonday.getDate() - baseDow);
|
||||
|
||||
const targetMonday = new Date(date);
|
||||
targetMonday.setHours(0, 0, 0, 0);
|
||||
const targetDow = targetMonday.getDay();
|
||||
targetMonday.setDate(targetMonday.getDate() - targetDow);
|
||||
|
||||
return Math.round((targetMonday.getTime() - baseMonday.getTime()) / msPerWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a given date falls on a "recurring day" according to the pattern.
|
||||
*
|
||||
* @param date The calendar date to check
|
||||
* @param pattern Recurrence pattern from allocation metadata
|
||||
* @param allocationStartDate The allocation's own start date (used for biweekly parity)
|
||||
*/
|
||||
export function isRecurringDay(
|
||||
date: Date,
|
||||
pattern: RecurrencePattern,
|
||||
allocationStartDate: Date,
|
||||
): boolean {
|
||||
const dow = date.getDay(); // 0=Sun, 1=Mon … 6=Sat
|
||||
|
||||
switch (pattern.frequency) {
|
||||
case RecurrenceFrequency.WEEKLY: {
|
||||
const weekdays = pattern.weekdays ?? [];
|
||||
return weekdays.includes(dow);
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.BIWEEKLY: {
|
||||
const weekdays = pattern.weekdays ?? [];
|
||||
if (!weekdays.includes(dow)) return false;
|
||||
// Check week parity: week 0 (same as allocationStart) = active,
|
||||
// odd weeks = skip, even weeks = active.
|
||||
const interval = pattern.interval ?? 2;
|
||||
const weeks = weeksSince(allocationStartDate, date);
|
||||
return weeks % interval === 0;
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.MONTHLY: {
|
||||
const monthDay = pattern.monthDay;
|
||||
if (monthDay == null) return false;
|
||||
return date.getDate() === monthDay;
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.CUSTOM:
|
||||
// CUSTOM means "always active, but with custom hoursPerDay"
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective hours for a day based on the recurrence pattern.
|
||||
* Returns 0 when the day is not a recurring day.
|
||||
*
|
||||
* @param date The calendar date
|
||||
* @param pattern Recurrence pattern from allocation metadata
|
||||
* @param defaultHoursPerDay The allocation's base hoursPerDay
|
||||
* @param allocationStartDate The allocation's own start date
|
||||
*/
|
||||
export function getRecurringHoursForDay(
|
||||
date: Date,
|
||||
pattern: RecurrencePattern,
|
||||
defaultHoursPerDay: number,
|
||||
allocationStartDate: Date,
|
||||
): number {
|
||||
// Respect optional start/end overrides on the pattern itself
|
||||
if (pattern.startDate) {
|
||||
const ps = new Date(pattern.startDate);
|
||||
ps.setHours(0, 0, 0, 0);
|
||||
if (date < ps) return 0;
|
||||
}
|
||||
if (pattern.endDate) {
|
||||
const pe = new Date(pattern.endDate);
|
||||
pe.setHours(0, 0, 0, 0);
|
||||
if (date > pe) return 0;
|
||||
}
|
||||
|
||||
if (!isRecurringDay(date, pattern, allocationStartDate)) return 0;
|
||||
return pattern.hoursPerDay ?? defaultHoursPerDay;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { FieldType, type BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
|
||||
export interface CustomFieldValidationError {
|
||||
key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a `dynamicFields` record against an array of BlueprintFieldDefinitions.
|
||||
* Returns an array of errors (empty = valid).
|
||||
*/
|
||||
export function validateCustomFields(
|
||||
fieldDefs: BlueprintFieldDefinition[],
|
||||
dynamicFields: Record<string, unknown>,
|
||||
): CustomFieldValidationError[] {
|
||||
const errors: CustomFieldValidationError[] = [];
|
||||
|
||||
for (const def of fieldDefs) {
|
||||
const value = dynamicFields[def.key];
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
|
||||
if (def.required && isEmpty) {
|
||||
errors.push({ key: def.key, message: `${def.label} is required` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEmpty) continue;
|
||||
|
||||
switch (def.type) {
|
||||
case FieldType.NUMBER: {
|
||||
if (typeof value !== "number" && isNaN(Number(value))) {
|
||||
errors.push({ key: def.key, message: `${def.label} must be a number` });
|
||||
}
|
||||
const validation = def.validation;
|
||||
if (validation) {
|
||||
const num = Number(value);
|
||||
if (validation.min !== undefined && num < validation.min) {
|
||||
errors.push({ key: def.key, message: `${def.label} must be at least ${validation.min}` });
|
||||
}
|
||||
if (validation.max !== undefined && num > validation.max) {
|
||||
errors.push({ key: def.key, message: `${def.label} must be at most ${validation.max}` });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case FieldType.BOOLEAN:
|
||||
if (value !== true && value !== false && value !== "true" && value !== "false") {
|
||||
errors.push({ key: def.key, message: `${def.label} must be a boolean` });
|
||||
}
|
||||
break;
|
||||
|
||||
case FieldType.SELECT:
|
||||
if (def.options && def.options.length > 0) {
|
||||
const valid = def.options.some((o) => o.value === value);
|
||||
if (!valid) {
|
||||
const allowed = def.options.map((o) => o.label || o.value).join(", ");
|
||||
errors.push({ key: def.key, message: `${def.label} must be one of: ${allowed}` });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case FieldType.MULTI_SELECT:
|
||||
if (Array.isArray(value) && def.options && def.options.length > 0) {
|
||||
const validSet = new Set(def.options.map((o) => o.value));
|
||||
const invalid = (value as string[]).filter((v) => !validSet.has(v));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({ key: def.key, message: `${def.label} contains invalid values: ${invalid.join(", ")}` });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case FieldType.URL:
|
||||
try {
|
||||
new URL(String(value));
|
||||
} catch {
|
||||
errors.push({ key: def.key, message: `${def.label} must be a valid URL` });
|
||||
}
|
||||
break;
|
||||
|
||||
case FieldType.EMAIL:
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
|
||||
errors.push({ key: def.key, message: `${def.label} must be a valid email address` });
|
||||
}
|
||||
break;
|
||||
|
||||
// TEXT, TEXTAREA, DATE — no structural validation beyond required
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./monitor.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Allocation, BudgetStatus, BudgetWarning } from "@planarchy/shared";
|
||||
import { BUDGET_WARNING_THRESHOLDS } from "@planarchy/shared";
|
||||
|
||||
/**
|
||||
* Computes budget status for a project given its allocations.
|
||||
* Pure function — all data passed as parameters.
|
||||
*/
|
||||
export function computeBudgetStatus(
|
||||
budgetCents: number,
|
||||
winProbability: number,
|
||||
allocations: Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
|
||||
projectStartDate: Date,
|
||||
projectEndDate: Date,
|
||||
): BudgetStatus {
|
||||
const activeStatuses = new Set(["CONFIRMED", "ACTIVE"]);
|
||||
const proposedStatuses = new Set(["PROPOSED"]);
|
||||
|
||||
let confirmedCents = 0;
|
||||
let proposedCents = 0;
|
||||
|
||||
for (const alloc of allocations) {
|
||||
const days = countWorkingDaysInRange(
|
||||
new Date(alloc.startDate),
|
||||
new Date(alloc.endDate),
|
||||
);
|
||||
const totalCents = alloc.dailyCostCents * days;
|
||||
|
||||
if (activeStatuses.has(alloc.status)) {
|
||||
confirmedCents += totalCents;
|
||||
} else if (proposedStatuses.has(alloc.status)) {
|
||||
proposedCents += totalCents;
|
||||
}
|
||||
}
|
||||
|
||||
const allocatedCents = confirmedCents + proposedCents;
|
||||
const remainingCents = budgetCents - allocatedCents;
|
||||
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
|
||||
const winProbabilityWeightedCents = Math.round(allocatedCents * (winProbability / 100));
|
||||
|
||||
const warnings: BudgetWarning[] = [];
|
||||
|
||||
if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.CRITICAL) {
|
||||
warnings.push({
|
||||
level: "critical",
|
||||
code: "BUDGET_CRITICAL",
|
||||
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — critical threshold exceeded`,
|
||||
thresholdPercent: BUDGET_WARNING_THRESHOLDS.CRITICAL,
|
||||
currentPercent: utilizationPercent,
|
||||
});
|
||||
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.WARNING) {
|
||||
warnings.push({
|
||||
level: "warning",
|
||||
code: "BUDGET_WARNING",
|
||||
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — approaching limit`,
|
||||
thresholdPercent: BUDGET_WARNING_THRESHOLDS.WARNING,
|
||||
currentPercent: utilizationPercent,
|
||||
});
|
||||
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.INFO) {
|
||||
warnings.push({
|
||||
level: "info",
|
||||
code: "BUDGET_INFO",
|
||||
message: `Budget utilization at ${utilizationPercent.toFixed(1)}%`,
|
||||
thresholdPercent: BUDGET_WARNING_THRESHOLDS.INFO,
|
||||
currentPercent: utilizationPercent,
|
||||
});
|
||||
}
|
||||
|
||||
if (allocatedCents > budgetCents) {
|
||||
warnings.push({
|
||||
level: "critical",
|
||||
code: "BUDGET_EXCEEDED",
|
||||
message: `Budget exceeded by ${((allocatedCents - budgetCents) / 100).toFixed(2)} EUR`,
|
||||
thresholdPercent: 100,
|
||||
currentPercent: utilizationPercent,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
budgetCents,
|
||||
allocatedCents,
|
||||
confirmedCents,
|
||||
proposedCents,
|
||||
remainingCents,
|
||||
utilizationPercent,
|
||||
winProbabilityWeightedCents,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** Simple working day counter (Mon-Fri) for budget calculations */
|
||||
function countWorkingDaysInRange(startDate: Date, endDate: Date): number {
|
||||
let count = 0;
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= end) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
count++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Pure scope-to-effort expansion engine.
|
||||
* Takes scope items and effort rules, produces demand line drafts.
|
||||
* No DB or IO dependencies.
|
||||
*/
|
||||
|
||||
export type EffortUnitMode = "per_frame" | "per_item" | "flat";
|
||||
|
||||
export interface EffortRuleInput {
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter?: string | null;
|
||||
unitMode: EffortUnitMode;
|
||||
hoursPerUnit: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ScopeItemInput {
|
||||
name: string;
|
||||
scopeType: string;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpandedDemandLine {
|
||||
scopeItemName: string;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter?: string | null;
|
||||
hours: number;
|
||||
unitMode: EffortUnitMode;
|
||||
unitCount: number;
|
||||
hoursPerUnit: number;
|
||||
}
|
||||
|
||||
export interface EffortExpansionResult {
|
||||
lines: ExpandedDemandLine[];
|
||||
warnings: string[];
|
||||
unmatchedScopeItems: string[];
|
||||
}
|
||||
|
||||
function getUnitCount(
|
||||
item: ScopeItemInput,
|
||||
unitMode: EffortUnitMode,
|
||||
): number {
|
||||
switch (unitMode) {
|
||||
case "per_frame":
|
||||
return item.frameCount ?? 1;
|
||||
case "per_item":
|
||||
return item.itemCount ?? 1;
|
||||
case "flat":
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand scope items into demand line drafts using effort rules.
|
||||
*
|
||||
* For each scope item, finds all matching rules by scopeType and generates
|
||||
* one demand line per matching rule. Hours = unitCount * hoursPerUnit.
|
||||
*
|
||||
* Rules are matched case-insensitively on scopeType.
|
||||
*/
|
||||
export function expandScopeToEffort(
|
||||
scopeItems: ScopeItemInput[],
|
||||
rules: EffortRuleInput[],
|
||||
): EffortExpansionResult {
|
||||
const lines: ExpandedDemandLine[] = [];
|
||||
const warnings: string[] = [];
|
||||
const unmatchedScopeItems: string[] = [];
|
||||
|
||||
if (rules.length === 0) {
|
||||
warnings.push("No effort rules provided.");
|
||||
return { lines, warnings, unmatchedScopeItems };
|
||||
}
|
||||
|
||||
// Index rules by normalized scopeType for fast lookup
|
||||
const rulesByScopeType = new Map<string, EffortRuleInput[]>();
|
||||
for (const rule of rules) {
|
||||
const key = rule.scopeType.toLowerCase().trim();
|
||||
const existing = rulesByScopeType.get(key) ?? [];
|
||||
existing.push(rule);
|
||||
rulesByScopeType.set(key, existing);
|
||||
}
|
||||
|
||||
for (const item of scopeItems) {
|
||||
if (!item.name.trim()) continue;
|
||||
|
||||
const normalizedType = item.scopeType.toLowerCase().trim();
|
||||
const matchingRules = rulesByScopeType.get(normalizedType);
|
||||
|
||||
if (!matchingRules || matchingRules.length === 0) {
|
||||
unmatchedScopeItems.push(item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by sortOrder for deterministic output
|
||||
const sortedRules = [...matchingRules].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
const unitCount = getUnitCount(item, rule.unitMode);
|
||||
const hours = Math.round(unitCount * rule.hoursPerUnit * 100) / 100;
|
||||
|
||||
if (hours <= 0) {
|
||||
warnings.push(
|
||||
`Skipped "${rule.discipline}" for "${item.name}": computed hours is 0 (unitCount=${unitCount}, hoursPerUnit=${rule.hoursPerUnit}).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push({
|
||||
scopeItemName: item.name,
|
||||
scopeType: item.scopeType,
|
||||
discipline: rule.discipline,
|
||||
...(rule.chapter != null ? { chapter: rule.chapter } : {}),
|
||||
hours,
|
||||
unitMode: rule.unitMode,
|
||||
unitCount,
|
||||
hoursPerUnit: rule.hoursPerUnit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatchedScopeItems.length > 0) {
|
||||
warnings.push(
|
||||
`${unmatchedScopeItems.length} scope item(s) had no matching rules: ${unmatchedScopeItems.slice(0, 5).join(", ")}${unmatchedScopeItems.length > 5 ? "..." : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { lines, warnings, unmatchedScopeItems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate expanded lines by discipline, summing hours.
|
||||
* Useful for creating one demand line per discipline instead of per scope item.
|
||||
*/
|
||||
export function aggregateByDiscipline(
|
||||
lines: ExpandedDemandLine[],
|
||||
): Array<{ discipline: string; chapter?: string | null; totalHours: number; lineCount: number }> {
|
||||
const map = new Map<string, { discipline: string; chapter?: string | null; totalHours: number; lineCount: number }>();
|
||||
|
||||
for (const line of lines) {
|
||||
const key = `${line.discipline}::${line.chapter ?? ""}`;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.totalHours += line.hours;
|
||||
existing.lineCount++;
|
||||
} else {
|
||||
map.set(key, {
|
||||
discipline: line.discipline,
|
||||
...(line.chapter != null ? { chapter: line.chapter } : {}),
|
||||
totalHours: line.hours,
|
||||
lineCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => b.totalHours - a.totalHours);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Pure experience-multiplier engine.
|
||||
* Applies parametric rate adjustments (cost/bill multipliers) and
|
||||
* shoring ratios (offshore/nearshore effort factors) to demand lines.
|
||||
* No DB or IO dependencies.
|
||||
*/
|
||||
|
||||
export interface ExperienceMultiplierRule {
|
||||
chapter?: string | null;
|
||||
location?: string | null;
|
||||
level?: string | null;
|
||||
costMultiplier: number;
|
||||
billMultiplier: number;
|
||||
shoringRatio?: number | null;
|
||||
additionalEffortRatio?: number | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface RateAdjustmentInput {
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
hours: number;
|
||||
chapter?: string | null;
|
||||
location?: string | null;
|
||||
level?: string | null;
|
||||
}
|
||||
|
||||
export interface RateAdjustmentResult {
|
||||
adjustedCostRateCents: number;
|
||||
adjustedBillRateCents: number;
|
||||
adjustedHours: number;
|
||||
appliedRules: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a specificity score for a rule given matching dimensions.
|
||||
* chapter=4, location=2, level=1 — higher means more specific.
|
||||
*/
|
||||
function ruleSpecificity(rule: ExperienceMultiplierRule): number {
|
||||
let score = 0;
|
||||
if (rule.chapter != null && rule.chapter !== "") score += 4;
|
||||
if (rule.location != null && rule.location !== "") score += 2;
|
||||
if (rule.level != null && rule.level !== "") score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
function normalise(value: string | null | undefined): string {
|
||||
return (value ?? "").toLowerCase().trim();
|
||||
}
|
||||
|
||||
function fieldMatches(
|
||||
ruleValue: string | null | undefined,
|
||||
inputValue: string | null | undefined,
|
||||
): boolean {
|
||||
const rv = normalise(ruleValue);
|
||||
if (rv === "") return true; // wildcard — matches everything
|
||||
return rv === normalise(inputValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching rule using hierarchical specificity.
|
||||
*
|
||||
* Matching priority (most specific first):
|
||||
* 1. chapter + location + level
|
||||
* 2. chapter + location
|
||||
* 3. chapter + level
|
||||
* 4. chapter only
|
||||
* 5. location + level
|
||||
* 6. location only
|
||||
* 7. level only
|
||||
* 8. Global fallback (no filters)
|
||||
*
|
||||
* When multiple rules share the same specificity, the first one wins.
|
||||
*/
|
||||
export function findBestMatchingRule(
|
||||
input: Pick<RateAdjustmentInput, "chapter" | "location" | "level">,
|
||||
rules: ExperienceMultiplierRule[],
|
||||
): ExperienceMultiplierRule | null {
|
||||
let bestRule: ExperienceMultiplierRule | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const rule of rules) {
|
||||
const chapterMatch = fieldMatches(rule.chapter, input.chapter);
|
||||
const locationMatch = fieldMatches(rule.location, input.location);
|
||||
const levelMatch = fieldMatches(rule.level, input.level);
|
||||
|
||||
if (!chapterMatch || !locationMatch || !levelMatch) continue;
|
||||
|
||||
const score = ruleSpecificity(rule);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestRule = rule;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply experience multipliers and shoring ratios to a single rate input.
|
||||
*
|
||||
* - Matches the most specific rule by chapter/location/level.
|
||||
* - Multiplies cost and bill rates (rounds to integer cents).
|
||||
* - Adjusts hours via shoring ratio and additional effort ratio:
|
||||
* `adjustedHours = onsiteHours + offshoreHours * (1 + additionalEffortRatio)`
|
||||
* where `onsiteHours = hours * (1 - shoringRatio)` and `offshoreHours = hours * shoringRatio`.
|
||||
*/
|
||||
export function applyExperienceMultipliers(
|
||||
input: RateAdjustmentInput,
|
||||
rules: ExperienceMultiplierRule[],
|
||||
): RateAdjustmentResult {
|
||||
const result: RateAdjustmentResult = {
|
||||
adjustedCostRateCents: input.costRateCents,
|
||||
adjustedBillRateCents: input.billRateCents,
|
||||
adjustedHours: input.hours,
|
||||
appliedRules: [],
|
||||
};
|
||||
|
||||
if (rules.length === 0) {
|
||||
result.appliedRules.push("No rules provided — values unchanged.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const matched = findBestMatchingRule(input, rules);
|
||||
if (!matched) {
|
||||
result.appliedRules.push("No matching rule found — values unchanged.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Describe the match for the audit trail
|
||||
const matchParts: string[] = [];
|
||||
if (matched.chapter) matchParts.push(`chapter=${matched.chapter}`);
|
||||
if (matched.location) matchParts.push(`location=${matched.location}`);
|
||||
if (matched.level) matchParts.push(`level=${matched.level}`);
|
||||
const matchLabel = matchParts.length > 0 ? matchParts.join(", ") : "global fallback";
|
||||
|
||||
// Apply rate multipliers
|
||||
result.adjustedCostRateCents = Math.round(input.costRateCents * matched.costMultiplier);
|
||||
result.adjustedBillRateCents = Math.round(input.billRateCents * matched.billMultiplier);
|
||||
|
||||
if (matched.costMultiplier !== 1.0 || matched.billMultiplier !== 1.0) {
|
||||
result.appliedRules.push(
|
||||
`Rate multipliers applied (${matchLabel}): cost x${matched.costMultiplier}, bill x${matched.billMultiplier}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply shoring ratio
|
||||
const shoringRatio = matched.shoringRatio ?? 0;
|
||||
const additionalEffort = matched.additionalEffortRatio ?? 0;
|
||||
|
||||
if (shoringRatio > 0) {
|
||||
const onsiteHours = input.hours * (1 - shoringRatio);
|
||||
const offshoreHours = input.hours * shoringRatio * (1 + additionalEffort);
|
||||
result.adjustedHours = Math.round((onsiteHours + offshoreHours) * 100) / 100;
|
||||
|
||||
result.appliedRules.push(
|
||||
`Shoring applied (${matchLabel}): ${(shoringRatio * 100).toFixed(0)}% shored` +
|
||||
(additionalEffort > 0
|
||||
? `, +${(additionalEffort * 100).toFixed(0)}% additional effort on shored portion`
|
||||
: "") +
|
||||
` => ${result.adjustedHours}h (from ${input.hours}h).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.appliedRules.length === 0) {
|
||||
result.appliedRules.push(`Matched rule (${matchLabel}) — multipliers are 1.0, no shoring; values unchanged.`);
|
||||
}
|
||||
|
||||
if (matched.description) {
|
||||
result.appliedRules.push(`Rule note: ${matched.description}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-apply experience multipliers to multiple demand line inputs.
|
||||
* Returns per-line results and an overall summary.
|
||||
*/
|
||||
export function applyExperienceMultipliersBatch(
|
||||
inputs: RateAdjustmentInput[],
|
||||
rules: ExperienceMultiplierRule[],
|
||||
): {
|
||||
results: RateAdjustmentResult[];
|
||||
totalOriginalHours: number;
|
||||
totalAdjustedHours: number;
|
||||
linesAdjusted: number;
|
||||
} {
|
||||
const results = inputs.map((input) => applyExperienceMultipliers(input, rules));
|
||||
|
||||
let totalOriginalHours = 0;
|
||||
let totalAdjustedHours = 0;
|
||||
let linesAdjusted = 0;
|
||||
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]!;
|
||||
const result = results[i]!;
|
||||
totalOriginalHours += input.hours;
|
||||
totalAdjustedHours += result.adjustedHours;
|
||||
if (
|
||||
result.adjustedCostRateCents !== input.costRateCents ||
|
||||
result.adjustedBillRateCents !== input.billRateCents ||
|
||||
result.adjustedHours !== input.hours
|
||||
) {
|
||||
linesAdjusted++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
totalOriginalHours: Math.round(totalOriginalHours * 100) / 100,
|
||||
totalAdjustedHours: Math.round(totalAdjustedHours * 100) / 100,
|
||||
linesAdjusted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
EstimateExportFormat,
|
||||
type EstimateExportArtifactPayload,
|
||||
type EstimateExportSummary,
|
||||
type EstimateStatus,
|
||||
type EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
import { summarizeEstimateDemandLines } from "./metrics.js";
|
||||
|
||||
type ExportProjectRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode?: string | null;
|
||||
status?: string | null;
|
||||
startDate?: Date | string | null;
|
||||
endDate?: Date | string | null;
|
||||
} | null;
|
||||
|
||||
type ExportAssumption = {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: unknown;
|
||||
sortOrder: number;
|
||||
notes?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ExportScopeItem = {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scene?: string | null;
|
||||
page?: string | null;
|
||||
location?: string | null;
|
||||
assumptionCategory?: string | null;
|
||||
technicalSpec: unknown;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
internalComments?: string | null;
|
||||
externalComments?: string | null;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ExportDemandLine = {
|
||||
id: string;
|
||||
scopeItemId?: string | null;
|
||||
roleId?: string | null;
|
||||
resourceId?: string | null;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter?: string | null;
|
||||
hours: number;
|
||||
days?: number | null;
|
||||
fte?: number | null;
|
||||
rateSource?: string | null;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
currency: string;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
monthlySpread: unknown;
|
||||
staffingAttributes: unknown;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ExportResourceSnapshot = {
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
sourceEid?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
fte?: number | null;
|
||||
location?: string | null;
|
||||
country?: string | null;
|
||||
level?: string | null;
|
||||
workType?: string | null;
|
||||
attributes: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ExportMetric = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
metricGroup?: string | null;
|
||||
valueDecimal: number;
|
||||
valueCents?: number | null;
|
||||
currency?: string | null;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface EstimateExportSource {
|
||||
estimate: {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
name: string;
|
||||
opportunityId?: string | null;
|
||||
baseCurrency: string;
|
||||
status: EstimateStatus | string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
version: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label?: string | null;
|
||||
status: EstimateVersionStatus | string;
|
||||
notes?: string | null;
|
||||
lockedAt?: Date | null;
|
||||
projectSnapshot: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
assumptions: ExportAssumption[];
|
||||
scopeItems: ExportScopeItem[];
|
||||
demandLines: ExportDemandLine[];
|
||||
resourceSnapshots: ExportResourceSnapshot[];
|
||||
metrics: ExportMetric[];
|
||||
};
|
||||
project: ExportProjectRef;
|
||||
}
|
||||
|
||||
function serializeDate(value: Date | string | null | undefined) {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
function stringifyValue(value: unknown) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toNumericRecord(value: unknown) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
(entry): entry is [string, number] => typeof entry[1] === "number",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function escapeDelimitedValue(value: unknown, delimiter: string) {
|
||||
const rendered = stringifyValue(value);
|
||||
if (
|
||||
rendered.includes(delimiter) ||
|
||||
rendered.includes('"') ||
|
||||
rendered.includes("\n") ||
|
||||
rendered.includes("\r")
|
||||
) {
|
||||
return `"${rendered.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function serializeDelimited(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
delimiter: string,
|
||||
) {
|
||||
if (rows.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const columns = Array.from(
|
||||
rows.reduce((keys, row) => {
|
||||
for (const key of Object.keys(row)) {
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}, new Set<string>()),
|
||||
);
|
||||
const header = columns.map((column) => escapeDelimitedValue(column, delimiter));
|
||||
const body = rows.map((row) =>
|
||||
columns.map((column) => escapeDelimitedValue(row[column], delimiter)).join(delimiter),
|
||||
);
|
||||
|
||||
return [header.join(delimiter), ...body].join("\n");
|
||||
}
|
||||
|
||||
function buildSummary(source: EstimateExportSource): EstimateExportSummary {
|
||||
const summarized = summarizeEstimateDemandLines(source.version.demandLines);
|
||||
const metricsByKey = new Map(
|
||||
source.version.metrics.map((metric) => [metric.key, metric]),
|
||||
);
|
||||
|
||||
const totalHours =
|
||||
metricsByKey.get("total_hours")?.valueDecimal ?? summarized.totalHours;
|
||||
const totalCostCents =
|
||||
metricsByKey.get("total_cost")?.valueCents ?? summarized.totalCostCents;
|
||||
const totalPriceCents =
|
||||
metricsByKey.get("total_price")?.valueCents ?? summarized.totalPriceCents;
|
||||
const marginCents =
|
||||
metricsByKey.get("margin")?.valueCents ?? summarized.marginCents;
|
||||
const marginPercent =
|
||||
metricsByKey.get("margin_percent")?.valueDecimal ?? summarized.marginPercent;
|
||||
|
||||
return {
|
||||
estimateId: source.estimate.id,
|
||||
estimateName: source.estimate.name,
|
||||
versionId: source.version.id,
|
||||
versionNumber: source.version.versionNumber,
|
||||
versionStatus: source.version.status as EstimateVersionStatus,
|
||||
projectId: source.estimate.projectId ?? source.project?.id ?? null,
|
||||
projectName: source.project?.name ?? null,
|
||||
baseCurrency: source.estimate.baseCurrency,
|
||||
assumptionCount: source.version.assumptions.length,
|
||||
scopeItemCount: source.version.scopeItems.length,
|
||||
demandLineCount: source.version.demandLines.length,
|
||||
resourceSnapshotCount: source.version.resourceSnapshots.length,
|
||||
totalHours,
|
||||
totalCostCents,
|
||||
totalPriceCents,
|
||||
marginCents,
|
||||
marginPercent,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverviewRows(
|
||||
source: EstimateExportSource,
|
||||
summary: EstimateExportSummary,
|
||||
) {
|
||||
return [
|
||||
{ field: "estimate_id", value: summary.estimateId },
|
||||
{ field: "estimate_name", value: summary.estimateName },
|
||||
{ field: "estimate_status", value: source.estimate.status },
|
||||
{ field: "version_id", value: summary.versionId },
|
||||
{ field: "version_number", value: summary.versionNumber },
|
||||
{ field: "version_status", value: summary.versionStatus },
|
||||
{ field: "version_label", value: source.version.label ?? "" },
|
||||
{ field: "version_notes", value: source.version.notes ?? "" },
|
||||
{ field: "project_id", value: summary.projectId ?? "" },
|
||||
{ field: "project_name", value: summary.projectName ?? "" },
|
||||
{ field: "project_code", value: source.project?.shortCode ?? "" },
|
||||
{ field: "base_currency", value: summary.baseCurrency },
|
||||
{ field: "opportunity_id", value: source.estimate.opportunityId ?? "" },
|
||||
{ field: "locked_at", value: serializeDate(source.version.lockedAt) ?? "" },
|
||||
{ field: "generated_from_project_start", value: serializeDate(source.project?.startDate) ?? "" },
|
||||
{ field: "generated_from_project_end", value: serializeDate(source.project?.endDate) ?? "" },
|
||||
{ field: "assumption_count", value: summary.assumptionCount },
|
||||
{ field: "scope_item_count", value: summary.scopeItemCount },
|
||||
{ field: "demand_line_count", value: summary.demandLineCount },
|
||||
{ field: "resource_snapshot_count", value: summary.resourceSnapshotCount },
|
||||
{ field: "total_hours", value: summary.totalHours },
|
||||
{ field: "total_cost_cents", value: summary.totalCostCents },
|
||||
{ field: "total_price_cents", value: summary.totalPriceCents },
|
||||
{ field: "margin_cents", value: summary.marginCents },
|
||||
{ field: "margin_percent", value: summary.marginPercent },
|
||||
];
|
||||
}
|
||||
|
||||
function buildAssumptionRows(assumptions: ExportAssumption[]) {
|
||||
return assumptions.map((assumption) => ({
|
||||
id: assumption.id,
|
||||
category: assumption.category,
|
||||
key: assumption.key,
|
||||
label: assumption.label,
|
||||
value_type: assumption.valueType,
|
||||
value: stringifyValue(assumption.value),
|
||||
notes: assumption.notes ?? "",
|
||||
sort_order: assumption.sortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildScopeRows(scopeItems: ExportScopeItem[]) {
|
||||
return scopeItems.map((scopeItem) => ({
|
||||
id: scopeItem.id,
|
||||
sequence_no: scopeItem.sequenceNo,
|
||||
scope_type: scopeItem.scopeType,
|
||||
package_code: scopeItem.packageCode ?? "",
|
||||
name: scopeItem.name,
|
||||
description: scopeItem.description ?? "",
|
||||
scene: scopeItem.scene ?? "",
|
||||
page: scopeItem.page ?? "",
|
||||
location: scopeItem.location ?? "",
|
||||
assumption_category: scopeItem.assumptionCategory ?? "",
|
||||
frame_count: scopeItem.frameCount ?? "",
|
||||
item_count: scopeItem.itemCount ?? "",
|
||||
unit_mode: scopeItem.unitMode ?? "",
|
||||
technical_spec: stringifyValue(scopeItem.technicalSpec),
|
||||
internal_comments: scopeItem.internalComments ?? "",
|
||||
external_comments: scopeItem.externalComments ?? "",
|
||||
metadata: stringifyValue(scopeItem.metadata),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDemandRows(source: EstimateExportSource) {
|
||||
const scopeItemsById = new Map(
|
||||
source.version.scopeItems.map((scopeItem) => [scopeItem.id, scopeItem]),
|
||||
);
|
||||
|
||||
return source.version.demandLines.map((line, index) => ({
|
||||
line_no: index + 1,
|
||||
line_id: line.id,
|
||||
scope_item_id: line.scopeItemId ?? "",
|
||||
scope_item_name:
|
||||
(line.scopeItemId ? scopeItemsById.get(line.scopeItemId)?.name : null) ?? "",
|
||||
role_id: line.roleId ?? "",
|
||||
resource_id: line.resourceId ?? "",
|
||||
line_type: line.lineType,
|
||||
name: line.name,
|
||||
chapter: line.chapter ?? "",
|
||||
hours: line.hours,
|
||||
days: line.days ?? "",
|
||||
fte: line.fte ?? "",
|
||||
rate_source: line.rateSource ?? "",
|
||||
cost_rate_cents: line.costRateCents,
|
||||
bill_rate_cents: line.billRateCents,
|
||||
currency: line.currency,
|
||||
cost_total_cents: line.costTotalCents,
|
||||
price_total_cents: line.priceTotalCents,
|
||||
monthly_spread: stringifyValue(toNumericRecord(line.monthlySpread)),
|
||||
staffing_attributes: stringifyValue(line.staffingAttributes),
|
||||
metadata: stringifyValue(line.metadata),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildResourceRows(resourceSnapshots: ExportResourceSnapshot[]) {
|
||||
return resourceSnapshots.map((snapshot) => ({
|
||||
id: snapshot.id,
|
||||
resource_id: snapshot.resourceId ?? "",
|
||||
source_eid: snapshot.sourceEid ?? "",
|
||||
display_name: snapshot.displayName,
|
||||
chapter: snapshot.chapter ?? "",
|
||||
role_id: snapshot.roleId ?? "",
|
||||
currency: snapshot.currency,
|
||||
lcr_cents: snapshot.lcrCents,
|
||||
ucr_cents: snapshot.ucrCents,
|
||||
fte: snapshot.fte ?? "",
|
||||
location: snapshot.location ?? "",
|
||||
country: snapshot.country ?? "",
|
||||
level: snapshot.level ?? "",
|
||||
work_type: snapshot.workType ?? "",
|
||||
attributes: stringifyValue(snapshot.attributes),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildMetricRows(metrics: ExportMetric[]) {
|
||||
return metrics.map((metric) => ({
|
||||
id: metric.id,
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
metric_group: metric.metricGroup ?? "",
|
||||
value_decimal: metric.valueDecimal,
|
||||
value_cents: metric.valueCents ?? "",
|
||||
currency: metric.currency ?? "",
|
||||
metadata: stringifyValue(metric.metadata),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSapRows(
|
||||
source: EstimateExportSource,
|
||||
summary: EstimateExportSummary,
|
||||
) {
|
||||
return source.version.demandLines.map((line, index) => ({
|
||||
record_type: "ESTIMATE_LINE",
|
||||
estimate_id: summary.estimateId,
|
||||
version_number: summary.versionNumber,
|
||||
project_code: source.project?.shortCode ?? "",
|
||||
project_name: summary.projectName ?? "",
|
||||
line_no: index + 1,
|
||||
line_name: line.name,
|
||||
role_id: line.roleId ?? "",
|
||||
resource_id: line.resourceId ?? "",
|
||||
chapter: line.chapter ?? "",
|
||||
hours: line.hours,
|
||||
cost_rate_cents: line.costRateCents,
|
||||
bill_rate_cents: line.billRateCents,
|
||||
cost_total_cents: line.costTotalCents,
|
||||
price_total_cents: line.priceTotalCents,
|
||||
currency: line.currency,
|
||||
rate_source: line.rateSource ?? "",
|
||||
version_status: summary.versionStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildMmpRows(
|
||||
source: EstimateExportSource,
|
||||
summary: EstimateExportSummary,
|
||||
) {
|
||||
const monthKeys = Array.from(
|
||||
new Set(
|
||||
source.version.demandLines.flatMap((line) =>
|
||||
Object.keys(toNumericRecord(line.monthlySpread)),
|
||||
),
|
||||
),
|
||||
).sort();
|
||||
|
||||
return source.version.demandLines.map((line, index) => {
|
||||
const baseRow: Record<string, unknown> = {
|
||||
estimate_id: summary.estimateId,
|
||||
version_number: summary.versionNumber,
|
||||
project_id: summary.projectId ?? "",
|
||||
project_code: source.project?.shortCode ?? "",
|
||||
line_no: index + 1,
|
||||
line_name: line.name,
|
||||
role_id: line.roleId ?? "",
|
||||
resource_id: line.resourceId ?? "",
|
||||
total_hours: line.hours,
|
||||
total_cost_cents: line.costTotalCents,
|
||||
total_price_cents: line.priceTotalCents,
|
||||
currency: line.currency,
|
||||
};
|
||||
|
||||
for (const monthKey of monthKeys) {
|
||||
baseRow[`month_${monthKey}`] =
|
||||
toNumericRecord(line.monthlySpread)[monthKey] ?? 0;
|
||||
}
|
||||
|
||||
return baseRow;
|
||||
});
|
||||
}
|
||||
|
||||
function buildJsonDocument(
|
||||
source: EstimateExportSource,
|
||||
summary: EstimateExportSummary,
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
estimate: {
|
||||
...source.estimate,
|
||||
createdAt: source.estimate.createdAt.toISOString(),
|
||||
updatedAt: source.estimate.updatedAt.toISOString(),
|
||||
},
|
||||
project: source.project
|
||||
? {
|
||||
...source.project,
|
||||
startDate: serializeDate(source.project.startDate),
|
||||
endDate: serializeDate(source.project.endDate),
|
||||
}
|
||||
: null,
|
||||
version: {
|
||||
id: source.version.id,
|
||||
versionNumber: source.version.versionNumber,
|
||||
label: source.version.label ?? null,
|
||||
status: source.version.status,
|
||||
notes: source.version.notes ?? null,
|
||||
lockedAt: serializeDate(source.version.lockedAt),
|
||||
projectSnapshot: source.version.projectSnapshot,
|
||||
createdAt: source.version.createdAt.toISOString(),
|
||||
updatedAt: source.version.updatedAt.toISOString(),
|
||||
},
|
||||
summary,
|
||||
assumptions: source.version.assumptions.map((assumption) => ({
|
||||
...assumption,
|
||||
createdAt: assumption.createdAt.toISOString(),
|
||||
updatedAt: assumption.updatedAt.toISOString(),
|
||||
})),
|
||||
scopeItems: source.version.scopeItems.map((scopeItem) => ({
|
||||
...scopeItem,
|
||||
createdAt: scopeItem.createdAt.toISOString(),
|
||||
updatedAt: scopeItem.updatedAt.toISOString(),
|
||||
})),
|
||||
demandLines: source.version.demandLines.map((line) => ({
|
||||
...line,
|
||||
createdAt: line.createdAt.toISOString(),
|
||||
updatedAt: line.updatedAt.toISOString(),
|
||||
})),
|
||||
resourceSnapshots: source.version.resourceSnapshots.map((snapshot) => ({
|
||||
...snapshot,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
updatedAt: snapshot.updatedAt.toISOString(),
|
||||
})),
|
||||
metrics: source.version.metrics.map((metric) => ({
|
||||
...metric,
|
||||
createdAt: metric.createdAt.toISOString(),
|
||||
updatedAt: metric.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function base64ByteLength(content: string) {
|
||||
const padding = content.endsWith("==") ? 2 : content.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((content.length * 3) / 4) - padding;
|
||||
}
|
||||
|
||||
function buildTextPayload(
|
||||
format: EstimateExportFormat,
|
||||
content: string,
|
||||
summary: EstimateExportSummary,
|
||||
options: {
|
||||
mimeType: string;
|
||||
fileExtension: string;
|
||||
rowCount: number;
|
||||
},
|
||||
): EstimateExportArtifactPayload {
|
||||
const lineCount = content.length === 0 ? 0 : content.split("\n").length;
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
format,
|
||||
mimeType: options.mimeType,
|
||||
encoding: "utf8",
|
||||
fileExtension: options.fileExtension,
|
||||
generatedAt: new Date().toISOString(),
|
||||
byteLength: new TextEncoder().encode(content).length,
|
||||
rowCount: options.rowCount,
|
||||
lineCount,
|
||||
previewText: content.split("\n").slice(0, 12).join("\n"),
|
||||
content,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildXlsxPayload(
|
||||
source: EstimateExportSource,
|
||||
summary: EstimateExportSummary,
|
||||
): EstimateExportArtifactPayload {
|
||||
const overviewRows = buildOverviewRows(source, summary);
|
||||
const assumptionRows = buildAssumptionRows(source.version.assumptions);
|
||||
const scopeRows = buildScopeRows(source.version.scopeItems);
|
||||
const demandRows = buildDemandRows(source);
|
||||
const resourceRows = buildResourceRows(source.version.resourceSnapshots);
|
||||
const metricRows = buildMetricRows(source.version.metrics);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheets = [
|
||||
{ name: "Overview", rows: overviewRows },
|
||||
{ name: "Assumptions", rows: assumptionRows },
|
||||
{ name: "Scope", rows: scopeRows },
|
||||
{ name: "DemandLines", rows: demandRows },
|
||||
{ name: "Resources", rows: resourceRows },
|
||||
{ name: "Metrics", rows: metricRows },
|
||||
] as const;
|
||||
|
||||
for (const sheet of sheets) {
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.json_to_sheet(sheet.rows),
|
||||
sheet.name,
|
||||
);
|
||||
}
|
||||
|
||||
const content = XLSX.write(workbook, {
|
||||
type: "base64",
|
||||
bookType: "xlsx",
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
format: EstimateExportFormat.XLSX,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
encoding: "base64",
|
||||
fileExtension: "xlsx",
|
||||
generatedAt: new Date().toISOString(),
|
||||
byteLength: base64ByteLength(content),
|
||||
rowCount:
|
||||
overviewRows.length +
|
||||
assumptionRows.length +
|
||||
scopeRows.length +
|
||||
demandRows.length +
|
||||
resourceRows.length +
|
||||
metricRows.length,
|
||||
lineCount: null,
|
||||
sheetNames: sheets.map((sheet) => sheet.name),
|
||||
previewText: `Sheets: ${sheets.map((sheet) => sheet.name).join(", ")}`,
|
||||
content,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEstimateExport(
|
||||
source: EstimateExportSource,
|
||||
format: EstimateExportFormat,
|
||||
): EstimateExportArtifactPayload {
|
||||
const summary = buildSummary(source);
|
||||
|
||||
if (format === EstimateExportFormat.JSON) {
|
||||
const content = JSON.stringify(buildJsonDocument(source, summary), null, 2);
|
||||
return buildTextPayload(format, content, summary, {
|
||||
mimeType: "application/json; charset=utf-8",
|
||||
fileExtension: "json",
|
||||
rowCount: summary.demandLineCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (format === EstimateExportFormat.CSV) {
|
||||
const rows = buildDemandRows(source);
|
||||
return buildTextPayload(format, serializeDelimited(rows, ","), summary, {
|
||||
mimeType: "text/csv; charset=utf-8",
|
||||
fileExtension: "csv",
|
||||
rowCount: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (format === EstimateExportFormat.SAP) {
|
||||
const rows = buildSapRows(source, summary);
|
||||
return buildTextPayload(format, serializeDelimited(rows, ";"), summary, {
|
||||
mimeType: "text/plain; charset=utf-8",
|
||||
fileExtension: "sap",
|
||||
rowCount: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (format === EstimateExportFormat.MMP) {
|
||||
const rows = buildMmpRows(source, summary);
|
||||
return buildTextPayload(format, serializeDelimited(rows, "|"), summary, {
|
||||
mimeType: "text/plain; charset=utf-8",
|
||||
fileExtension: "mmp",
|
||||
rowCount: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
return buildXlsxPayload(source, summary);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./effort-rules.js";
|
||||
export * from "./experience-multiplier.js";
|
||||
export * from "./export-serializer.js";
|
||||
export * from "./metrics.js";
|
||||
export * from "./monthly-spread.js";
|
||||
export * from "./version-compare.js";
|
||||
export * from "./weekly-phasing.js";
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
EstimateDemandLine,
|
||||
EstimateDemandLineCalculationMetadata,
|
||||
EstimateDemandLineRateMode,
|
||||
EstimateDemandSummary,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export interface EstimateDemandLineRateSnapshot {
|
||||
resourceId?: string | null;
|
||||
currency?: string | null;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLineForCalculation {
|
||||
resourceId?: string | null | undefined;
|
||||
hours: number;
|
||||
rateSource?: string | null | undefined;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
currency?: string | null | undefined;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
type ParsedDemandLineMetadata = Record<string, unknown> & {
|
||||
calculation?: Partial<EstimateDemandLineCalculationMetadata>;
|
||||
};
|
||||
|
||||
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
|
||||
return value === "resource" || value === "manual" ? value : undefined;
|
||||
}
|
||||
|
||||
function parseDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): ParsedDemandLineMetadata {
|
||||
const safeMetadata =
|
||||
typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
|
||||
? metadata
|
||||
: {};
|
||||
const rawCalculation =
|
||||
typeof safeMetadata.calculation === "object" &&
|
||||
safeMetadata.calculation !== null &&
|
||||
!Array.isArray(safeMetadata.calculation)
|
||||
? (safeMetadata.calculation as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
if (!rawCalculation) {
|
||||
return safeMetadata as ParsedDemandLineMetadata;
|
||||
}
|
||||
|
||||
const calculation: Partial<EstimateDemandLineCalculationMetadata> = {};
|
||||
|
||||
const costRateMode = parseRateMode(rawCalculation.costRateMode);
|
||||
if (costRateMode) {
|
||||
calculation.costRateMode = costRateMode;
|
||||
}
|
||||
|
||||
const billRateMode = parseRateMode(rawCalculation.billRateMode);
|
||||
if (billRateMode) {
|
||||
calculation.billRateMode = billRateMode;
|
||||
}
|
||||
|
||||
if (rawCalculation.totalMode === "computed") {
|
||||
calculation.totalMode = "computed";
|
||||
}
|
||||
|
||||
if (typeof rawCalculation.liveCostRateCents === "number") {
|
||||
calculation.liveCostRateCents = rawCalculation.liveCostRateCents;
|
||||
}
|
||||
|
||||
if (typeof rawCalculation.liveBillRateCents === "number") {
|
||||
calculation.liveBillRateCents = rawCalculation.liveBillRateCents;
|
||||
}
|
||||
|
||||
if (typeof rawCalculation.liveCurrency === "string") {
|
||||
calculation.liveCurrency = rawCalculation.liveCurrency;
|
||||
}
|
||||
|
||||
return {
|
||||
...safeMetadata,
|
||||
...(Object.keys(calculation).length > 0 ? { calculation } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function inferRateMode(
|
||||
resourceSnapshot: EstimateDemandLineRateSnapshot | null | undefined,
|
||||
effectiveRateCents: number,
|
||||
liveRateCents: number | undefined,
|
||||
explicitMode: EstimateDemandLineRateMode | undefined,
|
||||
): EstimateDemandLineRateMode {
|
||||
if (explicitMode) {
|
||||
return explicitMode;
|
||||
}
|
||||
|
||||
if (!resourceSnapshot || liveRateCents == null) {
|
||||
return "manual";
|
||||
}
|
||||
|
||||
return effectiveRateCents === liveRateCents ? "resource" : "manual";
|
||||
}
|
||||
|
||||
export function getEstimateDemandLineCalculationMetadata(
|
||||
line: Pick<
|
||||
EstimateDemandLineForCalculation,
|
||||
"resourceId" | "costRateCents" | "billRateCents" | "metadata"
|
||||
>,
|
||||
options?: {
|
||||
resourceSnapshot?: EstimateDemandLineRateSnapshot | null | undefined;
|
||||
},
|
||||
): EstimateDemandLineCalculationMetadata {
|
||||
const parsedMetadata = parseDemandLineMetadata(line.metadata);
|
||||
const explicitCalculation = parsedMetadata.calculation;
|
||||
const resourceSnapshot = options?.resourceSnapshot;
|
||||
const liveCostRateCents = resourceSnapshot?.lcrCents;
|
||||
const liveBillRateCents = resourceSnapshot?.ucrCents;
|
||||
|
||||
return {
|
||||
costRateMode: inferRateMode(
|
||||
resourceSnapshot,
|
||||
line.costRateCents,
|
||||
liveCostRateCents,
|
||||
explicitCalculation?.costRateMode,
|
||||
),
|
||||
billRateMode: inferRateMode(
|
||||
resourceSnapshot,
|
||||
line.billRateCents,
|
||||
liveBillRateCents,
|
||||
explicitCalculation?.billRateMode,
|
||||
),
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: liveCostRateCents ?? null,
|
||||
liveBillRateCents: liveBillRateCents ?? null,
|
||||
liveCurrency: resourceSnapshot?.currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEstimateDemandLine<T extends EstimateDemandLineForCalculation>(
|
||||
line: T,
|
||||
options?: {
|
||||
resourceSnapshot?: EstimateDemandLineRateSnapshot | null | undefined;
|
||||
defaultCurrency?: string;
|
||||
},
|
||||
): T {
|
||||
const resourceSnapshot = options?.resourceSnapshot;
|
||||
const calculation = getEstimateDemandLineCalculationMetadata(line, {
|
||||
resourceSnapshot,
|
||||
});
|
||||
const effectiveCostRateCents =
|
||||
calculation.costRateMode === "resource" && resourceSnapshot
|
||||
? resourceSnapshot.lcrCents
|
||||
: line.costRateCents;
|
||||
const effectiveBillRateCents =
|
||||
calculation.billRateMode === "resource" && resourceSnapshot
|
||||
? resourceSnapshot.ucrCents
|
||||
: line.billRateCents;
|
||||
const currency =
|
||||
((calculation.costRateMode === "resource" ||
|
||||
calculation.billRateMode === "resource") &&
|
||||
resourceSnapshot?.currency
|
||||
? resourceSnapshot.currency
|
||||
: line.currency) ||
|
||||
resourceSnapshot?.currency ||
|
||||
options?.defaultCurrency ||
|
||||
"EUR";
|
||||
const metadata = parseDemandLineMetadata(line.metadata);
|
||||
|
||||
return {
|
||||
...line,
|
||||
costRateCents: effectiveCostRateCents,
|
||||
billRateCents: effectiveBillRateCents,
|
||||
currency,
|
||||
costTotalCents: Math.round(line.hours * effectiveCostRateCents),
|
||||
priceTotalCents: Math.round(line.hours * effectiveBillRateCents),
|
||||
metadata: {
|
||||
...metadata,
|
||||
calculation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeEstimateDemandLines(
|
||||
demandLines: Pick<
|
||||
EstimateDemandLine,
|
||||
"hours" | "costTotalCents" | "priceTotalCents"
|
||||
>[],
|
||||
): EstimateDemandSummary {
|
||||
const totalHours = demandLines.reduce((sum, line) => sum + line.hours, 0);
|
||||
const totalCostCents = demandLines.reduce(
|
||||
(sum, line) => sum + line.costTotalCents,
|
||||
0,
|
||||
);
|
||||
const totalPriceCents = demandLines.reduce(
|
||||
(sum, line) => sum + line.priceTotalCents,
|
||||
0,
|
||||
);
|
||||
const marginCents = totalPriceCents - totalCostCents;
|
||||
const marginPercent =
|
||||
totalPriceCents > 0 ? Math.round((marginCents / totalPriceCents) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
totalCostCents,
|
||||
totalPriceCents,
|
||||
marginCents,
|
||||
marginPercent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Monthly spread computation for estimate demand lines.
|
||||
*
|
||||
* Distributes total hours across months in a date range. Supports:
|
||||
* - Even distribution (pro-rated for partial months)
|
||||
* - Manual overrides per month
|
||||
* - Rebalancing remaining hours after manual edits
|
||||
*/
|
||||
|
||||
/** Format: "YYYY-MM" */
|
||||
type MonthKey = string;
|
||||
|
||||
export interface MonthlySpreadInput {
|
||||
totalHours: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface MonthlySpreadResult {
|
||||
/** Hours per month, keyed by "YYYY-MM" */
|
||||
spread: Record<MonthKey, number>;
|
||||
/** Ordered month keys for display */
|
||||
months: MonthKey[];
|
||||
}
|
||||
|
||||
export interface RebalanceSpreadInput {
|
||||
totalHours: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
/** Months with manually locked values */
|
||||
lockedMonths: Record<MonthKey, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ordered month keys between two dates (inclusive).
|
||||
*/
|
||||
export function getEstimateMonthRange(startDate: Date, endDate: Date): MonthKey[] {
|
||||
const months: MonthKey[] = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const endMonth = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
|
||||
while (cursor <= endMonth) {
|
||||
const year = cursor.getFullYear();
|
||||
const month = String(cursor.getMonth() + 1).padStart(2, "0");
|
||||
months.push(`${year}-${month}`);
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts working days (Mon-Fri) in a date range.
|
||||
*/
|
||||
function countWorkingDays(from: Date, to: Date): number {
|
||||
let count = 0;
|
||||
const cursor = new Date(from);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
const end = new Date(to);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const day = cursor.getDay();
|
||||
if (day !== 0 && day !== 6) count++;
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseMonthKey(monthKey: string): { year: number; month: number } {
|
||||
const parts = monthKey.split("-");
|
||||
return {
|
||||
year: parseInt(parts[0] ?? "0", 10),
|
||||
month: parseInt(parts[1] ?? "1", 10) - 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get working days per month within a date range, pro-rated for partial months.
|
||||
*/
|
||||
function getWorkingDaysPerMonth(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
months: MonthKey[],
|
||||
): Record<MonthKey, number> {
|
||||
const result: Record<MonthKey, number> = {};
|
||||
|
||||
for (const monthKey of months) {
|
||||
const { year, month } = parseMonthKey(monthKey);
|
||||
|
||||
const monthStart = new Date(year, month, 1);
|
||||
const monthEnd = new Date(year, month + 1, 0); // last day of month
|
||||
|
||||
const effectiveStart = startDate > monthStart ? startDate : monthStart;
|
||||
const effectiveEnd = endDate < monthEnd ? endDate : monthEnd;
|
||||
|
||||
result[monthKey] = countWorkingDays(effectiveStart, effectiveEnd);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute total hours evenly across months, weighted by working days.
|
||||
*/
|
||||
export function computeEvenSpread(input: MonthlySpreadInput): MonthlySpreadResult {
|
||||
const { totalHours, startDate, endDate } = input;
|
||||
const months = getEstimateMonthRange(startDate, endDate);
|
||||
|
||||
if (months.length === 0) {
|
||||
return { spread: {}, months: [] };
|
||||
}
|
||||
|
||||
const workingDays = getWorkingDaysPerMonth(startDate, endDate, months);
|
||||
const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0);
|
||||
|
||||
const spread: Record<MonthKey, number> = {};
|
||||
|
||||
if (totalWorkingDays === 0) {
|
||||
// Fallback: distribute evenly by month count
|
||||
const perMonth = Math.round((totalHours / months.length) * 10) / 10;
|
||||
for (const month of months) {
|
||||
spread[month] = perMonth;
|
||||
}
|
||||
} else {
|
||||
for (const month of months) {
|
||||
const days = workingDays[month] ?? 0;
|
||||
const weight = days / totalWorkingDays;
|
||||
spread[month] = Math.round(totalHours * weight * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust rounding error on last month
|
||||
const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0);
|
||||
const diff = Math.round((totalHours - spreadTotal) * 10) / 10;
|
||||
if (diff !== 0 && months.length > 0) {
|
||||
const lastMonth = months[months.length - 1]!;
|
||||
spread[lastMonth] = Math.round(((spread[lastMonth] ?? 0) + diff) * 10) / 10;
|
||||
}
|
||||
|
||||
return { spread, months };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebalance: distribute remaining hours (after locked months) across unlocked months.
|
||||
*/
|
||||
export function rebalanceSpread(input: RebalanceSpreadInput): MonthlySpreadResult {
|
||||
const { totalHours, startDate, endDate, lockedMonths } = input;
|
||||
const months = getEstimateMonthRange(startDate, endDate);
|
||||
|
||||
if (months.length === 0) {
|
||||
return { spread: {}, months: [] };
|
||||
}
|
||||
|
||||
const lockedTotal = Object.entries(lockedMonths)
|
||||
.filter(([key]) => months.includes(key))
|
||||
.reduce((sum, [, hours]) => sum + hours, 0);
|
||||
|
||||
const remainingHours = Math.max(0, totalHours - lockedTotal);
|
||||
const unlockedMonths = months.filter((m) => !(m in lockedMonths));
|
||||
|
||||
const spread: Record<MonthKey, number> = {};
|
||||
|
||||
// Copy locked months
|
||||
for (const month of months) {
|
||||
if (month in lockedMonths) {
|
||||
spread[month] = lockedMonths[month] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (unlockedMonths.length === 0) {
|
||||
return { spread, months };
|
||||
}
|
||||
|
||||
// Distribute remaining hours across unlocked months
|
||||
const workingDays = getWorkingDaysPerMonth(startDate, endDate, unlockedMonths);
|
||||
const totalWorkingDays = Object.values(workingDays).reduce((sum, d) => sum + d, 0);
|
||||
|
||||
if (totalWorkingDays === 0) {
|
||||
const perMonth = Math.round((remainingHours / unlockedMonths.length) * 10) / 10;
|
||||
for (const month of unlockedMonths) {
|
||||
spread[month] = perMonth;
|
||||
}
|
||||
} else {
|
||||
for (const month of unlockedMonths) {
|
||||
const days = workingDays[month] ?? 0;
|
||||
const weight = days / totalWorkingDays;
|
||||
spread[month] = Math.round(remainingHours * weight * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust rounding error on last unlocked month (only when locked total doesn't exceed budget)
|
||||
if (lockedTotal <= totalHours) {
|
||||
const spreadTotal = Object.values(spread).reduce((sum, h) => sum + h, 0);
|
||||
const diff = Math.round((totalHours - spreadTotal) * 10) / 10;
|
||||
if (diff !== 0 && unlockedMonths.length > 0) {
|
||||
const lastUnlocked = unlockedMonths[unlockedMonths.length - 1]!;
|
||||
spread[lastUnlocked] = Math.round(((spread[lastUnlocked] ?? 0) + diff) * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
return { spread, months };
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize monthly spreads across multiple demand lines.
|
||||
* Returns total hours per month.
|
||||
*/
|
||||
export function summarizeMonthlySpread(
|
||||
spreads: Record<MonthKey, number>[],
|
||||
): Record<MonthKey, number> {
|
||||
const totals: Record<MonthKey, number> = {};
|
||||
|
||||
for (const spread of spreads) {
|
||||
for (const [month, hours] of Object.entries(spread)) {
|
||||
totals[month] = Math.round(((totals[month] ?? 0) + hours) * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Pure diff engine for comparing two estimate version snapshots.
|
||||
* No DB or IO dependencies — suitable for engine package.
|
||||
*/
|
||||
|
||||
export interface VersionCompareInput {
|
||||
label?: string | null;
|
||||
versionNumber: number;
|
||||
demandLines: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
hours: number;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
chapter?: string | null;
|
||||
lineType: string;
|
||||
}>;
|
||||
assumptions: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
scopeItems: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
description?: string | null;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
}>;
|
||||
resourceSnapshots?: Array<{
|
||||
id?: string;
|
||||
resourceId?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
location?: string | null;
|
||||
level?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type DemandLineDiffStatus = "added" | "removed" | "changed" | "unchanged";
|
||||
|
||||
export interface DemandLineDiff {
|
||||
name: string;
|
||||
status: DemandLineDiffStatus;
|
||||
hoursDelta?: number;
|
||||
costDelta?: number;
|
||||
priceDelta?: number;
|
||||
a?: { hours: number; costTotalCents: number; priceTotalCents: number };
|
||||
b?: { hours: number; costTotalCents: number; priceTotalCents: number };
|
||||
}
|
||||
|
||||
export type AssumptionDiffStatus = "added" | "removed" | "changed" | "unchanged";
|
||||
|
||||
export interface AssumptionDiff {
|
||||
key: string;
|
||||
label: string;
|
||||
status: AssumptionDiffStatus;
|
||||
aValue?: unknown;
|
||||
bValue?: unknown;
|
||||
}
|
||||
|
||||
export type ScopeItemDiffStatus = "added" | "removed" | "changed" | "unchanged";
|
||||
|
||||
export interface ScopeItemDiff {
|
||||
name: string;
|
||||
scopeType: string;
|
||||
status: ScopeItemDiffStatus;
|
||||
changedFields?: string[] | undefined;
|
||||
a?: { frameCount?: number | null | undefined; itemCount?: number | null | undefined; description?: string | null | undefined } | undefined;
|
||||
b?: { frameCount?: number | null | undefined; itemCount?: number | null | undefined; description?: string | null | undefined } | undefined;
|
||||
}
|
||||
|
||||
export type ResourceSnapshotDiffStatus = "added" | "removed" | "changed" | "unchanged";
|
||||
|
||||
export interface ResourceSnapshotDiff {
|
||||
displayName: string;
|
||||
status: ResourceSnapshotDiffStatus;
|
||||
lcrDelta?: number | undefined;
|
||||
ucrDelta?: number | undefined;
|
||||
a?: { lcrCents: number; ucrCents: number; currency: string; location?: string | null | undefined; level?: string | null | undefined } | undefined;
|
||||
b?: { lcrCents: number; ucrCents: number; currency: string; location?: string | null | undefined; level?: string | null | undefined } | undefined;
|
||||
}
|
||||
|
||||
export interface ChapterSubtotal {
|
||||
chapter: string;
|
||||
hoursA: number;
|
||||
hoursB: number;
|
||||
hoursDelta: number;
|
||||
costA: number;
|
||||
costB: number;
|
||||
costDelta: number;
|
||||
}
|
||||
|
||||
export interface VersionDiffSummary {
|
||||
totalHoursDelta: number;
|
||||
totalCostDelta: number;
|
||||
totalPriceDelta: number;
|
||||
marginPercentA: number;
|
||||
marginPercentB: number;
|
||||
marginPercentDelta: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
linesChanged: number;
|
||||
assumptionsChanged: number;
|
||||
scopeItemsAdded: number;
|
||||
scopeItemsRemoved: number;
|
||||
scopeItemsChanged: number;
|
||||
resourceSnapshotsChanged: number;
|
||||
}
|
||||
|
||||
export interface VersionDiff {
|
||||
summary: VersionDiffSummary;
|
||||
demandLineDiffs: DemandLineDiff[];
|
||||
assumptionDiffs: AssumptionDiff[];
|
||||
scopeItemDiffs: ScopeItemDiff[];
|
||||
resourceSnapshotDiffs: ResourceSnapshotDiff[];
|
||||
chapterSubtotals: ChapterSubtotal[];
|
||||
}
|
||||
|
||||
type DemandLine = VersionCompareInput["demandLines"][number];
|
||||
type ScopeItem = VersionCompareInput["scopeItems"][number];
|
||||
type ResourceSnapshot = NonNullable<VersionCompareInput["resourceSnapshots"]>[number];
|
||||
|
||||
function scopeDetail(s: ScopeItem): ScopeItemDiff["a"] {
|
||||
return {
|
||||
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
|
||||
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
|
||||
...(s.description !== undefined ? { description: s.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resDetail(r: ResourceSnapshot): NonNullable<ResourceSnapshotDiff["a"]> {
|
||||
return {
|
||||
lcrCents: r.lcrCents,
|
||||
ucrCents: r.ucrCents,
|
||||
currency: r.currency,
|
||||
...(r.location !== undefined ? { location: r.location } : {}),
|
||||
...(r.level !== undefined ? { level: r.level } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function demandLineFuzzyKey(line: DemandLine): string {
|
||||
return `${line.name}::${line.lineType}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match demand lines from version A to version B.
|
||||
* First pass: exact id match. Second pass: name+lineType fuzzy match for unmatched lines.
|
||||
*/
|
||||
function matchDemandLines(
|
||||
aLines: DemandLine[],
|
||||
bLines: DemandLine[],
|
||||
): {
|
||||
matched: Array<{ a: DemandLine; b: DemandLine }>;
|
||||
addedInB: DemandLine[];
|
||||
removedFromA: DemandLine[];
|
||||
} {
|
||||
const matched: Array<{ a: DemandLine; b: DemandLine }> = [];
|
||||
const unmatchedA = new Map<string, DemandLine>();
|
||||
const unmatchedB = new Map<string, DemandLine>();
|
||||
|
||||
// Index B by id
|
||||
const bById = new Map<string, DemandLine>();
|
||||
for (const line of bLines) {
|
||||
bById.set(line.id, line);
|
||||
}
|
||||
|
||||
// Pass 1: exact id match
|
||||
const matchedBIds = new Set<string>();
|
||||
for (const aLine of aLines) {
|
||||
const bLine = bById.get(aLine.id);
|
||||
if (bLine) {
|
||||
matched.push({ a: aLine, b: bLine });
|
||||
matchedBIds.add(bLine.id);
|
||||
} else {
|
||||
unmatchedA.set(aLine.id, aLine);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bLine of bLines) {
|
||||
if (!matchedBIds.has(bLine.id)) {
|
||||
unmatchedB.set(bLine.id, bLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: fuzzy match by name+lineType
|
||||
const bByFuzzy = new Map<string, DemandLine>();
|
||||
for (const bLine of unmatchedB.values()) {
|
||||
const key = demandLineFuzzyKey(bLine);
|
||||
// Only use fuzzy if unique in unmatched B
|
||||
if (!bByFuzzy.has(key)) {
|
||||
bByFuzzy.set(key, bLine);
|
||||
} else {
|
||||
// Duplicate fuzzy key — skip fuzzy matching for this key
|
||||
bByFuzzy.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const fuzzyMatchedBIds = new Set<string>();
|
||||
const stillUnmatchedA: DemandLine[] = [];
|
||||
|
||||
for (const aLine of unmatchedA.values()) {
|
||||
const key = demandLineFuzzyKey(aLine);
|
||||
const bLine = bByFuzzy.get(key);
|
||||
if (bLine && !fuzzyMatchedBIds.has(bLine.id)) {
|
||||
matched.push({ a: aLine, b: bLine });
|
||||
fuzzyMatchedBIds.add(bLine.id);
|
||||
} else {
|
||||
stillUnmatchedA.push(aLine);
|
||||
}
|
||||
}
|
||||
|
||||
const addedInB: DemandLine[] = [];
|
||||
for (const bLine of unmatchedB.values()) {
|
||||
if (!fuzzyMatchedBIds.has(bLine.id)) {
|
||||
addedInB.push(bLine);
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, addedInB, removedFromA: stillUnmatchedA };
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a == null && b == null) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a === "object" && a !== null && b !== null) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two estimate version snapshots and return a structured diff.
|
||||
* Pure function — no side effects.
|
||||
*/
|
||||
export function compareEstimateVersions(
|
||||
a: VersionCompareInput,
|
||||
b: VersionCompareInput,
|
||||
): VersionDiff {
|
||||
// --- Demand lines ---
|
||||
const { matched, addedInB, removedFromA } = matchDemandLines(
|
||||
a.demandLines,
|
||||
b.demandLines,
|
||||
);
|
||||
|
||||
const demandLineDiffs: DemandLineDiff[] = [];
|
||||
|
||||
for (const { a: aLine, b: bLine } of matched) {
|
||||
const hoursDelta = bLine.hours - aLine.hours;
|
||||
const costDelta = bLine.costTotalCents - aLine.costTotalCents;
|
||||
const priceDelta = bLine.priceTotalCents - aLine.priceTotalCents;
|
||||
const isChanged = hoursDelta !== 0 || costDelta !== 0 || priceDelta !== 0;
|
||||
|
||||
demandLineDiffs.push({
|
||||
name: bLine.name,
|
||||
status: isChanged ? "changed" : "unchanged",
|
||||
...(isChanged ? { hoursDelta, costDelta, priceDelta } : {}),
|
||||
a: { hours: aLine.hours, costTotalCents: aLine.costTotalCents, priceTotalCents: aLine.priceTotalCents },
|
||||
b: { hours: bLine.hours, costTotalCents: bLine.costTotalCents, priceTotalCents: bLine.priceTotalCents },
|
||||
});
|
||||
}
|
||||
|
||||
for (const line of addedInB) {
|
||||
demandLineDiffs.push({
|
||||
name: line.name,
|
||||
status: "added",
|
||||
hoursDelta: line.hours,
|
||||
costDelta: line.costTotalCents,
|
||||
priceDelta: line.priceTotalCents,
|
||||
b: { hours: line.hours, costTotalCents: line.costTotalCents, priceTotalCents: line.priceTotalCents },
|
||||
});
|
||||
}
|
||||
|
||||
for (const line of removedFromA) {
|
||||
demandLineDiffs.push({
|
||||
name: line.name,
|
||||
status: "removed",
|
||||
hoursDelta: -line.hours,
|
||||
costDelta: -line.costTotalCents,
|
||||
priceDelta: -line.priceTotalCents,
|
||||
a: { hours: line.hours, costTotalCents: line.costTotalCents, priceTotalCents: line.priceTotalCents },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Assumptions ---
|
||||
const aAssumptions = new Map(a.assumptions.map((x) => [x.key, x]));
|
||||
const bAssumptions = new Map(b.assumptions.map((x) => [x.key, x]));
|
||||
const allKeys = new Set([...aAssumptions.keys(), ...bAssumptions.keys()]);
|
||||
|
||||
const assumptionDiffs: AssumptionDiff[] = [];
|
||||
let assumptionsChanged = 0;
|
||||
|
||||
for (const key of allKeys) {
|
||||
const aItem = aAssumptions.get(key);
|
||||
const bItem = bAssumptions.get(key);
|
||||
|
||||
if (aItem && bItem) {
|
||||
const changed = !valuesEqual(aItem.value, bItem.value);
|
||||
if (changed) assumptionsChanged++;
|
||||
assumptionDiffs.push({
|
||||
key,
|
||||
label: bItem.label,
|
||||
status: changed ? "changed" : "unchanged",
|
||||
aValue: aItem.value,
|
||||
bValue: bItem.value,
|
||||
});
|
||||
} else if (bItem) {
|
||||
assumptionsChanged++;
|
||||
assumptionDiffs.push({
|
||||
key,
|
||||
label: bItem.label,
|
||||
status: "added",
|
||||
bValue: bItem.value,
|
||||
});
|
||||
} else if (aItem) {
|
||||
assumptionsChanged++;
|
||||
assumptionDiffs.push({
|
||||
key,
|
||||
label: aItem.label,
|
||||
status: "removed",
|
||||
aValue: aItem.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scope items (detailed diff) ---
|
||||
const aScopeByKey = new Map(a.scopeItems.map((s) => [`${s.name}::${s.scopeType}`, s]));
|
||||
const bScopeByKey = new Map(b.scopeItems.map((s) => [`${s.name}::${s.scopeType}`, s]));
|
||||
const allScopeKeys = new Set([...aScopeByKey.keys(), ...bScopeByKey.keys()]);
|
||||
|
||||
const scopeItemDiffs: ScopeItemDiff[] = [];
|
||||
let scopeItemsAdded = 0;
|
||||
let scopeItemsRemoved = 0;
|
||||
let scopeItemsChanged = 0;
|
||||
|
||||
for (const key of allScopeKeys) {
|
||||
const aItem = aScopeByKey.get(key);
|
||||
const bItem = bScopeByKey.get(key);
|
||||
|
||||
if (aItem && bItem) {
|
||||
const changedFields: string[] = [];
|
||||
if (aItem.frameCount !== bItem.frameCount) changedFields.push("frameCount");
|
||||
if (aItem.itemCount !== bItem.itemCount) changedFields.push("itemCount");
|
||||
if ((aItem.description ?? null) !== (bItem.description ?? null)) changedFields.push("description");
|
||||
if ((aItem.packageCode ?? null) !== (bItem.packageCode ?? null)) changedFields.push("packageCode");
|
||||
|
||||
const status: ScopeItemDiffStatus = changedFields.length > 0 ? "changed" : "unchanged";
|
||||
if (status === "changed") scopeItemsChanged++;
|
||||
|
||||
scopeItemDiffs.push({
|
||||
name: bItem.name,
|
||||
scopeType: bItem.scopeType,
|
||||
status,
|
||||
...(changedFields.length > 0 ? { changedFields } : {}),
|
||||
a: scopeDetail(aItem),
|
||||
b: scopeDetail(bItem),
|
||||
});
|
||||
} else if (bItem) {
|
||||
scopeItemsAdded++;
|
||||
scopeItemDiffs.push({
|
||||
name: bItem.name,
|
||||
scopeType: bItem.scopeType,
|
||||
status: "added",
|
||||
b: scopeDetail(bItem),
|
||||
});
|
||||
} else if (aItem) {
|
||||
scopeItemsRemoved++;
|
||||
scopeItemDiffs.push({
|
||||
name: aItem.name,
|
||||
scopeType: aItem.scopeType,
|
||||
status: "removed",
|
||||
a: scopeDetail(aItem),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resource snapshots ---
|
||||
const aResources = a.resourceSnapshots ?? [];
|
||||
const bResources = b.resourceSnapshots ?? [];
|
||||
|
||||
const aResByKey = new Map(aResources.map((r) => [r.resourceId ?? r.displayName, r]));
|
||||
const bResByKey = new Map(bResources.map((r) => [r.resourceId ?? r.displayName, r]));
|
||||
const allResKeys = new Set([...aResByKey.keys(), ...bResByKey.keys()]);
|
||||
|
||||
const resourceSnapshotDiffs: ResourceSnapshotDiff[] = [];
|
||||
let resourceSnapshotsChanged = 0;
|
||||
|
||||
for (const key of allResKeys) {
|
||||
const aRes = aResByKey.get(key);
|
||||
const bRes = bResByKey.get(key);
|
||||
|
||||
if (aRes && bRes) {
|
||||
const lcrDelta = bRes.lcrCents - aRes.lcrCents;
|
||||
const ucrDelta = bRes.ucrCents - aRes.ucrCents;
|
||||
const locationChanged = (aRes.location ?? null) !== (bRes.location ?? null);
|
||||
const levelChanged = (aRes.level ?? null) !== (bRes.level ?? null);
|
||||
const isChanged = lcrDelta !== 0 || ucrDelta !== 0 || locationChanged || levelChanged;
|
||||
|
||||
if (isChanged) resourceSnapshotsChanged++;
|
||||
|
||||
resourceSnapshotDiffs.push({
|
||||
displayName: bRes.displayName,
|
||||
status: isChanged ? "changed" : "unchanged",
|
||||
...(isChanged ? { lcrDelta, ucrDelta } : {}),
|
||||
a: resDetail(aRes),
|
||||
b: resDetail(bRes),
|
||||
});
|
||||
} else if (bRes) {
|
||||
resourceSnapshotsChanged++;
|
||||
resourceSnapshotDiffs.push({
|
||||
displayName: bRes.displayName,
|
||||
status: "added",
|
||||
b: resDetail(bRes),
|
||||
});
|
||||
} else if (aRes) {
|
||||
resourceSnapshotsChanged++;
|
||||
resourceSnapshotDiffs.push({
|
||||
displayName: aRes.displayName,
|
||||
status: "removed",
|
||||
a: resDetail(aRes),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chapter subtotals ---
|
||||
const chapterMap = new Map<string, { hoursA: number; hoursB: number; costA: number; costB: number }>();
|
||||
|
||||
for (const line of a.demandLines) {
|
||||
const ch = line.chapter ?? "(no chapter)";
|
||||
const entry = chapterMap.get(ch) ?? { hoursA: 0, hoursB: 0, costA: 0, costB: 0 };
|
||||
entry.hoursA += line.hours;
|
||||
entry.costA += line.costTotalCents;
|
||||
chapterMap.set(ch, entry);
|
||||
}
|
||||
for (const line of b.demandLines) {
|
||||
const ch = line.chapter ?? "(no chapter)";
|
||||
const entry = chapterMap.get(ch) ?? { hoursA: 0, hoursB: 0, costA: 0, costB: 0 };
|
||||
entry.hoursB += line.hours;
|
||||
entry.costB += line.costTotalCents;
|
||||
chapterMap.set(ch, entry);
|
||||
}
|
||||
|
||||
const chapterSubtotals: ChapterSubtotal[] = [...chapterMap.entries()]
|
||||
.map(([chapter, v]) => ({
|
||||
chapter,
|
||||
hoursA: v.hoursA,
|
||||
hoursB: v.hoursB,
|
||||
hoursDelta: v.hoursB - v.hoursA,
|
||||
costA: v.costA,
|
||||
costB: v.costB,
|
||||
costDelta: v.costB - v.costA,
|
||||
}))
|
||||
.sort((x, y) => Math.abs(y.costDelta) - Math.abs(x.costDelta));
|
||||
|
||||
// --- Summary ---
|
||||
const aTotalHours = a.demandLines.reduce((s, l) => s + l.hours, 0);
|
||||
const bTotalHours = b.demandLines.reduce((s, l) => s + l.hours, 0);
|
||||
const aTotalCost = a.demandLines.reduce((s, l) => s + l.costTotalCents, 0);
|
||||
const bTotalCost = b.demandLines.reduce((s, l) => s + l.costTotalCents, 0);
|
||||
const aTotalPrice = a.demandLines.reduce((s, l) => s + l.priceTotalCents, 0);
|
||||
const bTotalPrice = b.demandLines.reduce((s, l) => s + l.priceTotalCents, 0);
|
||||
|
||||
const marginPercentA = aTotalPrice > 0 ? ((aTotalPrice - aTotalCost) / aTotalPrice) * 100 : 0;
|
||||
const marginPercentB = bTotalPrice > 0 ? ((bTotalPrice - bTotalCost) / bTotalPrice) * 100 : 0;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalHoursDelta: bTotalHours - aTotalHours,
|
||||
totalCostDelta: bTotalCost - aTotalCost,
|
||||
totalPriceDelta: bTotalPrice - aTotalPrice,
|
||||
marginPercentA,
|
||||
marginPercentB,
|
||||
marginPercentDelta: marginPercentB - marginPercentA,
|
||||
linesAdded: addedInB.length,
|
||||
linesRemoved: removedFromA.length,
|
||||
linesChanged: demandLineDiffs.filter((d) => d.status === "changed").length,
|
||||
assumptionsChanged,
|
||||
scopeItemsAdded,
|
||||
scopeItemsRemoved,
|
||||
scopeItemsChanged,
|
||||
resourceSnapshotsChanged,
|
||||
},
|
||||
demandLineDiffs,
|
||||
assumptionDiffs,
|
||||
scopeItemDiffs,
|
||||
resourceSnapshotDiffs,
|
||||
chapterSubtotals,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Weekly phasing computation for estimate demand lines (4Dispo-style).
|
||||
*
|
||||
* Distributes total hours across ISO 8601 weeks. Supports:
|
||||
* - Even distribution
|
||||
* - Front-loaded (60/40 split)
|
||||
* - Back-loaded (40/60 split)
|
||||
* - Custom per-week overrides
|
||||
* - Aggregation to monthly spread
|
||||
* - Aggregation by chapter for 4Dispo view
|
||||
*/
|
||||
|
||||
export interface WeekDefinition {
|
||||
weekNumber: number;
|
||||
year: number;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
label: string; // e.g. "W12 2026"
|
||||
}
|
||||
|
||||
export interface WeeklyPhasingInput {
|
||||
totalHours: number;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
pattern?: "even" | "front_loaded" | "back_loaded" | "custom";
|
||||
customWeeklyHours?: Record<string, number>; // weekKey "2026-W12" -> hours
|
||||
}
|
||||
|
||||
export interface WeeklyPhasingResult {
|
||||
weeks: WeekDefinition[];
|
||||
weeklyHours: Record<string, number>; // weekKey -> hours
|
||||
totalDistributedHours: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ISO 8601 week number and year for a given date.
|
||||
* Week 1 is the week containing January 4th; weeks start on Monday.
|
||||
*/
|
||||
function getISOWeekData(date: Date): { year: number; week: number } {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
// Set to nearest Thursday: current date + 4 - current day number (Monday=1, Sunday=7)
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
return { year: d.getUTCFullYear(), week };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Monday of the ISO week containing the given date.
|
||||
*/
|
||||
function getISOWeekMonday(date: Date): Date {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7; // Monday=1, Sunday=7
|
||||
d.setUTCDate(d.getUTCDate() - (dayNum - 1));
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date (string or Date) to an ISO week key "YYYY-Www".
|
||||
*/
|
||||
export function weekKeyFromDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const { year, week } = getISOWeekData(d);
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDateISO(d: Date): string {
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ISO week definitions between two dates (inclusive).
|
||||
* Each week covers Monday-Sunday. Partial weeks at the boundaries are included.
|
||||
*/
|
||||
export function generateWeekRange(startDate: string, endDate: string): WeekDefinition[] {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (start > end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const weeks: WeekDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Start from the Monday of the week containing startDate
|
||||
let monday = getISOWeekMonday(start);
|
||||
|
||||
while (monday.getTime() <= end.getTime()) {
|
||||
const { year, week } = getISOWeekData(monday);
|
||||
const key = `${year}-W${String(week).padStart(2, "0")}`;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setUTCDate(sunday.getUTCDate() + 6);
|
||||
|
||||
weeks.push({
|
||||
weekNumber: week,
|
||||
year,
|
||||
startDate: formatDateISO(monday),
|
||||
endDate: formatDateISO(sunday),
|
||||
label: `W${String(week).padStart(2, "0")} ${year}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Move to next Monday
|
||||
monday = new Date(monday);
|
||||
monday.setUTCDate(monday.getUTCDate() + 7);
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute total hours across weeks according to the specified pattern.
|
||||
*/
|
||||
export function distributeHoursToWeeks(input: WeeklyPhasingInput): WeeklyPhasingResult {
|
||||
const { totalHours, startDate, endDate, pattern = "even", customWeeklyHours } = input;
|
||||
const weeks = generateWeekRange(startDate, endDate);
|
||||
|
||||
if (weeks.length === 0) {
|
||||
return { weeks: [], weeklyHours: {}, totalDistributedHours: 0 };
|
||||
}
|
||||
|
||||
const weeklyHours: Record<string, number> = {};
|
||||
|
||||
if (pattern === "custom" && customWeeklyHours) {
|
||||
// Use custom values, defaulting missing weeks to 0
|
||||
for (const week of weeks) {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
weeklyHours[key] = customWeeklyHours[key] ?? 0;
|
||||
}
|
||||
} else if (pattern === "even") {
|
||||
const perWeek = totalHours / weeks.length;
|
||||
for (const week of weeks) {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
weeklyHours[key] = Math.round(perWeek * 100) / 100;
|
||||
}
|
||||
// Fix rounding error on last week
|
||||
adjustRoundingError(weeklyHours, weeks, totalHours);
|
||||
} else if (pattern === "front_loaded") {
|
||||
distributeLoadedPattern(weeklyHours, weeks, totalHours, "front");
|
||||
} else if (pattern === "back_loaded") {
|
||||
distributeLoadedPattern(weeklyHours, weeks, totalHours, "back");
|
||||
}
|
||||
|
||||
const totalDistributedHours =
|
||||
Math.round(Object.values(weeklyHours).reduce((sum, h) => sum + h, 0) * 100) / 100;
|
||||
|
||||
return { weeks, weeklyHours, totalDistributedHours };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute hours with a linear ramp (front or back loaded).
|
||||
* Front: 60% first half, 40% second half with linear decrease
|
||||
* Back: 40% first half, 60% second half with linear increase
|
||||
*/
|
||||
function distributeLoadedPattern(
|
||||
weeklyHours: Record<string, number>,
|
||||
weeks: WeekDefinition[],
|
||||
totalHours: number,
|
||||
direction: "front" | "back",
|
||||
): void {
|
||||
const n = weeks.length;
|
||||
|
||||
if (n === 1) {
|
||||
const key = `${weeks[0]!.year}-W${String(weeks[0]!.weekNumber).padStart(2, "0")}`;
|
||||
weeklyHours[key] = totalHours;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create linear ramp weights
|
||||
// Front: weight decreases from high to low
|
||||
// Back: weight increases from low to high
|
||||
const weights: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (direction === "front") {
|
||||
weights.push(n - i); // n, n-1, ..., 1
|
||||
} else {
|
||||
weights.push(i + 1); // 1, 2, ..., n
|
||||
}
|
||||
}
|
||||
|
||||
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const week = weeks[i]!;
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
weeklyHours[key] = Math.round((totalHours * (weights[i]! / totalWeight)) * 100) / 100;
|
||||
}
|
||||
|
||||
adjustRoundingError(weeklyHours, weeks, totalHours);
|
||||
}
|
||||
|
||||
function adjustRoundingError(
|
||||
weeklyHours: Record<string, number>,
|
||||
weeks: WeekDefinition[],
|
||||
totalHours: number,
|
||||
): void {
|
||||
const distributed = Object.values(weeklyHours).reduce((sum, h) => sum + h, 0);
|
||||
const diff = Math.round((totalHours - distributed) * 100) / 100;
|
||||
if (diff !== 0 && weeks.length > 0) {
|
||||
const lastWeek = weeks[weeks.length - 1]!;
|
||||
const lastKey = `${lastWeek.year}-W${String(lastWeek.weekNumber).padStart(2, "0")}`;
|
||||
weeklyHours[lastKey] = Math.round(((weeklyHours[lastKey] ?? 0) + diff) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert weekly hours (keyed by "YYYY-Www") to monthly totals (keyed by "YYYY-MM").
|
||||
*
|
||||
* Each week's hours are attributed to the month containing the Thursday of that week
|
||||
* (which matches the ISO week-numbering year's month attribution).
|
||||
*/
|
||||
export function aggregateWeeklyToMonthly(
|
||||
weeklyHours: Record<string, number>,
|
||||
): Record<string, number> {
|
||||
const monthly: Record<string, number> = {};
|
||||
|
||||
for (const [weekKey, hours] of Object.entries(weeklyHours)) {
|
||||
const monthKey = weekKeyToMonthKey(weekKey);
|
||||
monthly[monthKey] = Math.round(((monthly[monthKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
|
||||
return monthly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the month key for a week key by finding the Thursday of that week.
|
||||
*/
|
||||
function weekKeyToMonthKey(weekKey: string): string {
|
||||
const match = weekKey.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid week key: ${weekKey}`);
|
||||
}
|
||||
|
||||
const year = parseInt(match[1]!, 10);
|
||||
const week = parseInt(match[2]!, 10);
|
||||
|
||||
// Find January 4th of that year (always in ISO week 1)
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4DayOfWeek = jan4.getUTCDay() || 7; // Monday=1
|
||||
// Monday of week 1
|
||||
const week1Monday = new Date(jan4);
|
||||
week1Monday.setUTCDate(jan4.getUTCDate() - (jan4DayOfWeek - 1));
|
||||
|
||||
// Monday of the target week
|
||||
const targetMonday = new Date(week1Monday);
|
||||
targetMonday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7);
|
||||
|
||||
// Thursday of the target week
|
||||
const thursday = new Date(targetMonday);
|
||||
thursday.setUTCDate(targetMonday.getUTCDate() + 3);
|
||||
|
||||
const monthNum = String(thursday.getUTCMonth() + 1).padStart(2, "0");
|
||||
return `${thursday.getUTCFullYear()}-${monthNum}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate weekly hours across multiple demand lines, grouped by chapter.
|
||||
* Returns a map of chapter -> weekKey -> total hours.
|
||||
* Lines with null/undefined chapter are grouped under "(Unassigned)".
|
||||
*/
|
||||
export function aggregateWeeklyByChapter(
|
||||
lines: Array<{ chapter?: string | null; weeklyHours: Record<string, number> }>,
|
||||
): Record<string, Record<string, number>> {
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const chapter = line.chapter ?? "(Unassigned)";
|
||||
if (!result[chapter]) {
|
||||
result[chapter] = {};
|
||||
}
|
||||
const chapterTotals = result[chapter]!;
|
||||
|
||||
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
|
||||
chapterTotals[weekKey] = Math.round(((chapterTotals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./allocation/index.js";
|
||||
export * from "./blueprint/validator.js";
|
||||
export * from "./budget/index.js";
|
||||
export * from "./estimate/index.js";
|
||||
export * from "./shift/index.js";
|
||||
export * from "./vacation/utils.js";
|
||||
export * from "./sah/index.js";
|
||||
export * from "./chargeability/index.js";
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Standard Available Hours (SAH) calculator.
|
||||
*
|
||||
* SAH = net working time after deducting holidays and absences.
|
||||
* It is the denominator for chargeability calculations.
|
||||
*/
|
||||
|
||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SAHInput {
|
||||
/** Base daily working hours for the country (e.g. 8 for DE, 9 for IN). */
|
||||
dailyWorkingHours: number;
|
||||
/** Optional variable schedule rules (e.g. Spain). */
|
||||
scheduleRules?: SpainScheduleRule | null;
|
||||
/** Resource FTE factor (0.01 – 1.0). Reduces effective daily hours. */
|
||||
fte: number;
|
||||
/** Period start date (inclusive). */
|
||||
periodStart: Date;
|
||||
/** Period end date (inclusive). */
|
||||
periodEnd: Date;
|
||||
/** Public holiday dates within the period (ISO strings or Dates). */
|
||||
publicHolidays: (Date | string)[];
|
||||
/** Absence dates within the period (vacation, illness, other). */
|
||||
absenceDays: (Date | string)[];
|
||||
}
|
||||
|
||||
export interface SAHResult {
|
||||
/** Total calendar days in the period. */
|
||||
calendarDays: number;
|
||||
/** Weekend days in the period. */
|
||||
weekendDays: number;
|
||||
/** Working days (calendar - weekends). */
|
||||
grossWorkingDays: number;
|
||||
/** Public holidays falling on working days. */
|
||||
publicHolidayDays: number;
|
||||
/** Absence days falling on working days (excluding holidays). */
|
||||
absenceDays: number;
|
||||
/** Net working days after holidays and absences. */
|
||||
netWorkingDays: number;
|
||||
/** Average effective hours per working day (after FTE scaling). */
|
||||
effectiveHoursPerDay: number;
|
||||
/** Total Standard Available Hours for the period. */
|
||||
standardAvailableHours: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toISODate(d: Date | string): string {
|
||||
if (typeof d === "string") return d.slice(0, 10);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function isWeekend(date: Date): boolean {
|
||||
const day = date.getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily working hours for a specific date given optional Spain schedule rules.
|
||||
*/
|
||||
export function getDailyHours(
|
||||
date: Date,
|
||||
baseHours: number,
|
||||
scheduleRules?: SpainScheduleRule | null,
|
||||
): number {
|
||||
if (!scheduleRules || scheduleRules.type !== "spain") {
|
||||
return baseHours;
|
||||
}
|
||||
|
||||
const dayOfWeek = date.getUTCDay();
|
||||
|
||||
// Fridays always use fridayHours
|
||||
if (dayOfWeek === 5) {
|
||||
return scheduleRules.fridayHours;
|
||||
}
|
||||
|
||||
// Check if date falls in summer period
|
||||
const month = date.getUTCMonth() + 1;
|
||||
const day = date.getUTCDate();
|
||||
const mmdd = `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
if (mmdd >= scheduleRules.summerPeriod.from && mmdd <= scheduleRules.summerPeriod.to) {
|
||||
return scheduleRules.summerHours;
|
||||
}
|
||||
|
||||
return scheduleRules.regularHours;
|
||||
}
|
||||
|
||||
// ─── Calculator ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function calculateSAH(input: SAHInput): SAHResult {
|
||||
const { dailyWorkingHours, scheduleRules, fte, periodStart, periodEnd, publicHolidays, absenceDays } = input;
|
||||
|
||||
const holidaySet = new Set(publicHolidays.map(toISODate));
|
||||
const absenceSet = new Set(absenceDays.map(toISODate));
|
||||
|
||||
let calendarDays = 0;
|
||||
let weekendDays = 0;
|
||||
let publicHolidayCount = 0;
|
||||
let absenceCount = 0;
|
||||
let totalHoursOnWorkingDays = 0;
|
||||
let netWorkingDays = 0;
|
||||
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
calendarDays++;
|
||||
const iso = cursor.toISOString().slice(0, 10);
|
||||
|
||||
if (isWeekend(cursor)) {
|
||||
weekendDays++;
|
||||
} else if (holidaySet.has(iso)) {
|
||||
publicHolidayCount++;
|
||||
} else if (absenceSet.has(iso)) {
|
||||
absenceCount++;
|
||||
} else {
|
||||
// This is a net working day
|
||||
const hoursForDay = getDailyHours(cursor, dailyWorkingHours, scheduleRules);
|
||||
totalHoursOnWorkingDays += hoursForDay * fte;
|
||||
netWorkingDays++;
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
const grossWorkingDays = calendarDays - weekendDays;
|
||||
const effectiveHoursPerDay = netWorkingDays > 0 ? totalHoursOnWorkingDays / netWorkingDays : dailyWorkingHours * fte;
|
||||
|
||||
return {
|
||||
calendarDays,
|
||||
weekendDays,
|
||||
grossWorkingDays,
|
||||
publicHolidayDays: publicHolidayCount,
|
||||
absenceDays: absenceCount,
|
||||
netWorkingDays,
|
||||
effectiveHoursPerDay: Math.round(effectiveHoursPerDay * 100) / 100,
|
||||
standardAvailableHours: Math.round(totalHoursOnWorkingDays * 100) / 100,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { calculateSAH, getDailyHours } from "./calculator.js";
|
||||
export type { SAHInput, SAHResult } from "./calculator.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./validator.js";
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
Allocation,
|
||||
ConflictDetail,
|
||||
CostImpact,
|
||||
Resource,
|
||||
ShiftValidationResult,
|
||||
ValidationError,
|
||||
ValidationWarning,
|
||||
} from "@planarchy/shared";
|
||||
import { calculateAllocation } from "../allocation/calculator.js";
|
||||
import { validateAvailability } from "../allocation/availability-validator.js";
|
||||
import { computeBudgetStatus } from "../budget/monitor.js";
|
||||
|
||||
export interface ShiftInput {
|
||||
project: {
|
||||
id: string;
|
||||
budgetCents: number;
|
||||
winProbability: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
allocations: (Pick<
|
||||
Allocation,
|
||||
"id" | "resourceId" | "startDate" | "endDate" | "hoursPerDay" | "percentage" | "role" | "dailyCostCents" | "status"
|
||||
> & {
|
||||
resource: Pick<Resource, "id" | "displayName" | "lcrCents" | "availability">;
|
||||
allAllocationsForResource: Pick<Allocation, "id" | "startDate" | "endDate" | "hoursPerDay" | "status" | "projectId">[];
|
||||
/** Extracted from allocation metadata before calling validator */
|
||||
includeSaturday?: boolean;
|
||||
})[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a proposed project timeline shift.
|
||||
*
|
||||
* Pure function — all data passed in, no DB access.
|
||||
* Returns a comprehensive validation result with cost impact and conflicts.
|
||||
*/
|
||||
export function validateShift(input: ShiftInput): ShiftValidationResult {
|
||||
const { project, newStartDate, newEndDate, allocations } = input;
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
const conflictDetails: ConflictDetail[] = [];
|
||||
|
||||
const durationMs = project.endDate.getTime() - project.startDate.getTime();
|
||||
const newDurationMs = newEndDate.getTime() - newStartDate.getTime();
|
||||
|
||||
// Validate date range
|
||||
if (newEndDate < newStartDate) {
|
||||
errors.push({
|
||||
code: "INVALID_DATE_RANGE",
|
||||
message: "New end date must be after new start date",
|
||||
field: "newEndDate",
|
||||
});
|
||||
return buildResult(false, errors, warnings, conflictDetails, buildZeroCostImpact(project.budgetCents));
|
||||
}
|
||||
|
||||
// Warn if duration changed significantly
|
||||
const durationDeltaDays = Math.abs((newDurationMs - durationMs) / (1000 * 60 * 60 * 24));
|
||||
if (durationDeltaDays > 7) {
|
||||
warnings.push({
|
||||
code: "DURATION_CHANGED",
|
||||
message: `Project duration changed by ${Math.round(durationDeltaDays)} days`,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate shifted allocation dates and check availability
|
||||
let newTotalCostCents = 0;
|
||||
let currentTotalCostCents = 0;
|
||||
|
||||
for (const alloc of allocations) {
|
||||
const { resource, allAllocationsForResource } = alloc;
|
||||
const includeSaturday = alloc.includeSaturday ?? false;
|
||||
|
||||
// Compute current allocation cost
|
||||
const currentCalc = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: alloc.hoursPerDay,
|
||||
startDate: new Date(alloc.startDate),
|
||||
endDate: new Date(alloc.endDate),
|
||||
availability: resource.availability,
|
||||
includeSaturday,
|
||||
});
|
||||
currentTotalCostCents += currentCalc.totalCostCents;
|
||||
|
||||
// Shift allocation proportionally within the new project window
|
||||
const shiftedStart = new Date(newStartDate);
|
||||
const shiftedEnd = new Date(newEndDate);
|
||||
|
||||
// Validate availability for shifted period (excluding current project's allocations)
|
||||
const otherAllocations = allAllocationsForResource.filter(
|
||||
(a) => a.projectId !== project.id,
|
||||
);
|
||||
const availResult = validateAvailability(
|
||||
shiftedStart,
|
||||
shiftedEnd,
|
||||
alloc.hoursPerDay,
|
||||
resource.availability,
|
||||
otherAllocations,
|
||||
includeSaturday,
|
||||
);
|
||||
|
||||
if (!availResult.valid) {
|
||||
const conflictDays = availResult.conflicts.map(
|
||||
(c) => c.date.toISOString().split("T")[0] ?? "",
|
||||
);
|
||||
conflictDetails.push({
|
||||
resourceId: resource.id,
|
||||
resourceName: resource.displayName,
|
||||
conflictType: "availability",
|
||||
days: conflictDays,
|
||||
message: `${resource.displayName} has availability conflicts on ${availResult.totalConflictDays} day(s)`,
|
||||
});
|
||||
|
||||
if (availResult.totalConflictDays > 5) {
|
||||
errors.push({
|
||||
code: "AVAILABILITY_CONFLICT",
|
||||
message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) exceed capacity`,
|
||||
resourceId: resource.id,
|
||||
});
|
||||
} else {
|
||||
warnings.push({
|
||||
code: "AVAILABILITY_WARNING",
|
||||
message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) may have capacity issues`,
|
||||
resourceId: resource.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compute new cost for shifted period
|
||||
const newCalc = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: alloc.hoursPerDay,
|
||||
startDate: shiftedStart,
|
||||
endDate: shiftedEnd,
|
||||
availability: resource.availability,
|
||||
includeSaturday,
|
||||
});
|
||||
newTotalCostCents += newCalc.totalCostCents;
|
||||
}
|
||||
|
||||
// Budget impact check
|
||||
const currentStatus = computeBudgetStatus(
|
||||
project.budgetCents,
|
||||
project.winProbability,
|
||||
allocations.map((a) => ({
|
||||
status: a.status,
|
||||
dailyCostCents: a.dailyCostCents,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
})),
|
||||
project.startDate,
|
||||
project.endDate,
|
||||
);
|
||||
|
||||
const wouldExceedBudget = newTotalCostCents > project.budgetCents;
|
||||
if (wouldExceedBudget) {
|
||||
errors.push({
|
||||
code: "BUDGET_EXCEEDED",
|
||||
message: `Shift would exceed budget by ${((newTotalCostCents - project.budgetCents) / 100).toFixed(2)} EUR`,
|
||||
});
|
||||
} else if (newTotalCostCents > project.budgetCents * 0.95) {
|
||||
warnings.push({
|
||||
code: "BUDGET_NEAR_LIMIT",
|
||||
message: `Shift would use ${((newTotalCostCents / project.budgetCents) * 100).toFixed(1)}% of budget`,
|
||||
});
|
||||
}
|
||||
|
||||
const costImpact: CostImpact = {
|
||||
currentTotalCents: currentTotalCostCents,
|
||||
newTotalCents: newTotalCostCents,
|
||||
deltaCents: newTotalCostCents - currentTotalCostCents,
|
||||
budgetCents: project.budgetCents,
|
||||
budgetUtilizationBefore: currentStatus.utilizationPercent,
|
||||
budgetUtilizationAfter:
|
||||
project.budgetCents > 0 ? (newTotalCostCents / project.budgetCents) * 100 : 0,
|
||||
wouldExceedBudget,
|
||||
};
|
||||
|
||||
return buildResult(errors.length === 0, errors, warnings, conflictDetails, costImpact);
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
valid: boolean,
|
||||
errors: ValidationError[],
|
||||
warnings: ValidationWarning[],
|
||||
conflictDetails: ConflictDetail[],
|
||||
costImpact: CostImpact,
|
||||
): ShiftValidationResult {
|
||||
return { valid, errors, warnings, costImpact, conflictDetails };
|
||||
}
|
||||
|
||||
function buildZeroCostImpact(budgetCents: number): CostImpact {
|
||||
return {
|
||||
currentTotalCents: 0,
|
||||
newTotalCents: 0,
|
||||
deltaCents: 0,
|
||||
budgetCents,
|
||||
budgetUtilizationBefore: 0,
|
||||
budgetUtilizationAfter: 0,
|
||||
wouldExceedBudget: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Vacation utility functions for the engine layer.
|
||||
* These are pure functions — no DB imports.
|
||||
*/
|
||||
|
||||
export interface VacationRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given date falls within any APPROVED vacation range.
|
||||
*/
|
||||
export function isVacationDay(date: Date, vacations: VacationRange[]): boolean {
|
||||
const t = new Date(date);
|
||||
t.setHours(0, 0, 0, 0);
|
||||
const time = t.getTime();
|
||||
|
||||
for (const v of vacations) {
|
||||
if (v.status !== "APPROVED") continue;
|
||||
const start = new Date(v.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(v.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
if (time >= start.getTime() && time <= end.getTime()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all APPROVED vacation dates (as Date objects) that fall within [startDate, endDate].
|
||||
* Used for demand recalculation display.
|
||||
*/
|
||||
export function getVacationDatesInRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
vacations: VacationRange[],
|
||||
): Date[] {
|
||||
const result: Date[] = [];
|
||||
const approved = vacations.filter((v) => v.status === "APPROVED");
|
||||
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= end) {
|
||||
for (const v of approved) {
|
||||
const vStart = new Date(v.startDate);
|
||||
vStart.setHours(0, 0, 0, 0);
|
||||
const vEnd = new Date(v.endDate);
|
||||
vEnd.setHours(0, 0, 0, 0);
|
||||
if (current >= vStart && current <= vEnd) {
|
||||
result.push(new Date(current));
|
||||
break;
|
||||
}
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@planarchy/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
thresholds: {
|
||||
lines: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
statements: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user