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