chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user