1184 lines
36 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
});
|