refactor(api): extract estimate read procedures
This commit is contained in:
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)),
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user