chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user