161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
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 };
|
|
}
|