180 lines
5.0 KiB
TypeScript
180 lines
5.0 KiB
TypeScript
import type { Prisma } from "@capakraken/db";
|
|
import {
|
|
aggregateWeeklyByChapter,
|
|
aggregateWeeklyToMonthly,
|
|
distributeHoursToWeeks,
|
|
generateWeekRange,
|
|
} from "@capakraken/engine";
|
|
import { PermissionKey, GenerateWeeklyPhasingSchema } from "@capakraken/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import {
|
|
controllerProcedure,
|
|
managerProcedure,
|
|
requirePermission,
|
|
} from "../trpc.js";
|
|
import { getEstimateById } from "@capakraken/application";
|
|
|
|
type WeeklyPhasingMeta = {
|
|
startDate: string;
|
|
endDate: string;
|
|
pattern: string;
|
|
weeklyHours: Record<string, number>;
|
|
generatedAt: string;
|
|
};
|
|
|
|
export const estimatePhasingProcedures = {
|
|
generateWeeklyPhasing: managerProcedure
|
|
.input(GenerateWeeklyPhasingSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
|
|
|
const estimate = await findUniqueOrThrow(
|
|
getEstimateById(
|
|
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
|
input.estimateId,
|
|
),
|
|
"Estimate",
|
|
);
|
|
|
|
const workingVersion = estimate.versions.find(
|
|
(version) => version.status === "WORKING",
|
|
);
|
|
|
|
if (!workingVersion) {
|
|
throw new TRPCError({
|
|
code: "PRECONDITION_FAILED",
|
|
message: "Estimate has no working version",
|
|
});
|
|
}
|
|
|
|
const pattern = input.pattern ?? "even";
|
|
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 });
|
|
}
|
|
|
|
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 findUniqueOrThrow(
|
|
getEstimateById(
|
|
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
|
input.estimateId,
|
|
),
|
|
"Estimate",
|
|
);
|
|
|
|
const version = estimate.versions[0];
|
|
if (!version) {
|
|
throw new TRPCError({
|
|
code: "PRECONDITION_FAILED",
|
|
message: "Estimate has no versions",
|
|
});
|
|
}
|
|
|
|
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) {
|
|
continue;
|
|
}
|
|
|
|
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: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
estimateId: input.estimateId,
|
|
versionId: version.id,
|
|
hasPhasing: true as const,
|
|
config: phasingConfig,
|
|
weeks: generateWeekRange(phasingConfig.startDate, phasingConfig.endDate),
|
|
lines: linesWithPhasing,
|
|
chapterAggregation: aggregateWeeklyByChapter(linesWithPhasing),
|
|
};
|
|
}),
|
|
};
|