chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
import {
|
||||
approveEstimateVersion,
|
||||
cloneEstimate,
|
||||
createEstimateExport,
|
||||
createEstimate,
|
||||
createEstimatePlanningHandoff,
|
||||
createEstimateRevision,
|
||||
getEstimateById,
|
||||
listEstimates,
|
||||
submitEstimateVersion,
|
||||
updateEstimateDraft,
|
||||
} from "@planarchy/application";
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import {
|
||||
normalizeEstimateDemandLine,
|
||||
summarizeEstimateDemandLines,
|
||||
generateWeekRange,
|
||||
distributeHoursToWeeks,
|
||||
aggregateWeeklyToMonthly,
|
||||
aggregateWeeklyByChapter,
|
||||
} from "@planarchy/engine";
|
||||
import {
|
||||
ApproveEstimateVersionSchema,
|
||||
CloneEstimateSchema,
|
||||
CreateEstimateExportSchema,
|
||||
CreateEstimatePlanningHandoffSchema,
|
||||
CreateEstimateSchema,
|
||||
CreateEstimateRevisionSchema,
|
||||
EstimateListFiltersSchema,
|
||||
GenerateWeeklyPhasingSchema,
|
||||
PermissionKey,
|
||||
SubmitEstimateVersionSchema,
|
||||
UpdateEstimateDraftSchema,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
controllerProcedure,
|
||||
createTRPCRouter,
|
||||
managerProcedure,
|
||||
protectedProcedure,
|
||||
requirePermission,
|
||||
} from "../trpc.js";
|
||||
import { emitAllocationCreated } from "../sse/event-bus.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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 const estimateRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.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 getEstimateById(
|
||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||
input.id,
|
||||
);
|
||||
|
||||
if (!estimate) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
||||
}
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateEstimateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
if (input.projectId) {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const estimate = await createEstimate(
|
||||
ctx.db as unknown as Parameters<typeof createEstimate>[0],
|
||||
withComputedMetrics(input, input.baseCurrency),
|
||||
);
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "CREATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
name: estimate.name,
|
||||
status: estimate.status,
|
||||
projectId: estimate.projectId,
|
||||
latestVersionNumber: estimate.latestVersionNumber,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
clone: managerProcedure
|
||||
.input(CloneEstimateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await cloneEstimate(
|
||||
ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Source estimate not found" ||
|
||||
error.message === "Source estimate has no versions"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "CREATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
name: estimate.name,
|
||||
clonedFrom: input.sourceEstimateId,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
updateDraft: managerProcedure
|
||||
.input(UpdateEstimateDraftSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
if (input.projectId) {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await updateEstimateDraft(
|
||||
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
|
||||
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Estimate not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Estimate has no working version"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
name: estimate.name,
|
||||
status: estimate.status,
|
||||
latestVersionNumber: estimate.latestVersionNumber,
|
||||
workingVersionId: estimate.versions.find(
|
||||
(version) => version.status === "WORKING",
|
||||
)?.id,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
submitVersion: managerProcedure
|
||||
.input(SubmitEstimateVersionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await submitEstimateVersion(
|
||||
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Estimate not found" ||
|
||||
error.message === "Estimate version not found"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
if (
|
||||
error.message === "Estimate has no working version" ||
|
||||
error.message === "Only working versions can be submitted"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
status: estimate.status,
|
||||
submittedVersionId: estimate.versions.find(
|
||||
(version) => version.status === "SUBMITTED",
|
||||
)?.id,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
approveVersion: managerProcedure
|
||||
.input(ApproveEstimateVersionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await approveEstimateVersion(
|
||||
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Estimate not found" ||
|
||||
error.message === "Estimate version not found"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
if (
|
||||
error.message === "Estimate has no submitted version" ||
|
||||
error.message === "Only submitted versions can be approved"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
status: estimate.status,
|
||||
approvedVersionId: estimate.versions.find(
|
||||
(version) => version.status === "APPROVED",
|
||||
)?.id,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
createRevision: managerProcedure
|
||||
.input(CreateEstimateRevisionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await createEstimateRevision(
|
||||
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Estimate not found" ||
|
||||
error.message === "Estimate version not found"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
if (
|
||||
error.message === "Estimate already has a working version" ||
|
||||
error.message === "Estimate has no locked version to revise" ||
|
||||
error.message === "Source version must be locked before creating a revision"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
status: estimate.status,
|
||||
latestVersionNumber: estimate.latestVersionNumber,
|
||||
workingVersionId: estimate.versions.find(
|
||||
(version) => version.status === "WORKING",
|
||||
)?.id,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
createExport: managerProcedure
|
||||
.input(CreateEstimateExportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await createEstimateExport(
|
||||
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Estimate not found" ||
|
||||
error.message === "Estimate version not found" ||
|
||||
error.message === "Estimate has no version to export"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const exportedVersion = input.versionId
|
||||
? estimate.versions.find((version) => version.id === input.versionId)
|
||||
: estimate.versions[0];
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: estimate.id,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
id: estimate.id,
|
||||
exportFormat: input.format,
|
||||
exportCount: exportedVersion?.exports.length ?? null,
|
||||
versionId: exportedVersion?.id ?? null,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}),
|
||||
|
||||
createPlanningHandoff: managerProcedure
|
||||
.input(CreateEstimatePlanningHandoffSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await createEstimatePlanningHandoff(
|
||||
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
|
||||
input,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message === "Estimate not found" ||
|
||||
error.message === "Estimate version not found" ||
|
||||
error.message === "Linked project not found"
|
||||
) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||
}
|
||||
if (
|
||||
error.message === "Estimate has no approved version" ||
|
||||
error.message === "Only approved versions can be handed off to planning" ||
|
||||
error.message === "Estimate must be linked to a project before planning handoff" ||
|
||||
error.message === "Planning handoff already exists for this approved version" ||
|
||||
error.message === "Linked project has an invalid date range" ||
|
||||
error.message.startsWith("Project window has no working days for demand line")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Estimate",
|
||||
entityId: result.estimateId,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
changes: {
|
||||
after: {
|
||||
planningHandoff: {
|
||||
versionId: result.estimateVersionId,
|
||||
versionNumber: result.estimateVersionNumber,
|
||||
projectId: result.projectId,
|
||||
createdCount: result.createdCount,
|
||||
assignedCount: result.assignedCount,
|
||||
placeholderCount: result.placeholderCount,
|
||||
fallbackPlaceholderCount: result.fallbackPlaceholderCount,
|
||||
},
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
for (const allocation of result.allocations) {
|
||||
emitAllocationCreated({
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
generateWeeklyPhasing: managerProcedure
|
||||
.input(GenerateWeeklyPhasingSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
const estimate = await getEstimateById(
|
||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||
input.estimateId,
|
||||
);
|
||||
|
||||
if (!estimate) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
||||
}
|
||||
|
||||
const workingVersion = estimate.versions.find(
|
||||
(v) => v.status === "WORKING",
|
||||
);
|
||||
|
||||
if (!workingVersion) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Estimate has no working version",
|
||||
});
|
||||
}
|
||||
|
||||
const pattern = input.pattern ?? "even";
|
||||
|
||||
// Distribute hours for each demand line and update DB
|
||||
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
|
||||
|
||||
for (const line of workingVersion.demandLines) {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: line.hours,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
pattern,
|
||||
});
|
||||
|
||||
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
|
||||
|
||||
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
|
||||
const metadata = {
|
||||
...existingMetadata,
|
||||
weeklyPhasing: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
pattern,
|
||||
weeklyHours: result.weeklyHours,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
updates.push({ id: line.id, monthlySpread, metadata });
|
||||
}
|
||||
|
||||
// Batch update all demand lines
|
||||
await Promise.all(
|
||||
updates.map((update) =>
|
||||
ctx.db.estimateDemandLine.update({
|
||||
where: { id: update.id },
|
||||
data: {
|
||||
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
|
||||
metadata: update.metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
estimateId: input.estimateId,
|
||||
versionId: workingVersion.id,
|
||||
linesUpdated: updates.length,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
pattern,
|
||||
};
|
||||
}),
|
||||
|
||||
getWeeklyPhasing: controllerProcedure
|
||||
.input(z.object({ estimateId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const estimate = await getEstimateById(
|
||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||
input.estimateId,
|
||||
);
|
||||
|
||||
if (!estimate) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
||||
}
|
||||
|
||||
// Get the latest version (first in the sorted array)
|
||||
const version = estimate.versions[0];
|
||||
|
||||
if (!version) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Estimate has no versions",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract weekly phasing from each demand line's metadata
|
||||
type WeeklyPhasingMeta = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
pattern: string;
|
||||
weeklyHours: Record<string, number>;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
const linesWithPhasing: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
chapter: string | null;
|
||||
hours: number;
|
||||
weeklyHours: Record<string, number>;
|
||||
}> = [];
|
||||
|
||||
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
|
||||
|
||||
for (const line of version.demandLines) {
|
||||
const meta = (line.metadata ?? {}) as Record<string, unknown>;
|
||||
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
|
||||
|
||||
if (phasing) {
|
||||
if (!phasingConfig) {
|
||||
phasingConfig = {
|
||||
startDate: phasing.startDate,
|
||||
endDate: phasing.endDate,
|
||||
pattern: phasing.pattern,
|
||||
};
|
||||
}
|
||||
|
||||
linesWithPhasing.push({
|
||||
id: line.id,
|
||||
name: line.name,
|
||||
chapter: line.chapter ?? null,
|
||||
hours: line.hours,
|
||||
weeklyHours: phasing.weeklyHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!phasingConfig || linesWithPhasing.length === 0) {
|
||||
return {
|
||||
estimateId: input.estimateId,
|
||||
versionId: version.id,
|
||||
hasPhasing: false as const,
|
||||
config: null,
|
||||
weeks: [],
|
||||
lines: [],
|
||||
chapterAggregation: {},
|
||||
};
|
||||
}
|
||||
|
||||
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
|
||||
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
|
||||
|
||||
return {
|
||||
estimateId: input.estimateId,
|
||||
versionId: version.id,
|
||||
hasPhasing: true as const,
|
||||
config: phasingConfig,
|
||||
weeks,
|
||||
lines: linesWithPhasing,
|
||||
chapterAggregation,
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user