refactor(api): extract estimate demand line helpers

This commit is contained in:
2026-03-31 11:32:00 +02:00
parent 79a396d788
commit c79ae70177
2 changed files with 164 additions and 169 deletions
@@ -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 };
}
+4 -169
View File
@@ -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,