chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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);
}
+7
View File
@@ -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";
+209
View File
@@ -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;
}