refactor(api): extract estimate read procedures

This commit is contained in:
2026-03-31 09:16:46 +02:00
parent 71b94d0ad1
commit aa47e4cb79
3 changed files with 409 additions and 217 deletions
@@ -245,6 +245,187 @@ describe("estimate router", () => {
}); });
}); });
describe("listVersions", () => {
it("returns estimate versions ordered from newest to oldest", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "est_1",
name: "Test Estimate",
status: EstimateStatus.DRAFT,
latestVersionNumber: 2,
versions: [
{
id: "ver_2",
versionNumber: 2,
label: "v2",
status: EstimateVersionStatus.SUBMITTED,
notes: null,
lockedAt: new Date("2026-03-14"),
createdAt: new Date("2026-03-14"),
updatedAt: new Date("2026-03-14"),
_count: {
assumptions: 1,
scopeItems: 2,
demandLines: 3,
resourceSnapshots: 4,
exports: 5,
},
},
],
});
const db = { estimate: { findUnique } };
const caller = createControllerCaller(db);
const result = await caller.listVersions({ estimateId: "est_1" });
expect(result.versions).toHaveLength(1);
expect(result.versions[0]?.id).toBe("ver_2");
expect(findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "est_1" },
select: expect.objectContaining({
versions: expect.objectContaining({
orderBy: { versionNumber: "desc" },
}),
}),
}),
);
});
it("throws NOT_FOUND when the estimate does not exist", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const db = { estimate: { findUnique } };
const caller = createControllerCaller(db);
await expect(caller.listVersions({ estimateId: "missing" })).rejects.toThrow(
expect.objectContaining({
code: "NOT_FOUND",
message: "Estimate not found",
}),
);
});
});
describe("getVersionSnapshot", () => {
it("returns aggregate counts and totals for the selected version", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "est_1",
name: "Test Estimate",
status: EstimateStatus.DRAFT,
baseCurrency: "EUR",
versions: [
{
id: "ver_2",
versionNumber: 2,
label: "Revision 2",
status: EstimateVersionStatus.SUBMITTED,
notes: "Ready",
lockedAt: new Date("2026-03-14"),
createdAt: new Date("2026-03-14"),
updatedAt: new Date("2026-03-15"),
assumptions: [
{ id: "a_1", category: "delivery", key: "onsite", label: "Onsite" },
{ id: "a_2", category: "delivery", key: "travel", label: "Travel" },
{ id: "a_3", category: "commercial", key: "buffer", label: "Buffer" },
],
scopeItems: [
{ id: "s_1", scopeType: "FEATURE", sequenceNo: 1, name: "Alpha" },
{ id: "s_2", scopeType: "FEATURE", sequenceNo: 2, name: "Beta" },
{ id: "s_3", scopeType: "SERVICE", sequenceNo: 3, name: "Gamma" },
],
demandLines: [
{
id: "d_1",
name: "Lead",
chapter: "Delivery",
hours: 10,
costTotalCents: 100_00,
priceTotalCents: 150_00,
currency: "EUR",
},
{
id: "d_2",
name: "QA",
chapter: null,
hours: 5,
costTotalCents: 50_00,
priceTotalCents: 90_00,
currency: "EUR",
},
],
resourceSnapshots: [
{
id: "r_1",
displayName: "Alice",
chapter: "Delivery",
currency: "EUR",
lcrCents: 10_000,
ucrCents: 15_000,
},
],
exports: [
{
id: "x_1",
format: EstimateExportFormat.XLSX,
fileName: "estimate.xlsx",
createdAt: new Date("2026-03-16"),
},
],
},
],
});
const db = { estimate: { findUnique } };
const caller = createControllerCaller(db);
const result = await caller.getVersionSnapshot({ estimateId: "est_1" });
expect(result.counts).toEqual({
assumptions: 3,
scopeItems: 3,
demandLines: 2,
resourceSnapshots: 1,
exports: 1,
});
expect(result.totals).toMatchObject({
hours: 15,
costTotalCents: 15000,
priceTotalCents: 24000,
});
expect(result.chapterBreakdown).toEqual([
expect.objectContaining({ chapter: "Delivery", lineCount: 1, hours: 10 }),
expect.objectContaining({ chapter: "Unassigned", lineCount: 1, hours: 5 }),
]);
expect(result.scopeTypeBreakdown).toEqual([
{ scopeType: "FEATURE", count: 2 },
{ scopeType: "SERVICE", count: 1 },
]);
expect(result.assumptionCategoryBreakdown).toEqual([
{ category: "delivery", count: 2 },
{ category: "commercial", count: 1 },
]);
});
it("throws NOT_FOUND when no matching version can be resolved", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "est_1",
name: "Test Estimate",
status: EstimateStatus.DRAFT,
baseCurrency: "EUR",
versions: [],
});
const db = { estimate: { findUnique } };
const caller = createControllerCaller(db);
await expect(
caller.getVersionSnapshot({ estimateId: "est_1", versionId: "missing_version" }),
).rejects.toThrow(
expect.objectContaining({
code: "NOT_FOUND",
message: "Estimate version not found",
}),
);
});
});
// ─── create ──────────────────────────────────────────────────────────────── // ─── create ────────────────────────────────────────────────────────────────
describe("create", () => { describe("create", () => {
+226
View File
@@ -0,0 +1,226 @@
import { getEstimateById, listEstimates } from "@capakraken/application";
import { summarizeEstimateDemandLines } from "@capakraken/engine";
import { EstimateListFiltersSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { controllerProcedure } from "../trpc.js";
async function readEstimateVersionSnapshot(
db: Parameters<typeof getEstimateById>[0],
input: { estimateId: string; versionId?: string | undefined },
) {
const estimate = await db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}
export const estimateReadProcedures = {
list: controllerProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) =>
findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
),
"Estimate",
)),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) =>
findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
)),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => readEstimateVersionSnapshot(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input,
)),
};
+2 -217
View File
@@ -6,7 +6,6 @@ import {
createEstimatePlanningHandoff, createEstimatePlanningHandoff,
createEstimateRevision, createEstimateRevision,
getEstimateById, getEstimateById,
listEstimates,
submitEstimateVersion, submitEstimateVersion,
updateEstimateDraft, updateEstimateDraft,
} from "@capakraken/application"; } from "@capakraken/application";
@@ -27,7 +26,6 @@ import {
CreateEstimatePlanningHandoffSchema, CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema, CreateEstimateSchema,
CreateEstimateRevisionSchema, CreateEstimateRevisionSchema,
EstimateListFiltersSchema,
GenerateWeeklyPhasingSchema, GenerateWeeklyPhasingSchema,
PermissionKey, PermissionKey,
SubmitEstimateVersionSchema, SubmitEstimateVersionSchema,
@@ -42,10 +40,10 @@ import {
controllerProcedure, controllerProcedure,
createTRPCRouter, createTRPCRouter,
managerProcedure, managerProcedure,
protectedProcedure,
requirePermission, requirePermission,
} from "../trpc.js"; } from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js"; import { emitAllocationCreated } from "../sse/event-bus.js";
import { estimateReadProcedures } from "./estimate-read.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED"; type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
@@ -245,220 +243,7 @@ async function autoFillDemandLineRates(
} }
export const estimateRouter = createTRPCRouter({ export const estimateRouter = createTRPCRouter({
list: controllerProcedure ...estimateReadProcedures,
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
),
"Estimate",
);
return estimate;
}),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
);
return estimate;
}),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}),
create: managerProcedure create: managerProcedure
.input(CreateEstimateSchema) .input(CreateEstimateSchema)