304 lines
9.4 KiB
TypeScript
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,
|
|
};
|
|
}
|