refactor(api): extract estimate demand line helpers
This commit is contained in:
@@ -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<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"];
|
||||||
|
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"];
|
||||||
|
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
|
||||||
|
metrics: z.infer<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"],
|
||||||
|
projectId?: string | null,
|
||||||
|
): Promise<{
|
||||||
|
demandLines: z.infer<typeof CreateEstimateSchema>["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<string, unknown>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -7,10 +7,6 @@ import {
|
|||||||
updateEstimateDraft,
|
updateEstimateDraft,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import type { Prisma } from "@capakraken/db";
|
import type { Prisma } from "@capakraken/db";
|
||||||
import {
|
|
||||||
normalizeEstimateDemandLine,
|
|
||||||
summarizeEstimateDemandLines,
|
|
||||||
} from "@capakraken/engine";
|
|
||||||
import {
|
import {
|
||||||
CloneEstimateSchema,
|
CloneEstimateSchema,
|
||||||
CreateEstimateExportSchema,
|
CreateEstimateExportSchema,
|
||||||
@@ -31,6 +27,10 @@ import {
|
|||||||
} from "../trpc.js";
|
} from "../trpc.js";
|
||||||
import { emitAllocationCreated } from "../sse/event-bus.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";
|
||||||
@@ -67,171 +67,6 @@ function rethrowEstimateRouterError(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComputedMetrics(
|
|
||||||
demandLines: z.infer<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"];
|
|
||||||
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"];
|
|
||||||
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
|
|
||||||
metrics: z.infer<typeof CreateEstimateSchema>["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<typeof CreateEstimateSchema>["demandLines"],
|
|
||||||
projectId?: string | null,
|
|
||||||
): Promise<{
|
|
||||||
demandLines: z.infer<typeof CreateEstimateSchema>["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<string, unknown>;
|
|
||||||
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({
|
export const estimateRouter = createTRPCRouter({
|
||||||
...estimateReadProcedures,
|
...estimateReadProcedures,
|
||||||
...estimateCommercialProcedures,
|
...estimateCommercialProcedures,
|
||||||
|
|||||||
Reference in New Issue
Block a user