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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user