Files
CapaKraken/packages/application/src/use-cases/estimate/create-planning-handoff.ts
T

304 lines
9.4 KiB
TypeScript

import { countWorkingDays } from "@planarchy/engine";
import { countEstimateHandoffPlanningEntries } from "../allocation/count-estimate-handoff-planning-entries.js";
import { createAssignment } from "../allocation/create-assignment.js";
import { createDemandRequirement } from "../allocation/create-demand-requirement.js";
import {
AllocationStatus,
EstimateVersionStatus,
type CreateEstimatePlanningHandoffInput,
type EstimatePlanningHandoffAllocationRef,
type EstimatePlanningHandoffResult,
type WeekdayAvailability,
} from "@planarchy/shared";
import {
ESTIMATE_DETAIL_INCLUDE,
PROJECT_SNAPSHOT_SELECT,
type EstimateDbClient,
type EstimateVersionDetails,
type EstimateWithDetails,
} from "./shared.js";
const STANDARD_BUSINESS_AVAILABILITY: WeekdayAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
};
function roundHoursPerDay(value: number) {
return Math.max(0.25, Math.min(24, Number(value.toFixed(2))));
}
function resolveVersion(
estimate: EstimateWithDetails,
versionId: string | undefined | null,
): EstimateVersionDetails {
const version = versionId
? estimate.versions.find((candidate) => candidate.id === versionId)
: estimate.versions.find((candidate) => candidate.status === EstimateVersionStatus.APPROVED);
if (!version) {
throw new Error(versionId ? "Estimate version not found" : "Estimate has no approved version");
}
if (version.status !== EstimateVersionStatus.APPROVED) {
throw new Error("Only approved versions can be handed off to planning");
}
return version;
}
async function getEstimateOrThrow(
db: EstimateDbClient,
estimateId: string,
): Promise<EstimateWithDetails> {
const estimate = await db.estimate.findUnique({
where: { id: estimateId },
include: ESTIMATE_DETAIL_INCLUDE,
});
if (!estimate) {
throw new Error("Estimate not found");
}
return estimate;
}
function canFallbackToPlaceholder(error: unknown) {
if (!(error instanceof Error)) {
return false;
}
return [
"Resource not found",
"Resource is required for non-placeholder allocations",
"Resource has availability conflicts on",
].some((message) => error.message.startsWith(message));
}
export async function createEstimatePlanningHandoff(
db: EstimateDbClient,
input: CreateEstimatePlanningHandoffInput,
): Promise<EstimatePlanningHandoffResult> {
const estimate = await getEstimateOrThrow(db, input.estimateId);
const targetVersion = resolveVersion(estimate, input.versionId);
if (!estimate.projectId) {
throw new Error("Estimate must be linked to a project before planning handoff");
}
const project = await db.project.findUnique({
where: { id: estimate.projectId },
select: PROJECT_SNAPSHOT_SELECT,
});
if (!project) {
throw new Error("Linked project not found");
}
if (project.endDate < project.startDate) {
throw new Error("Linked project has an invalid date range");
}
const existingHandoffPlanningEntryCount =
await countEstimateHandoffPlanningEntries(db, {
projectId: estimate.projectId,
estimateVersionId: targetVersion.id,
});
if (existingHandoffPlanningEntryCount > 0) {
throw new Error("Planning handoff already exists for this approved version");
}
const resourceIds = [
...new Set(
targetVersion.demandLines
.map((line) => line.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string"),
),
];
const resources = resourceIds.length
? await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
availability: true,
},
})
: [];
const resourceMap = new Map(
resources.map((resource) => [
resource.id,
resource.availability as unknown as WeekdayAvailability,
]),
);
const createdHandoff = await db.$transaction(async (tx) => {
const handoffAllocations: EstimatePlanningHandoffAllocationRef[] = [];
let fallbackPlaceholderCount = 0;
for (const line of targetVersion.demandLines) {
if (line.hours <= 0) {
continue;
}
const headcount = Math.max(1, Math.ceil(line.fte != null && line.fte > 0 ? line.fte : 1));
const resourceAvailability =
line.resourceId != null ? resourceMap.get(line.resourceId) : null;
const workingDays = countWorkingDays(
project.startDate,
project.endDate,
resourceAvailability ?? STANDARD_BUSINESS_AVAILABILITY,
);
if (workingDays <= 0) {
throw new Error(`Project window has no working days for demand line "${line.name}"`);
}
const hoursPerDay = roundHoursPerDay(line.hours / workingDays / headcount);
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
const estimateHandoffMetadata = {
estimateId: estimate.id,
estimateVersionId: targetVersion.id,
estimateVersionNumber: targetVersion.versionNumber,
estimateDemandLineId: line.id,
estimateDemandLineName: line.name,
handedOffAt: new Date().toISOString(),
estimatedHours: line.hours,
estimatedDays: line.days,
estimatedFte: line.fte,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
currency: line.currency,
costTotalCents: line.costTotalCents,
priceTotalCents: line.priceTotalCents,
sourceMetadata: line.metadata,
sourceStaffingAttributes: line.staffingAttributes,
sourceMonthlySpread: line.monthlySpread,
} satisfies Record<string, unknown>;
const demandMetadata = {
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
estimateHandoff: estimateHandoffMetadata,
} satisfies Record<string, unknown>;
try {
if (line.resourceId) {
const demandRequirement = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
headcount,
status: AllocationStatus.PROPOSED,
metadata: {
...demandMetadata,
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: "resource",
linkedResourceId: line.resourceId,
},
},
},
);
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: line.resourceId,
demandRequirementId: demandRequirement.id,
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
status: AllocationStatus.PROPOSED,
metadata: {
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: "resource",
linkedResourceId: line.resourceId,
},
},
},
);
handoffAllocations.push({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
isPlaceholder: false,
});
continue;
}
} catch (error) {
if (!canFallbackToPlaceholder(error)) {
throw error;
}
}
const placeholder = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
headcount,
status: AllocationStatus.PROPOSED,
metadata: {
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: line.resourceId ? "fallback_placeholder" : "placeholder",
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
},
},
},
);
if (line.resourceId) {
fallbackPlaceholderCount += 1;
}
handoffAllocations.push({
id: placeholder.id,
projectId: placeholder.projectId,
resourceId: null,
isPlaceholder: true,
});
}
return { allocations: handoffAllocations, fallbackPlaceholderCount };
});
const placeholderCount = createdHandoff.allocations.filter(
(allocation) => allocation.isPlaceholder,
).length;
const assignedCount = createdHandoff.allocations.length - placeholderCount;
return {
estimateId: estimate.id,
estimateVersionId: targetVersion.id,
estimateVersionNumber: targetVersion.versionNumber,
projectId: project.id,
createdCount: createdHandoff.allocations.length,
placeholderCount,
assignedCount,
fallbackPlaceholderCount: createdHandoff.fallbackPlaceholderCount,
allocations: createdHandoff.allocations,
};
}