From aa47e4cb7928706ca969deb8624e670e80a00f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 09:16:46 +0200 Subject: [PATCH] refactor(api): extract estimate read procedures --- .../api/src/__tests__/estimate-router.test.ts | 181 ++++++++++++++ packages/api/src/router/estimate-read.ts | 226 ++++++++++++++++++ packages/api/src/router/estimate.ts | 219 +---------------- 3 files changed, 409 insertions(+), 217 deletions(-) create mode 100644 packages/api/src/router/estimate-read.ts diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index 147deba..bbae498 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -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 ──────────────────────────────────────────────────────────────── describe("create", () => { diff --git a/packages/api/src/router/estimate-read.ts b/packages/api/src/router/estimate-read.ts new file mode 100644 index 0000000..31c43c6 --- /dev/null +++ b/packages/api/src/router/estimate-read.ts @@ -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[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>((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>((acc, item) => { + acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1; + return acc; + }, {}); + + const assumptionCategoryTotals = version.assumptions.reduce>((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[0], + input, + )), + + getById: controllerProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => + findUniqueOrThrow( + getEstimateById( + ctx.db as unknown as Parameters[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[0], + input, + )), +}; diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index 15ccf9b..524a110 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -6,7 +6,6 @@ import { createEstimatePlanningHandoff, createEstimateRevision, getEstimateById, - listEstimates, submitEstimateVersion, updateEstimateDraft, } from "@capakraken/application"; @@ -27,7 +26,6 @@ import { CreateEstimatePlanningHandoffSchema, CreateEstimateSchema, CreateEstimateRevisionSchema, - EstimateListFiltersSchema, GenerateWeeklyPhasingSchema, PermissionKey, SubmitEstimateVersionSchema, @@ -42,10 +40,10 @@ import { controllerProcedure, createTRPCRouter, managerProcedure, - protectedProcedure, requirePermission, } from "../trpc.js"; import { emitAllocationCreated } from "../sse/event-bus.js"; +import { estimateReadProcedures } from "./estimate-read.js"; type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED"; @@ -245,220 +243,7 @@ async function autoFillDemandLineRates( } export const estimateRouter = createTRPCRouter({ - list: controllerProcedure - .input(EstimateListFiltersSchema.default({})) - .query(async ({ ctx, input }) => - listEstimates( - ctx.db as unknown as Parameters[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[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>((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>((acc, item) => { - acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1; - return acc; - }, {}); - - const assumptionCategoryTotals = version.assumptions.reduce>((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, - }; - }), + ...estimateReadProcedures, create: managerProcedure .input(CreateEstimateSchema)