refactor(api): extract estimate procedure support
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
cloneEstimate,
|
||||
createEstimate,
|
||||
createEstimateExport,
|
||||
createEstimatePlanningHandoff,
|
||||
updateEstimateDraft,
|
||||
} from "@capakraken/application";
|
||||
import type { Prisma } from "@capakraken/db";
|
||||
import {
|
||||
CloneEstimateSchema,
|
||||
CreateEstimateExportSchema,
|
||||
CreateEstimatePlanningHandoffSchema,
|
||||
CreateEstimateSchema,
|
||||
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 { emitAllocationCreated } from "../sse/event-bus.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
import {
|
||||
autoFillDemandLineRates,
|
||||
withComputedMetrics,
|
||||
} from "./estimate-demand-lines.js";
|
||||
|
||||
type EstimateProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
|
||||
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
|
||||
|
||||
type EstimateRouterErrorRule = {
|
||||
code: EstimateRouterErrorCode;
|
||||
messages?: readonly string[];
|
||||
predicates?: readonly ((message: string) => boolean)[];
|
||||
};
|
||||
|
||||
function withAuditUser(userId: string | undefined) {
|
||||
return userId ? { userId } : {};
|
||||
}
|
||||
|
||||
export 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 lookupDemandLineRateInputSchema = 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(),
|
||||
});
|
||||
|
||||
type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
|
||||
type CloneEstimateInput = z.infer<typeof CloneEstimateSchema>;
|
||||
type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
|
||||
type CreateEstimateExportInput = z.infer<typeof CreateEstimateExportSchema>;
|
||||
type CreateEstimatePlanningHandoffInput = z.infer<
|
||||
typeof CreateEstimatePlanningHandoffSchema
|
||||
>;
|
||||
type LookupDemandLineRateInput = z.infer<typeof lookupDemandLineRateInputSchema>;
|
||||
|
||||
export async function createEstimateRecord(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: CreateEstimateInput,
|
||||
) {
|
||||
if (input.projectId) {
|
||||
await findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
"Project",
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
...withAuditUser(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;
|
||||
}
|
||||
|
||||
export async function cloneEstimateRecord(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: CloneEstimateInput,
|
||||
) {
|
||||
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",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
name: estimate.name,
|
||||
clonedFrom: input.sourceEstimateId,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}
|
||||
|
||||
export async function updateEstimateDraftRecord(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: UpdateEstimateDraftInput,
|
||||
) {
|
||||
if (input.projectId) {
|
||||
await findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
"Project",
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
...withAuditUser(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;
|
||||
}
|
||||
|
||||
export async function createEstimateExportRecord(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: CreateEstimateExportInput,
|
||||
) {
|
||||
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",
|
||||
...withAuditUser(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;
|
||||
}
|
||||
|
||||
export async function createEstimatePlanningHandoffRecord(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: CreateEstimatePlanningHandoffInput,
|
||||
) {
|
||||
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",
|
||||
...withAuditUser(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;
|
||||
}
|
||||
|
||||
export async function lookupDemandLineRateForEstimate(
|
||||
ctx: EstimateProcedureContext,
|
||||
input: LookupDemandLineRateInput,
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user