400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import {
|
|
cloneEstimate,
|
|
createEstimateExport,
|
|
createEstimate,
|
|
createEstimatePlanningHandoff,
|
|
getEstimateById,
|
|
updateEstimateDraft,
|
|
} from "@capakraken/application";
|
|
import type { Prisma } from "@capakraken/db";
|
|
import {
|
|
CloneEstimateSchema,
|
|
CreateEstimateExportSchema,
|
|
CreateEstimatePlanningHandoffSchema,
|
|
CreateEstimateSchema,
|
|
PermissionKey,
|
|
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,
|
|
requirePermission,
|
|
} from "../trpc.js";
|
|
import { emitAllocationCreated } from "../sse/event-bus.js";
|
|
import { estimateCommercialProcedures } from "./estimate-commercial.js";
|
|
import {
|
|
autoFillDemandLineRates,
|
|
withComputedMetrics,
|
|
} from "./estimate-demand-lines.js";
|
|
import { estimatePhasingProcedures } from "./estimate-phasing.js";
|
|
import { estimateReadProcedures } from "./estimate-read.js";
|
|
import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.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;
|
|
}
|
|
|
|
export const estimateRouter = createTRPCRouter({
|
|
...estimateReadProcedures,
|
|
...estimateCommercialProcedures,
|
|
...estimatePhasingProcedures,
|
|
...estimateVersionWorkflowProcedures,
|
|
|
|
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;
|
|
}),
|
|
|
|
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;
|
|
}),
|
|
|
|
// ─── 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,
|
|
};
|
|
}),
|
|
});
|