refactor(api): split project graph estimate assembly
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
import {
|
||||
computeBudgetStatus,
|
||||
computeEvenSpread,
|
||||
distributeHoursToWeeks,
|
||||
summarizeEstimateDemandLines,
|
||||
} from "@capakraken/engine";
|
||||
import { computeBudgetStatus } from "@capakraken/engine";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import { buildProjectEstimateGraph } from "./computation-graph-project-estimate.js";
|
||||
import { loadProjectGraphSnapshot, type ProjectGraphInput } from "./computation-graph-project-snapshot.js";
|
||||
import { type GraphLink, type GraphNode, fmtNum, l, n } from "./computation-graph-shared.js";
|
||||
import { type GraphLink, type GraphNode, l, n } from "./computation-graph-shared.js";
|
||||
|
||||
export async function readProjectGraphSnapshot(
|
||||
ctx: Parameters<typeof loadProjectGraphSnapshot>[0],
|
||||
@@ -34,228 +30,14 @@ export async function readProjectGraphSnapshot(
|
||||
] : []),
|
||||
);
|
||||
|
||||
if (latestVersion && latestVersion.demandLines.length > 0) {
|
||||
const lines = latestVersion.demandLines;
|
||||
const summary = summarizeEstimateDemandLines(lines);
|
||||
const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary;
|
||||
|
||||
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
|
||||
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;
|
||||
|
||||
const chapterMap = new Map<string, number>();
|
||||
for (const demandLine of lines) {
|
||||
const chapter = demandLine.chapter ?? "(none)";
|
||||
chapterMap.set(chapter, (chapterMap.get(chapter) ?? 0) + demandLine.hours);
|
||||
}
|
||||
const chapterCount = chapterMap.size;
|
||||
const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0;
|
||||
|
||||
nodes.push(
|
||||
n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0),
|
||||
n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0),
|
||||
n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0),
|
||||
...(snapshotCount > 0 ? [
|
||||
n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0),
|
||||
] : []),
|
||||
n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"),
|
||||
n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"),
|
||||
n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"),
|
||||
n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"),
|
||||
n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"),
|
||||
...(chapterCount > 1 ? [
|
||||
n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1),
|
||||
] : []),
|
||||
);
|
||||
|
||||
links.push(
|
||||
l("input.estLines", "est.totalHours", "Σ hours", 1),
|
||||
l("input.avgCostRate", "est.totalCostCents", "× hours", 2),
|
||||
l("est.totalHours", "est.totalCostCents", "× costRate", 2),
|
||||
l("input.avgBillRate", "est.totalPriceCents", "× hours", 2),
|
||||
l("est.totalHours", "est.totalPriceCents", "× billRate", 2),
|
||||
l("est.totalPriceCents", "est.marginCents", "−", 2),
|
||||
l("est.totalCostCents", "est.marginCents", "−", 2),
|
||||
l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
|
||||
l("est.totalPriceCents", "est.marginPercent", "÷", 1),
|
||||
...(snapshotCount > 0 ? [
|
||||
l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1),
|
||||
l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1),
|
||||
] : []),
|
||||
...(chapterCount > 1 ? [
|
||||
l("input.estLines", "est.chapters", "group by", 1),
|
||||
l("est.chapters", "est.totalHours", "Σ per chapter", 1),
|
||||
] : []),
|
||||
);
|
||||
|
||||
const scopeItems = latestVersion.scopeItems ?? [];
|
||||
if (scopeItems.length > 0) {
|
||||
const totalFrameCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.frameCount ?? 0), 0);
|
||||
const totalItemCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.itemCount ?? 0), 0);
|
||||
const scopeTypes = new Set(scopeItems.map((scopeItem) => scopeItem.scopeType));
|
||||
|
||||
nodes.push(
|
||||
n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0),
|
||||
...(totalFrameCount > 0 ? [
|
||||
n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1),
|
||||
] : []),
|
||||
...(totalItemCount > 0 ? [
|
||||
n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1),
|
||||
] : []),
|
||||
n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0),
|
||||
n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"),
|
||||
);
|
||||
|
||||
links.push(
|
||||
l("effort.scopeItems", "effort.expandedHours", "expand", 2),
|
||||
l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2),
|
||||
...(totalFrameCount > 0 ? [
|
||||
l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1),
|
||||
l("effort.totalFrames", "effort.expandedHours", "per_frame", 1),
|
||||
] : []),
|
||||
...(totalItemCount > 0 ? [
|
||||
l("effort.scopeItems", "effort.totalItems", "Σ items", 1),
|
||||
l("effort.totalItems", "effort.expandedHours", "per_item", 1),
|
||||
] : []),
|
||||
l("effort.expandedHours", "est.totalHours", "→ demand lines", 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (experienceRuleCount > 0) {
|
||||
nodes.push(
|
||||
n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0),
|
||||
n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"),
|
||||
n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"),
|
||||
n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"),
|
||||
n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"),
|
||||
);
|
||||
|
||||
links.push(
|
||||
l("exp.ruleCount", "exp.costMultiplier", "match rule", 1),
|
||||
l("exp.ruleCount", "exp.billMultiplier", "match rule", 1),
|
||||
l("exp.ruleCount", "exp.shoringRatio", "match rule", 1),
|
||||
l("exp.costMultiplier", "exp.adjustedRates", "×", 2),
|
||||
l("exp.billMultiplier", "exp.adjustedRates", "×", 2),
|
||||
l("exp.shoringRatio", "exp.adjustedRates", "adjust hours", 1),
|
||||
l("exp.adjustedRates", "est.totalCostCents", "→ costRate", 1),
|
||||
l("exp.adjustedRates", "est.totalPriceCents", "→ billRate", 1),
|
||||
);
|
||||
}
|
||||
|
||||
const terms = latestVersion.commercialTerms as {
|
||||
contingencyPercent?: number;
|
||||
discountPercent?: number;
|
||||
pricingModel?: string;
|
||||
paymentTermDays?: number;
|
||||
warrantyMonths?: number;
|
||||
paymentMilestones?: Array<{ label: string; percent: number; dueDate?: string | null }>;
|
||||
} | null;
|
||||
|
||||
const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent);
|
||||
const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths);
|
||||
|
||||
if (hasCommercialAdjustments) {
|
||||
const contingencyPct = terms!.contingencyPercent ?? 0;
|
||||
const discountPct = terms!.discountPercent ?? 0;
|
||||
const contingencyCents = Math.round((totalCostCents * contingencyPct) / 100);
|
||||
const discountCents = Math.round((totalPriceCents * discountPct) / 100);
|
||||
const adjustedCost = totalCostCents + contingencyCents;
|
||||
const adjustedPrice = totalPriceCents - discountCents;
|
||||
const adjustedMargin = adjustedPrice - adjustedCost;
|
||||
const adjustedMarginPct = adjustedPrice > 0 ? (adjustedMargin / adjustedPrice) * 100 : 0;
|
||||
|
||||
nodes.push(
|
||||
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0),
|
||||
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0),
|
||||
n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"),
|
||||
n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"),
|
||||
n("comm.adjustedCost", "Adj. Cost", fmtEur(adjustedCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
|
||||
n("comm.adjustedPrice", "Adj. Price", fmtEur(adjustedPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
|
||||
n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjustedMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
|
||||
n("comm.adjustedMarginPct", "Adj. Margin %", `${adjustedMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"),
|
||||
);
|
||||
|
||||
links.push(
|
||||
l("est.totalCostCents", "comm.contingencyCents", "×", 1),
|
||||
l("input.contingencyPct", "comm.contingencyCents", "× %", 1),
|
||||
l("est.totalPriceCents", "comm.discountCents", "×", 1),
|
||||
l("input.discountPct", "comm.discountCents", "× %", 1),
|
||||
l("est.totalCostCents", "comm.adjustedCost", "+", 2),
|
||||
l("comm.contingencyCents", "comm.adjustedCost", "+", 2),
|
||||
l("est.totalPriceCents", "comm.adjustedPrice", "−", 2),
|
||||
l("comm.discountCents", "comm.adjustedPrice", "−", 2),
|
||||
l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2),
|
||||
l("comm.adjustedCost", "comm.adjustedMargin", "−", 2),
|
||||
l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2),
|
||||
l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) {
|
||||
if (terms!.pricingModel) {
|
||||
nodes.push(
|
||||
n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0),
|
||||
);
|
||||
}
|
||||
if (terms!.paymentTermDays) {
|
||||
nodes.push(
|
||||
n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0),
|
||||
);
|
||||
}
|
||||
if (terms!.warrantyMonths) {
|
||||
nodes.push(
|
||||
n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0),
|
||||
);
|
||||
}
|
||||
const milestones = terms!.paymentMilestones ?? [];
|
||||
if (milestones.length > 0) {
|
||||
nodes.push(
|
||||
n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((milestone) => `${milestone.label}: ${milestone.percent}%`).join(", ")})`, 2),
|
||||
n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((sum, milestone) => sum + milestone.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"),
|
||||
);
|
||||
links.push(
|
||||
l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDateRange) {
|
||||
const spreadResult = computeEvenSpread({
|
||||
totalHours,
|
||||
startDate: project.startDate!,
|
||||
endDate: project.endDate!,
|
||||
});
|
||||
const weeklyResult = distributeHoursToWeeks({
|
||||
totalHours,
|
||||
startDate: project.startDate!.toISOString().slice(0, 10),
|
||||
endDate: project.endDate!.toISOString().slice(0, 10),
|
||||
pattern: "even",
|
||||
});
|
||||
const hasManualSpreads = lines.some((demandLine) => {
|
||||
const spread = demandLine.monthlySpread as Record<string, number> | null;
|
||||
return spread && Object.keys(spread).length > 0;
|
||||
});
|
||||
|
||||
nodes.push(
|
||||
n("spread.monthCount", "Months", `${spreadResult.months.length}`, "count", "SPREAD", `${spreadResult.months.length} months in project date range`, 1),
|
||||
n("spread.weekCount", "Weeks", `${weeklyResult.weeks.length}`, "count", "SPREAD", `${weeklyResult.weeks.length} ISO weeks in project date range`, 1),
|
||||
n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"),
|
||||
n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"),
|
||||
n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"),
|
||||
);
|
||||
|
||||
links.push(
|
||||
l("input.projectStart", "spread.monthCount", "→ range", 1),
|
||||
l("input.projectEnd", "spread.monthCount", "→ range", 1),
|
||||
l("input.projectStart", "spread.weekCount", "→ range", 1),
|
||||
l("input.projectEnd", "spread.weekCount", "→ range", 1),
|
||||
l("est.totalHours", "spread.monthlySpread", "distribute", 2),
|
||||
l("spread.monthCount", "spread.monthlySpread", "÷ by workdays", 1),
|
||||
l("est.totalHours", "spread.weeklyPhasing", "distribute", 2),
|
||||
l("spread.weekCount", "spread.weeklyPhasing", "÷ by weeks", 1),
|
||||
l("spread.weeklyPhasing", "spread.totalDistributed", "Σ", 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
const estimateGraph = buildProjectEstimateGraph({
|
||||
project,
|
||||
latestVersion,
|
||||
effortRuleCount,
|
||||
experienceRuleCount,
|
||||
});
|
||||
nodes.push(...estimateGraph.nodes);
|
||||
links.push(...estimateGraph.links);
|
||||
|
||||
if (projectAllocations.length > 0) {
|
||||
const budgetStatus = computeBudgetStatus(
|
||||
@@ -295,8 +77,8 @@ export async function readProjectGraphSnapshot(
|
||||
l("input.winProbability", "budget.weightedCents", "×", 1),
|
||||
);
|
||||
|
||||
if (latestVersion && latestVersion.demandLines.length > 0) {
|
||||
const estimatedCost = latestVersion.demandLines.reduce((sum, demandLine) => sum + demandLine.costTotalCents, 0);
|
||||
if (estimateGraph.estimatedCostCents != null) {
|
||||
const estimatedCost = estimateGraph.estimatedCostCents;
|
||||
const gapCents = budgetStatus.allocatedCents - estimatedCost;
|
||||
nodes.push(
|
||||
n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
|
||||
|
||||
Reference in New Issue
Block a user