Files
CapaKraken/packages/api/src/router/estimate.ts
T

1184 lines
36 KiB
TypeScript

import {
approveEstimateVersion,
cloneEstimate,
createEstimateExport,
createEstimate,
createEstimatePlanningHandoff,
createEstimateRevision,
getEstimateById,
listEstimates,
submitEstimateVersion,
updateEstimateDraft,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
generateWeekRange,
distributeHoursToWeeks,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "@capakraken/engine";
import {
ApproveEstimateVersionSchema,
CloneEstimateSchema,
CommercialTermsSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
CreateEstimateRevisionSchema,
EstimateListFiltersSchema,
GenerateWeeklyPhasingSchema,
PermissionKey,
SubmitEstimateVersionSchema,
UpdateCommercialTermsSchema,
UpdateEstimateDraftSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import {
controllerProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
type EstimateRouterErrorRule = {
code: EstimateRouterErrorCode;
messages?: readonly string[];
predicates?: readonly ((message: string) => boolean)[];
};
function rethrowEstimateRouterError(
error: unknown,
rules: readonly EstimateRouterErrorRule[],
): never {
if (!(error instanceof Error)) {
throw error;
}
const matchingRule = rules.find(
(rule) =>
rule.messages?.includes(error.message) === true ||
rule.predicates?.some((predicate) => predicate(error.message)) === true,
);
if (matchingRule) {
throw new TRPCError({
code: matchingRule.code,
message: error.message,
});
}
throw error;
}
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
const summary = summarizeEstimateDemandLines(demandLines);
return [
{
key: "total_hours",
label: "Total Hours",
metricGroup: "summary",
valueDecimal: summary.totalHours,
metadata: {},
},
{
key: "total_cost",
label: "Total Cost",
metricGroup: "summary",
valueDecimal: summary.totalCostCents / 100,
valueCents: summary.totalCostCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "total_price",
label: "Total Price",
metricGroup: "summary",
valueDecimal: summary.totalPriceCents / 100,
valueCents: summary.totalPriceCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin",
label: "Margin",
metricGroup: "summary",
valueDecimal: summary.marginCents / 100,
valueCents: summary.marginCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin_percent",
label: "Margin %",
metricGroup: "summary",
valueDecimal: summary.marginPercent,
metadata: {},
},
];
}
function normalizeDemandLines<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
},
>(input: T, baseCurrency: string) {
const snapshotsByResourceId = new Map(
input.resourceSnapshots
.filter(
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
resourceId: string;
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
);
return input.demandLines.map((line) =>
normalizeEstimateDemandLine(line, {
resourceSnapshot:
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
defaultCurrency: baseCurrency,
}),
);
}
function withComputedMetrics<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
metrics: z.infer<typeof CreateEstimateSchema>["metrics"];
},
>(input: T, baseCurrency: string) {
const normalizedDemandLines = normalizeDemandLines(input, baseCurrency);
const computedMetrics = buildComputedMetrics(normalizedDemandLines);
const computedKeys = new Set(computedMetrics.map((metric) => metric.key));
return {
...input,
demandLines: normalizedDemandLines,
metrics: [
...input.metrics.filter((metric) => !computedKeys.has(metric.key)),
...computedMetrics,
],
};
}
/**
* Auto-fill rate card rates into demand lines that have default (zero) rates.
* A line is eligible for auto-fill when both costRateCents and billRateCents
* are 0 (the Zod default) and rateSource is not explicitly set.
*
* Returns the enriched demand lines and a list of line indices that were auto-filled.
*/
async function autoFillDemandLineRates(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
projectId?: string | null,
): Promise<{
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
autoFilledIndices: number[];
}> {
// Resolve clientId from the linked project
let clientId: string | null = null;
if (projectId) {
const project = await db.project.findUnique({
where: { id: projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const autoFilledIndices: number[] = [];
const enriched = await Promise.all(
demandLines.map(async (line, index) => {
// Only auto-fill if both rates are at default (0) and no explicit rateSource
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
if (!isDefaultRate || hasExplicitSource) return line;
const result = await lookupRate(db, {
clientId,
chapter: line.chapter ?? null,
roleId: line.roleId ?? null,
});
if (!result) return line;
autoFilledIndices.push(index);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
return {
...line,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateSource: `rate-card:${result.rateCardId}`,
metadata: {
...existingMetadata,
autoAppliedRateCard: {
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
appliedAt: new Date().toISOString(),
},
},
};
}),
);
return { demandLines: enriched, autoFilledIndices };
}
export const estimateRouter = createTRPCRouter({
list: controllerProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
),
"Estimate",
);
return estimate;
}),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
);
return estimate;
}),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(enrichedInput, input.baseCurrency),
);
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
clone: managerProcedure
.input(CloneEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await cloneEstimate(
ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Source estimate not found", "Source estimate has no versions"],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
clonedFrom: input.sourceEstimateId,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
// Resolve projectId: explicit input or existing estimate's projectId
let effectiveProjectId = input.projectId;
if (!effectiveProjectId) {
const existing = await ctx.db.estimate.findUnique({
where: { id: input.id },
select: { projectId: true },
});
effectiveProjectId = existing?.projectId ?? undefined;
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found"],
},
{
code: "PRECONDITION_FAILED",
messages: ["Estimate has no working version"],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
submitVersion: managerProcedure
.input(SubmitEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await submitEstimateVersion(
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
submittedVersionId: estimate.versions.find(
(version) => version.status === "SUBMITTED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
approveVersion: managerProcedure
.input(ApproveEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await approveEstimateVersion(
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no submitted version",
"Only submitted versions can be approved",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
approvedVersionId: estimate.versions.find(
(version) => version.status === "APPROVED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createRevision: managerProcedure
.input(CreateEstimateRevisionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateRevision(
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate already has a working version",
"Estimate has no locked version to revise",
"Source version must be locked before creating a revision",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createExport: managerProcedure
.input(CreateEstimateExportSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateExport(
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Estimate has no version to export",
],
},
]);
}
const exportedVersion = input.versionId
? estimate.versions.find((version) => version.id === input.versionId)
: estimate.versions[0];
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
exportFormat: input.format,
exportCount: exportedVersion?.exports.length ?? null,
versionId: exportedVersion?.id ?? null,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createPlanningHandoff: managerProcedure
.input(CreateEstimatePlanningHandoffSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
let result;
try {
result = await createEstimatePlanningHandoff(
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Linked project not found",
],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no approved version",
"Only approved versions can be handed off to planning",
"Estimate must be linked to a project before planning handoff",
"Planning handoff already exists for this approved version",
"Linked project has an invalid date range",
],
predicates: [
(message) =>
message.startsWith("Project window has no working days for demand line"),
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: result.estimateId,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
planningHandoff: {
versionId: result.estimateVersionId,
versionNumber: result.estimateVersionNumber,
projectId: result.projectId,
createdCount: result.createdCount,
assignedCount: result.assignedCount,
placeholderCount: result.placeholderCount,
fallbackPlaceholderCount: result.fallbackPlaceholderCount,
},
},
} as Prisma.InputJsonValue,
},
});
for (const allocation of result.allocations) {
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
});
}
return result;
}),
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
// Distribute hours for each demand line and update DB
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
// Batch update all demand lines
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
// Get the latest version (first in the sorted array)
const version = estimate.versions[0];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
// Extract weekly phasing from each demand line's metadata
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (phasing) {
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks,
lines: linesWithPhasing,
chapterAggregation,
};
}),
// ─── Commercial Terms ───────────────────────────────────────────────────
getCommercialTerms: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: { id: true, commercialTerms: true },
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const raw = version.commercialTerms;
// Parse stored JSON through Zod for type safety, fall back to defaults
const terms = raw
? CommercialTermsSchema.parse(raw)
: CommercialTermsSchema.parse({});
return { versionId: version.id, terms };
}),
updateCommercialTerms: managerProcedure
.input(UpdateCommercialTermsSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { where: { status: "WORKING" }, take: 1 }),
select: { id: true, status: true },
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
if (version.status !== "WORKING") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Commercial terms can only be edited on working versions",
});
}
const validated = CommercialTermsSchema.parse(input.terms);
await ctx.db.estimateVersion.update({
where: { id: version.id },
data: { commercialTerms: validated as unknown as Prisma.InputJsonValue },
});
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
field: "commercialTerms",
after: validated,
} as Prisma.InputJsonValue,
},
});
return { versionId: version.id, terms: validated };
}),
// ─── Rate Card Lookup for Demand Lines ──────────────────────────────────
lookupDemandLineRate: controllerProcedure
.input(z.object({
projectId: z.string().optional(),
clientId: z.string().optional(),
roleId: z.string().optional(),
chapter: z.string().optional(),
seniority: z.string().optional(),
location: z.string().optional(),
workType: z.string().optional(),
effectiveDate: z.coerce.date().optional(),
}))
.query(async ({ ctx, input }) => {
// Resolve clientId from project if not provided directly
let clientId = input.clientId ?? null;
if (!clientId && input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const result = await lookupRate(ctx.db, {
clientId,
chapter: input.chapter ?? null,
roleId: input.roleId ?? null,
seniority: input.seniority ?? null,
location: input.location ?? null,
workType: input.workType ?? null,
effectiveDate: input.effectiveDate ?? null,
});
if (!result) return { found: false as const };
return {
found: true as const,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
};
}),
});