refactor(api): extract computation graph project snapshot
This commit is contained in:
@@ -315,4 +315,24 @@ describe("computation graph router", () => {
|
|||||||
expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
|
expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
|
||||||
expect(result.links?.length).toBe(result.selectedLinkCount);
|
expect(result.links?.length).toBe(result.selectedLinkCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the project snapshot graph through the base router contract", async () => {
|
||||||
|
const db = createProjectDb(vi.fn().mockResolvedValue(buildProject()));
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getProjectData({
|
||||||
|
projectId: "project_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.meta).toEqual({
|
||||||
|
projectName: "Gelddruckmaschine",
|
||||||
|
projectCode: "GDM",
|
||||||
|
});
|
||||||
|
expect(result.nodes.map((node) => node.id)).toEqual(expect.arrayContaining([
|
||||||
|
"input.budgetCents",
|
||||||
|
"input.winProbability",
|
||||||
|
"budget.allocatedCents",
|
||||||
|
]));
|
||||||
|
expect(result.links.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import {
|
||||||
|
computeBudgetStatus,
|
||||||
|
computeEvenSpread,
|
||||||
|
distributeHoursToWeeks,
|
||||||
|
summarizeEstimateDemandLines,
|
||||||
|
} from "@capakraken/engine";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
import { type GraphLink, type GraphNode, fmtNum, l, n } from "./computation-graph-shared.js";
|
||||||
|
|
||||||
|
type ProjectGraphInput = {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readProjectGraphSnapshot(
|
||||||
|
ctx: { db: TRPCContext["db"] },
|
||||||
|
input: ProjectGraphInput,
|
||||||
|
) {
|
||||||
|
const project = await ctx.db.project.findUniqueOrThrow({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortCode: true,
|
||||||
|
budgetCents: true,
|
||||||
|
winProbability: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimate = await ctx.db.estimate.findFirst({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
commercialTerms: true,
|
||||||
|
demandLines: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hours: true,
|
||||||
|
costRateCents: true,
|
||||||
|
billRateCents: true,
|
||||||
|
costTotalCents: true,
|
||||||
|
priceTotalCents: true,
|
||||||
|
chapter: true,
|
||||||
|
monthlySpread: true,
|
||||||
|
scopeItemId: true,
|
||||||
|
resourceId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scopeItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
scopeType: true,
|
||||||
|
frameCount: true,
|
||||||
|
itemCount: true,
|
||||||
|
unitMode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceSnapshots: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
resourceId: true,
|
||||||
|
displayName: true,
|
||||||
|
chapter: true,
|
||||||
|
lcrCents: true,
|
||||||
|
ucrCents: true,
|
||||||
|
location: true,
|
||||||
|
level: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
const latestVersion = estimate?.versions[0];
|
||||||
|
|
||||||
|
let effortRuleCount = 0;
|
||||||
|
let experienceRuleCount = 0;
|
||||||
|
try {
|
||||||
|
effortRuleCount = await ctx.db.effortRule.count();
|
||||||
|
experienceRuleCount = await ctx.db.experienceMultiplierRule.count();
|
||||||
|
} catch {
|
||||||
|
// tables may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: GraphNode[] = [];
|
||||||
|
const links: GraphLink[] = [];
|
||||||
|
|
||||||
|
const hasBudget = project.budgetCents > 0;
|
||||||
|
const hasDateRange = !!(project.startDate && project.endDate);
|
||||||
|
nodes.push(
|
||||||
|
n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
|
||||||
|
n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
|
||||||
|
...(hasDateRange ? [
|
||||||
|
n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0),
|
||||||
|
n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0),
|
||||||
|
] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 projectAllocations = await ctx.db.assignment.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projectAllocations.length > 0) {
|
||||||
|
const budgetStatus = computeBudgetStatus(
|
||||||
|
project.budgetCents,
|
||||||
|
project.winProbability,
|
||||||
|
projectAllocations.map((allocation) => ({
|
||||||
|
status: allocation.status as unknown as string,
|
||||||
|
dailyCostCents: allocation.dailyCostCents,
|
||||||
|
startDate: allocation.startDate,
|
||||||
|
endDate: allocation.endDate,
|
||||||
|
hoursPerDay: allocation.hoursPerDay,
|
||||||
|
})) as Parameters<typeof computeBudgetStatus>[2],
|
||||||
|
project.startDate ?? new Date(),
|
||||||
|
project.endDate ?? new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
|
||||||
|
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
|
||||||
|
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
|
||||||
|
n("budget.remainingCents", "Remaining", hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A", hasBudget ? "EUR" : "—", "BUDGET", hasBudget ? "Remaining budget" : "Cannot compute — no budget set", 3, hasBudget ? "budget - allocated" : "needs budget"),
|
||||||
|
n("budget.utilizationPct", "Utilization", hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A", hasBudget ? "%" : "—", "BUDGET", hasBudget ? "Budget utilization" : "Cannot compute — no budget set", 3, hasBudget ? "allocated / budget × 100" : "needs budget"),
|
||||||
|
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
|
||||||
|
n("budget.allocCount", "Allocations", `${projectAllocations.length}`, "count", "BUDGET", `${projectAllocations.length} resource allocations on project`, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
links.push(
|
||||||
|
l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1),
|
||||||
|
l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1),
|
||||||
|
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
|
||||||
|
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
|
||||||
|
l("input.budgetCents", "budget.remainingCents", "−", 2),
|
||||||
|
l("budget.allocatedCents", "budget.remainingCents", "−", 2),
|
||||||
|
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
|
||||||
|
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
|
||||||
|
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
|
||||||
|
l("input.winProbability", "budget.weightedCents", "×", 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (latestVersion && latestVersion.demandLines.length > 0) {
|
||||||
|
const estimatedCost = latestVersion.demandLines.reduce((sum, demandLine) => sum + demandLine.costTotalCents, 0);
|
||||||
|
const gapCents = budgetStatus.allocatedCents - estimatedCost;
|
||||||
|
nodes.push(
|
||||||
|
n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
|
||||||
|
gapCents > 0
|
||||||
|
? `Actual allocations exceed estimate by ${fmtEur(gapCents)}`
|
||||||
|
: gapCents < 0
|
||||||
|
? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}`
|
||||||
|
: "Actual allocations match estimate",
|
||||||
|
3, "allocated - estCost"),
|
||||||
|
);
|
||||||
|
links.push(
|
||||||
|
l("budget.allocatedCents", "budget.estVsActualGap", "−", 1),
|
||||||
|
l("est.totalCostCents", "budget.estVsActualGap", "−", 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
links,
|
||||||
|
meta: {
|
||||||
|
projectName: project.name,
|
||||||
|
projectCode: project.shortCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
type Domain =
|
||||||
|
| "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET"
|
||||||
|
| "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD";
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
unit: string;
|
||||||
|
domain: Domain;
|
||||||
|
description: string;
|
||||||
|
formula?: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
formula: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function n(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
value: number | string,
|
||||||
|
unit: string,
|
||||||
|
domain: Domain,
|
||||||
|
description: string,
|
||||||
|
level: number,
|
||||||
|
formula?: string,
|
||||||
|
): GraphNode {
|
||||||
|
return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function l(source: string, target: string, formula: string, weight = 1): GraphLink {
|
||||||
|
return { source, target, formula, weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPct(ratio: number): string {
|
||||||
|
return `${(ratio * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtNum(value: number, decimals = 1): string {
|
||||||
|
return value.toFixed(decimals);
|
||||||
|
}
|
||||||
@@ -5,9 +5,6 @@ import {
|
|||||||
computeBudgetStatus,
|
computeBudgetStatus,
|
||||||
getMonthRange,
|
getMonthRange,
|
||||||
DEFAULT_CALCULATION_RULES,
|
DEFAULT_CALCULATION_RULES,
|
||||||
summarizeEstimateDemandLines,
|
|
||||||
computeEvenSpread,
|
|
||||||
distributeHoursToWeeks,
|
|
||||||
type AssignmentSlice,
|
type AssignmentSlice,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||||
@@ -26,51 +23,9 @@ import {
|
|||||||
loadResourceDailyAvailabilityContexts,
|
loadResourceDailyAvailabilityContexts,
|
||||||
} from "../lib/resource-capacity.js";
|
} from "../lib/resource-capacity.js";
|
||||||
import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js";
|
import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js";
|
||||||
|
import { readProjectGraphSnapshot } from "./computation-graph-project.js";
|
||||||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
import { type GraphLink, type GraphNode, fmtPct, fmtNum, l, n } from "./computation-graph-shared.js";
|
||||||
|
export type { GraphLink, GraphNode } from "./computation-graph-shared.js";
|
||||||
type Domain =
|
|
||||||
| "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET"
|
|
||||||
| "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD";
|
|
||||||
|
|
||||||
export interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: number | string;
|
|
||||||
unit: string;
|
|
||||||
domain: Domain;
|
|
||||||
description: string;
|
|
||||||
formula?: string;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphLink {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
formula: string;
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function n(
|
|
||||||
id: string, label: string, value: number | string, unit: string,
|
|
||||||
domain: Domain, description: string, level: number, formula?: string,
|
|
||||||
): GraphNode {
|
|
||||||
return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function l(source: string, target: string, formula: string, weight = 1): GraphLink {
|
|
||||||
return { source, target, formula, weight };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtPct(ratio: number): string {
|
|
||||||
return `${(ratio * 100).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(v: number, decimals = 1): string {
|
|
||||||
return v.toFixed(decimals);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailabilityHoursForDate(
|
function getAvailabilityHoursForDate(
|
||||||
availability: WeekdayAvailability,
|
availability: WeekdayAvailability,
|
||||||
@@ -660,404 +615,3 @@ async function readResourceGraphSnapshot(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readProjectGraphSnapshot(
|
|
||||||
ctx: { db: TRPCContext["db"] },
|
|
||||||
input: z.infer<typeof projectGraphInputSchema>,
|
|
||||||
) {
|
|
||||||
const project = await ctx.db.project.findUniqueOrThrow({
|
|
||||||
where: { id: input.projectId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
shortCode: true,
|
|
||||||
budgetCents: true,
|
|
||||||
winProbability: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const estimate = await ctx.db.estimate.findFirst({
|
|
||||||
where: { projectId: input.projectId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
versions: {
|
|
||||||
orderBy: { versionNumber: "desc" },
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
commercialTerms: true,
|
|
||||||
demandLines: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
hours: true,
|
|
||||||
costRateCents: true,
|
|
||||||
billRateCents: true,
|
|
||||||
costTotalCents: true,
|
|
||||||
priceTotalCents: true,
|
|
||||||
chapter: true,
|
|
||||||
monthlySpread: true,
|
|
||||||
scopeItemId: true,
|
|
||||||
resourceId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scopeItems: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
scopeType: true,
|
|
||||||
frameCount: true,
|
|
||||||
itemCount: true,
|
|
||||||
unitMode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resourceSnapshots: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
resourceId: true,
|
|
||||||
displayName: true,
|
|
||||||
chapter: true,
|
|
||||||
lcrCents: true,
|
|
||||||
ucrCents: true,
|
|
||||||
location: true,
|
|
||||||
level: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
});
|
|
||||||
const latestVersion = estimate?.versions[0];
|
|
||||||
|
|
||||||
let effortRuleCount = 0;
|
|
||||||
let experienceRuleCount = 0;
|
|
||||||
try {
|
|
||||||
effortRuleCount = await ctx.db.effortRule.count();
|
|
||||||
experienceRuleCount = await ctx.db.experienceMultiplierRule.count();
|
|
||||||
} catch {
|
|
||||||
// tables may not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: GraphNode[] = [];
|
|
||||||
const links: GraphLink[] = [];
|
|
||||||
|
|
||||||
const hasBudget = project.budgetCents > 0;
|
|
||||||
const hasDateRange = !!(project.startDate && project.endDate);
|
|
||||||
nodes.push(
|
|
||||||
n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
|
|
||||||
n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
|
|
||||||
...(hasDateRange ? [
|
|
||||||
n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0),
|
|
||||||
n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0),
|
|
||||||
] : []),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 dl of lines) {
|
|
||||||
const ch = dl.chapter ?? "(none)";
|
|
||||||
chapterMap.set(ch, (chapterMap.get(ch) ?? 0) + dl.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((s, si) => s + (si.frameCount ?? 0), 0);
|
|
||||||
const totalItemCount = scopeItems.reduce((s, si) => s + (si.itemCount ?? 0), 0);
|
|
||||||
const scopeTypes = new Set(scopeItems.map((si) => si.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 adjCost = totalCostCents + contingencyCents;
|
|
||||||
const adjPrice = totalPriceCents - discountCents;
|
|
||||||
const adjMargin = adjPrice - adjCost;
|
|
||||||
const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 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(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
|
|
||||||
n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
|
|
||||||
n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
|
|
||||||
n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.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((m) => `${m.label}: ${m.percent}%`).join(", ")})`, 2),
|
|
||||||
n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((s, m) => s + m.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 monthCount = spreadResult.months.length;
|
|
||||||
|
|
||||||
const weeklyResult = distributeHoursToWeeks({
|
|
||||||
totalHours,
|
|
||||||
startDate: project.startDate!.toISOString().slice(0, 10),
|
|
||||||
endDate: project.endDate!.toISOString().slice(0, 10),
|
|
||||||
pattern: "even",
|
|
||||||
});
|
|
||||||
const weekCount = weeklyResult.weeks.length;
|
|
||||||
|
|
||||||
const hasManualSpreads = lines.some((dl) => {
|
|
||||||
const spread = dl.monthlySpread as Record<string, number> | null;
|
|
||||||
return spread && Object.keys(spread).length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
n("spread.monthCount", "Months", `${monthCount}`, "count", "SPREAD", `${monthCount} months in project date range`, 1),
|
|
||||||
n("spread.weekCount", "Weeks", `${weekCount}`, "count", "SPREAD", `${weekCount} 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 projectAllocs = await ctx.db.assignment.findMany({
|
|
||||||
where: { projectId: input.projectId },
|
|
||||||
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (projectAllocs.length > 0) {
|
|
||||||
const budgetStatus = computeBudgetStatus(
|
|
||||||
project.budgetCents,
|
|
||||||
project.winProbability,
|
|
||||||
projectAllocs.map((pa) => ({
|
|
||||||
status: pa.status as unknown as string,
|
|
||||||
dailyCostCents: pa.dailyCostCents,
|
|
||||||
startDate: pa.startDate,
|
|
||||||
endDate: pa.endDate,
|
|
||||||
hoursPerDay: pa.hoursPerDay,
|
|
||||||
})) as Parameters<typeof computeBudgetStatus>[2],
|
|
||||||
project.startDate ?? new Date(),
|
|
||||||
project.endDate ?? new Date(),
|
|
||||||
);
|
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
|
|
||||||
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
|
|
||||||
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
|
|
||||||
n("budget.remainingCents", "Remaining",
|
|
||||||
hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A",
|
|
||||||
hasBudget ? "EUR" : "—", "BUDGET",
|
|
||||||
hasBudget ? "Remaining budget" : "Cannot compute — no budget set",
|
|
||||||
3, hasBudget ? "budget - allocated" : "needs budget"),
|
|
||||||
n("budget.utilizationPct", "Utilization",
|
|
||||||
hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A",
|
|
||||||
hasBudget ? "%" : "—", "BUDGET",
|
|
||||||
hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
|
|
||||||
3, hasBudget ? "allocated / budget × 100" : "needs budget"),
|
|
||||||
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
|
|
||||||
n("budget.allocCount", "Allocations", `${projectAllocs.length}`, "count", "BUDGET", `${projectAllocs.length} resource allocations on project`, 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
links.push(
|
|
||||||
l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1),
|
|
||||||
l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1),
|
|
||||||
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
|
|
||||||
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
|
|
||||||
l("input.budgetCents", "budget.remainingCents", "−", 2),
|
|
||||||
l("budget.allocatedCents", "budget.remainingCents", "−", 2),
|
|
||||||
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
|
|
||||||
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
|
|
||||||
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
|
|
||||||
l("input.winProbability", "budget.weightedCents", "×", 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (latestVersion && latestVersion.demandLines.length > 0) {
|
|
||||||
const estCost = latestVersion.demandLines.reduce((s, dl) => s + dl.costTotalCents, 0);
|
|
||||||
const gapCents = budgetStatus.allocatedCents - estCost;
|
|
||||||
nodes.push(
|
|
||||||
n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
|
|
||||||
gapCents > 0
|
|
||||||
? `Actual allocations exceed estimate by ${fmtEur(gapCents)}`
|
|
||||||
: gapCents < 0
|
|
||||||
? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}`
|
|
||||||
: "Actual allocations match estimate",
|
|
||||||
3, "allocated - estCost"),
|
|
||||||
);
|
|
||||||
links.push(
|
|
||||||
l("budget.allocatedCents", "budget.estVsActualGap", "−", 1),
|
|
||||||
l("est.totalCostCents", "budget.estVsActualGap", "−", 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
links,
|
|
||||||
meta: {
|
|
||||||
projectName: project.name,
|
|
||||||
projectCode: project.shortCode,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user