diff --git a/packages/api/src/router/computation-graph-project-estimate.ts b/packages/api/src/router/computation-graph-project-estimate.ts new file mode 100644 index 0000000..ad12f90 --- /dev/null +++ b/packages/api/src/router/computation-graph-project-estimate.ts @@ -0,0 +1,260 @@ +import { + computeEvenSpread, + distributeHoursToWeeks, + summarizeEstimateDemandLines, +} from "@capakraken/engine"; +import { fmtEur } from "../lib/format-utils.js"; +import type { ProjectGraphSnapshot } from "./computation-graph-project-snapshot.js"; +import { type GraphLink, type GraphNode, fmtNum, l, n } from "./computation-graph-shared.js"; + +type ProjectEstimateGraphInput = { + project: ProjectGraphSnapshot["project"]; + latestVersion: ProjectGraphSnapshot["latestVersion"]; + effortRuleCount: number; + experienceRuleCount: number; +}; + +export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): { + estimatedCostCents: number | null; + links: GraphLink[]; + nodes: GraphNode[]; +} { + const { project, latestVersion, effortRuleCount, experienceRuleCount } = input; + const nodes: GraphNode[] = []; + const links: GraphLink[] = []; + + if (!latestVersion || latestVersion.demandLines.length === 0) { + return { + estimatedCostCents: null, + nodes, + links, + }; + } + + 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(); + 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 (project.startDate && project.endDate) { + 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 | 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), + ); + } + + return { + estimatedCostCents: totalCostCents, + nodes, + links, + }; +} diff --git a/packages/api/src/router/computation-graph-project.ts b/packages/api/src/router/computation-graph-project.ts index 1e6c575..2027303 100644 --- a/packages/api/src/router/computation-graph-project.ts +++ b/packages/api/src/router/computation-graph-project.ts @@ -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[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(); - 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 | 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",