diff --git a/packages/api/src/router/estimate-demand-lines.ts b/packages/api/src/router/estimate-demand-lines.ts new file mode 100644 index 0000000..1682e09 --- /dev/null +++ b/packages/api/src/router/estimate-demand-lines.ts @@ -0,0 +1,160 @@ +import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine"; +import { CreateEstimateSchema } from "@capakraken/shared"; +import { z } from "zod"; +import { lookupRate } from "../lib/rate-card-lookup.js"; + +function buildComputedMetrics( + demandLines: z.infer["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["demandLines"]; + resourceSnapshots: z.infer["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, + }), + ); +} + +export function withComputedMetrics< + T extends { + demandLines: z.infer["demandLines"]; + resourceSnapshots: z.infer["resourceSnapshots"]; + metrics: z.infer["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, + ], + }; +} + +export async function autoFillDemandLineRates( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + demandLines: z.infer["demandLines"], + projectId?: string | null, +): Promise<{ + demandLines: z.infer["demandLines"]; + autoFilledIndices: number[]; +}> { + 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) => { + 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; + 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 }; +} diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index 2cf93eb..6c108f5 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -7,10 +7,6 @@ import { updateEstimateDraft, } from "@capakraken/application"; import type { Prisma } from "@capakraken/db"; -import { - normalizeEstimateDemandLine, - summarizeEstimateDemandLines, -} from "@capakraken/engine"; import { CloneEstimateSchema, CreateEstimateExportSchema, @@ -31,6 +27,10 @@ import { } 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"; @@ -67,171 +67,6 @@ function rethrowEstimateRouterError( throw error; } -function buildComputedMetrics( - demandLines: z.infer["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["demandLines"]; - resourceSnapshots: z.infer["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["demandLines"]; - resourceSnapshots: z.infer["resourceSnapshots"]; - metrics: z.infer["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["demandLines"], - projectId?: string | null, -): Promise<{ - demandLines: z.infer["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; - 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({ ...estimateReadProcedures, ...estimateCommercialProcedures,