refactor(api): extract estimate procedure support

This commit is contained in:
2026-03-31 22:45:05 +02:00
parent 3f9ae29e01
commit ba2bf00712
3 changed files with 408 additions and 375 deletions
@@ -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,
};
}
@@ -10,40 +10,8 @@ import {
PermissionKey, PermissionKey,
SubmitEstimateVersionSchema, SubmitEstimateVersionSchema,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { managerProcedure, requirePermission } from "../trpc.js"; import { managerProcedure, requirePermission } from "../trpc.js";
import { rethrowEstimateRouterError } from "./estimate-procedure-support.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 estimateVersionWorkflowProcedures = { export const estimateVersionWorkflowProcedures = {
submitVersion: managerProcedure submitVersion: managerProcedure
+16 -342
View File
@@ -1,12 +1,3 @@
import {
cloneEstimate,
createEstimateExport,
createEstimate,
createEstimatePlanningHandoff,
getEstimateById,
updateEstimateDraft,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
import { import {
CloneEstimateSchema, CloneEstimateSchema,
CreateEstimateExportSchema, CreateEstimateExportSchema,
@@ -15,58 +6,26 @@ import {
PermissionKey, PermissionKey,
UpdateEstimateDraftSchema, UpdateEstimateDraftSchema,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import {
import { z } from "zod"; cloneEstimateRecord,
import { findUniqueOrThrow } from "../db/helpers.js"; createEstimateExportRecord,
import { lookupRate } from "../lib/rate-card-lookup.js"; createEstimatePlanningHandoffRecord,
createEstimateRecord,
lookupDemandLineRateForEstimate,
lookupDemandLineRateInputSchema,
updateEstimateDraftRecord,
} from "./estimate-procedure-support.js";
import { import {
controllerProcedure, controllerProcedure,
createTRPCRouter, createTRPCRouter,
managerProcedure, managerProcedure,
requirePermission, requirePermission,
} from "../trpc.js"; } from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
import { estimateCommercialProcedures } from "./estimate-commercial.js"; import { estimateCommercialProcedures } from "./estimate-commercial.js";
import {
autoFillDemandLineRates,
withComputedMetrics,
} from "./estimate-demand-lines.js";
import { estimatePhasingProcedures } from "./estimate-phasing.js"; import { estimatePhasingProcedures } from "./estimate-phasing.js";
import { estimateReadProcedures } from "./estimate-read.js"; import { estimateReadProcedures } from "./estimate-read.js";
import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.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({ export const estimateRouter = createTRPCRouter({
...estimateReadProcedures, ...estimateReadProcedures,
...estimateCommercialProcedures, ...estimateCommercialProcedures,
@@ -77,323 +36,38 @@ export const estimateRouter = createTRPCRouter({
.input(CreateEstimateSchema) .input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return createEstimateRecord(ctx, input);
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 clone: managerProcedure
.input(CloneEstimateSchema) .input(CloneEstimateSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return cloneEstimateRecord(ctx, input);
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 updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema) .input(UpdateEstimateDraftSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return updateEstimateDraftRecord(ctx, input);
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 createExport: managerProcedure
.input(CreateEstimateExportSchema) .input(CreateEstimateExportSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return createEstimateExportRecord(ctx, input);
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 createPlanningHandoff: managerProcedure
.input(CreateEstimatePlanningHandoffSchema) .input(CreateEstimatePlanningHandoffSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return createEstimatePlanningHandoffRecord(ctx, input);
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 lookupDemandLineRate: controllerProcedure
.input(z.object({ .input(lookupDemandLineRateInputSchema)
projectId: z.string().optional(), .query(({ ctx, input }) => lookupDemandLineRateForEstimate(ctx, input)),
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,
};
}),
}); });