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 { 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 { 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; const demandMetadata = { ...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}), estimateHandoff: estimateHandoffMetadata, } satisfies Record; try { if (line.resourceId) { const demandRequirement = await createDemandRequirement( tx as unknown as Parameters[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[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[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, }; }