refactor(api): extract scenario router helpers
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import type { ScenarioChangeInput, ScenarioDb } from "./scenario-shared.js";
|
||||||
|
|
||||||
|
export async function applyProjectScenario(
|
||||||
|
db: ScenarioDb,
|
||||||
|
input: {
|
||||||
|
projectId: string;
|
||||||
|
changes: ScenarioChangeInput[];
|
||||||
|
userId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { projectId, changes, userId } = input;
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.remove && change.assignmentId) {
|
||||||
|
await db.assignment.update({
|
||||||
|
where: { id: change.assignmentId },
|
||||||
|
data: { status: "CANCELLED" },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.assignmentId) {
|
||||||
|
await db.assignment.update({
|
||||||
|
where: { id: change.assignmentId },
|
||||||
|
data: {
|
||||||
|
startDate: change.startDate,
|
||||||
|
endDate: change.endDate,
|
||||||
|
hoursPerDay: change.hoursPerDay,
|
||||||
|
...(change.resourceId ? { resourceId: change.resourceId } : {}),
|
||||||
|
...(change.roleId ? { roleId: change.roleId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(change.assignmentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!change.resourceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await db.resource.findUnique({
|
||||||
|
where: { id: change.resourceId },
|
||||||
|
select: { lcrCents: true },
|
||||||
|
});
|
||||||
|
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
|
||||||
|
|
||||||
|
const newAssignment = await db.assignment.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
resourceId: change.resourceId,
|
||||||
|
...(change.roleId ? { roleId: change.roleId } : {}),
|
||||||
|
startDate: change.startDate,
|
||||||
|
endDate: change.endDate,
|
||||||
|
hoursPerDay: change.hoursPerDay,
|
||||||
|
percentage: 100,
|
||||||
|
dailyCostCents,
|
||||||
|
status: "PROPOSED",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(newAssignment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void createAuditEntry({
|
||||||
|
db,
|
||||||
|
entityType: "ScenarioApplication",
|
||||||
|
entityId: projectId,
|
||||||
|
entityName: project.name,
|
||||||
|
action: "CREATE",
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
|
summary: `Applied scenario to project "${project.name}" (${created.length} allocations created/modified)`,
|
||||||
|
metadata: { appliedCount: created.length, assignmentIds: created },
|
||||||
|
source: "ui",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { appliedCount: created.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
calculateScenarioEntryHours,
|
||||||
|
getScenarioAvailability,
|
||||||
|
loadScenarioAvailabilityContexts,
|
||||||
|
scenarioBaselineAssignmentInclude,
|
||||||
|
scenarioBaselineProjectSelect,
|
||||||
|
scenarioDemandInclude,
|
||||||
|
type ScenarioDb,
|
||||||
|
} from "./scenario-shared.js";
|
||||||
|
|
||||||
|
export async function readProjectScenarioBaseline(
|
||||||
|
db: ScenarioDb,
|
||||||
|
projectId: string,
|
||||||
|
) {
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: scenarioBaselineProjectSelect,
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = await db.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
include: scenarioBaselineAssignmentInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
const demands = await db.demandRequirement.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
include: scenarioDemandInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignmentRangeStart = assignments.length > 0
|
||||||
|
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||||
|
: project.startDate;
|
||||||
|
const assignmentRangeEnd = assignments.length > 0
|
||||||
|
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||||
|
: project.endDate;
|
||||||
|
|
||||||
|
const contexts = await loadScenarioAvailabilityContexts(
|
||||||
|
db,
|
||||||
|
assignments
|
||||||
|
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : [])),
|
||||||
|
assignmentRangeStart,
|
||||||
|
assignmentRangeEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baselineAllocations = assignments.map((assignment) => {
|
||||||
|
const availability = getScenarioAvailability(assignment.resource?.availability);
|
||||||
|
const lcrCents = assignment.resource?.lcrCents ?? 0;
|
||||||
|
const totalHours = calculateScenarioEntryHours({
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
lcrCents,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
availability,
|
||||||
|
}, {
|
||||||
|
periodStart: assignmentRangeStart,
|
||||||
|
periodEnd: assignmentRangeEnd,
|
||||||
|
contexts,
|
||||||
|
});
|
||||||
|
const costCents = Math.round(totalHours * lcrCents);
|
||||||
|
const workingDays = assignment.hoursPerDay > 0
|
||||||
|
? Math.round((totalHours / assignment.hoursPerDay) * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: assignment.id,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
resourceName: assignment.resource?.displayName ?? "Unknown",
|
||||||
|
resourceEid: assignment.resource?.eid ?? "",
|
||||||
|
lcrCents,
|
||||||
|
roleId: assignment.roleId,
|
||||||
|
roleName: assignment.roleEntity?.name ?? assignment.role ?? "",
|
||||||
|
roleColor: assignment.roleEntity?.color ?? null,
|
||||||
|
startDate: assignment.startDate.toISOString(),
|
||||||
|
endDate: assignment.endDate.toISOString(),
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
status: assignment.status,
|
||||||
|
costCents,
|
||||||
|
totalHours,
|
||||||
|
workingDays,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baselineDemands = demands.map((demand) => ({
|
||||||
|
id: demand.id,
|
||||||
|
roleId: demand.roleId,
|
||||||
|
roleName: demand.roleEntity?.name ?? demand.role ?? "",
|
||||||
|
roleColor: demand.roleEntity?.color ?? null,
|
||||||
|
startDate: demand.startDate.toISOString(),
|
||||||
|
endDate: demand.endDate.toISOString(),
|
||||||
|
hoursPerDay: demand.hoursPerDay,
|
||||||
|
headcount: demand.headcount,
|
||||||
|
status: demand.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
assignments: baselineAllocations,
|
||||||
|
demands: baselineDemands,
|
||||||
|
totalCostCents: baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0),
|
||||||
|
totalHours: baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0),
|
||||||
|
budgetCents: project.budgetCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { calculateAllocation } from "@capakraken/engine/allocation";
|
||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import {
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
|
export type ScenarioDb = TRPCContext["db"];
|
||||||
|
|
||||||
|
export type ScenarioChangeInput = {
|
||||||
|
assignmentId?: string | undefined;
|
||||||
|
resourceId?: string | undefined;
|
||||||
|
roleId?: string | undefined;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
remove?: boolean | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScenarioAvailability = {
|
||||||
|
monday: number;
|
||||||
|
tuesday: number;
|
||||||
|
wednesday: number;
|
||||||
|
thursday: number;
|
||||||
|
friday: number;
|
||||||
|
saturday: number;
|
||||||
|
sunday: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScenarioEntry = {
|
||||||
|
resourceId: string | null;
|
||||||
|
lcrCents: number;
|
||||||
|
hoursPerDay: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
availability: ScenarioAvailability;
|
||||||
|
isNew: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScenarioResourceContextInput = {
|
||||||
|
id: string;
|
||||||
|
availability: unknown;
|
||||||
|
countryId: string | null;
|
||||||
|
country?: { code: string } | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityId: string | null;
|
||||||
|
metroCity?: { name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScenarioSkillsValue = Array<{ skill?: string | null }> | null | undefined;
|
||||||
|
|
||||||
|
export const DEFAULT_AVAILABILITY: ScenarioAvailability = {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scenarioRoleSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioResourceSelect = {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
eid: true,
|
||||||
|
lcrCents: true,
|
||||||
|
availability: true,
|
||||||
|
chargeabilityTarget: true,
|
||||||
|
skills: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioBaselineProjectSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortCode: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
budgetCents: true,
|
||||||
|
orderType: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioSimulationProjectSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
budgetCents: true,
|
||||||
|
orderType: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioBaselineAssignmentInclude = {
|
||||||
|
resource: { select: scenarioResourceSelect },
|
||||||
|
roleEntity: { select: scenarioRoleSelect },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioDemandInclude = {
|
||||||
|
roleEntity: { select: scenarioRoleSelect },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioSimulationAssignmentInclude = {
|
||||||
|
resource: { select: scenarioResourceSelect },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const scenarioUtilizationAssignmentSelect = {
|
||||||
|
id: true,
|
||||||
|
resourceId: true,
|
||||||
|
projectId: true,
|
||||||
|
hoursPerDay: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getScenarioAvailability(availability: unknown): ScenarioAvailability {
|
||||||
|
return (availability as ScenarioAvailability) ?? DEFAULT_AVAILABILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundToTenths(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectScenarioSkillSet(skills: ScenarioSkillsValue): Set<string> {
|
||||||
|
const normalized = new Set<string>();
|
||||||
|
for (const skill of skills ?? []) {
|
||||||
|
if (typeof skill?.skill === "string" && skill.skill.trim().length > 0) {
|
||||||
|
normalized.add(skill.skill.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResourceAvailabilityContextInput(resource: ScenarioResourceContextInput) {
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadScenarioAvailabilityContexts(
|
||||||
|
db: ScenarioDb,
|
||||||
|
resources: ScenarioResourceContextInput[],
|
||||||
|
periodStart: Date,
|
||||||
|
periodEnd: Date,
|
||||||
|
) {
|
||||||
|
return loadResourceDailyAvailabilityContexts(
|
||||||
|
db,
|
||||||
|
resources.map(toResourceAvailabilityContextInput),
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateScenarioEntryHours(
|
||||||
|
entry: Pick<ScenarioEntry, "resourceId" | "lcrCents" | "hoursPerDay" | "startDate" | "endDate" | "availability">,
|
||||||
|
options: {
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
contexts: Awaited<ReturnType<typeof loadScenarioAvailabilityContexts>>;
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
if (!entry.resourceId) {
|
||||||
|
return calculateAllocation({
|
||||||
|
lcrCents: entry.lcrCents,
|
||||||
|
hoursPerDay: entry.hoursPerDay,
|
||||||
|
startDate: entry.startDate,
|
||||||
|
endDate: entry.endDate,
|
||||||
|
availability: entry.availability,
|
||||||
|
}).totalHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateEffectiveBookedHours({
|
||||||
|
availability: entry.availability,
|
||||||
|
startDate: entry.startDate,
|
||||||
|
endDate: entry.endDate,
|
||||||
|
hoursPerDay: entry.hoursPerDay,
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
context: options.contexts.get(entry.resourceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours } from "../lib/resource-capacity.js";
|
||||||
|
import {
|
||||||
|
calculateScenarioEntryHours,
|
||||||
|
collectScenarioSkillSet,
|
||||||
|
getScenarioAvailability,
|
||||||
|
loadScenarioAvailabilityContexts,
|
||||||
|
roundToTenths,
|
||||||
|
scenarioResourceSelect,
|
||||||
|
scenarioSimulationAssignmentInclude,
|
||||||
|
scenarioSimulationProjectSelect,
|
||||||
|
scenarioUtilizationAssignmentSelect,
|
||||||
|
type ScenarioChangeInput,
|
||||||
|
type ScenarioDb,
|
||||||
|
type ScenarioEntry,
|
||||||
|
} from "./scenario-shared.js";
|
||||||
|
|
||||||
|
export async function simulateProjectScenario(
|
||||||
|
db: ScenarioDb,
|
||||||
|
input: {
|
||||||
|
projectId: string;
|
||||||
|
changes: ScenarioChangeInput[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { projectId, changes } = input;
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: scenarioSimulationProjectSelect,
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAssignments = await db.assignment.findMany({
|
||||||
|
where: { projectId, status: { not: "CANCELLED" } },
|
||||||
|
include: scenarioSimulationAssignmentInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.resourceId) resourceIds.add(change.resourceId);
|
||||||
|
}
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
if (assignment.resourceId) resourceIds.add(assignment.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db.resource.findMany({
|
||||||
|
where: { id: { in: [...resourceIds] } },
|
||||||
|
select: scenarioResourceSelect,
|
||||||
|
});
|
||||||
|
const resourceMap = new Map(resources.map((resource) => [resource.id, resource]));
|
||||||
|
|
||||||
|
const removedAssignmentIds = new Set(
|
||||||
|
changes.filter((change) => change.remove && change.assignmentId).map((change) => change.assignmentId!),
|
||||||
|
);
|
||||||
|
const modifiedAssignmentIds = new Set(
|
||||||
|
changes.filter((change) => !change.remove && change.assignmentId).map((change) => change.assignmentId!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scenarioEntries: ScenarioEntry[] = [];
|
||||||
|
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
if (removedAssignmentIds.has(assignment.id)) continue;
|
||||||
|
if (modifiedAssignmentIds.has(assignment.id)) continue;
|
||||||
|
|
||||||
|
scenarioEntries.push({
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
lcrCents: assignment.resource?.lcrCents ?? 0,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
availability: getScenarioAvailability(assignment.resource?.availability),
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.remove) continue;
|
||||||
|
|
||||||
|
const resource = change.resourceId ? resourceMap.get(change.resourceId) : null;
|
||||||
|
scenarioEntries.push({
|
||||||
|
resourceId: change.resourceId ?? null,
|
||||||
|
lcrCents: resource?.lcrCents ?? 0,
|
||||||
|
hoursPerDay: change.hoursPerDay,
|
||||||
|
startDate: change.startDate,
|
||||||
|
endDate: change.endDate,
|
||||||
|
availability: getScenarioAvailability(resource?.availability),
|
||||||
|
isNew: !change.assignmentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedResourceIds = [...new Set(
|
||||||
|
scenarioEntries
|
||||||
|
.map((entry) => entry.resourceId)
|
||||||
|
.filter((resourceId): resourceId is string => Boolean(resourceId)),
|
||||||
|
)];
|
||||||
|
|
||||||
|
const allAssignmentsForResources = affectedResourceIds.length > 0
|
||||||
|
? await db.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
resourceId: { in: affectedResourceIds },
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
select: scenarioUtilizationAssignmentSelect,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const assignmentsByResource = new Map<string, typeof allAssignmentsForResources>();
|
||||||
|
for (const assignment of allAssignmentsForResources) {
|
||||||
|
if (!assignment.resourceId) continue;
|
||||||
|
const existing = assignmentsByResource.get(assignment.resourceId) ?? [];
|
||||||
|
existing.push(assignment);
|
||||||
|
assignmentsByResource.set(assignment.resourceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let windowStart = project.startDate;
|
||||||
|
let windowEnd = project.endDate;
|
||||||
|
for (const entry of scenarioEntries) {
|
||||||
|
if (entry.startDate < windowStart) windowStart = entry.startDate;
|
||||||
|
if (entry.endDate > windowEnd) windowEnd = entry.endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = await loadScenarioAvailabilityContexts(
|
||||||
|
db,
|
||||||
|
resources,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
let scenarioCostCents = 0;
|
||||||
|
let scenarioHours = 0;
|
||||||
|
for (const entry of scenarioEntries) {
|
||||||
|
const totalHours = calculateScenarioEntryHours(entry, {
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
contexts,
|
||||||
|
});
|
||||||
|
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
|
||||||
|
scenarioHours += totalHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baselineCostCents = 0;
|
||||||
|
let baselineHours = 0;
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
const totalHours = calculateScenarioEntryHours({
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
lcrCents: assignment.resource?.lcrCents ?? 0,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
availability: getScenarioAvailability(assignment.resource?.availability),
|
||||||
|
}, {
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
contexts,
|
||||||
|
});
|
||||||
|
baselineHours += totalHours;
|
||||||
|
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceImpacts = affectedResourceIds.map((resourceId) => {
|
||||||
|
const resource = resourceMap.get(resourceId);
|
||||||
|
if (!resource) return null;
|
||||||
|
|
||||||
|
const availability = getScenarioAvailability(resource.availability);
|
||||||
|
const context = contexts.get(resourceId);
|
||||||
|
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentProjectAssignments = (assignmentsByResource.get(resourceId) ?? []).filter(
|
||||||
|
(assignment) => assignment.projectId === projectId,
|
||||||
|
);
|
||||||
|
let currentProjectHours = 0;
|
||||||
|
for (const assignment of currentProjectAssignments) {
|
||||||
|
currentProjectHours += calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioResourceEntries = scenarioEntries.filter((entry) => entry.resourceId === resourceId);
|
||||||
|
let scenarioProjectHours = 0;
|
||||||
|
for (const entry of scenarioResourceEntries) {
|
||||||
|
scenarioProjectHours += calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: entry.startDate,
|
||||||
|
endDate: entry.endDate,
|
||||||
|
hoursPerDay: entry.hoursPerDay,
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherProjectAssignments = (assignmentsByResource.get(resourceId) ?? []).filter(
|
||||||
|
(assignment) => assignment.projectId !== projectId,
|
||||||
|
);
|
||||||
|
let otherProjectsHours = 0;
|
||||||
|
for (const assignment of otherProjectAssignments) {
|
||||||
|
otherProjectsHours += calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
periodStart: windowStart,
|
||||||
|
periodEnd: windowEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTotalHours = otherProjectsHours + currentProjectHours;
|
||||||
|
const scenarioTotalHours = otherProjectsHours + scenarioProjectHours;
|
||||||
|
const currentUtilization = totalAvailableHours > 0 ? (currentTotalHours / totalAvailableHours) * 100 : 0;
|
||||||
|
const scenarioUtilization = totalAvailableHours > 0 ? (scenarioTotalHours / totalAvailableHours) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceId,
|
||||||
|
resourceName: resource.displayName,
|
||||||
|
chargeabilityTarget: resource.chargeabilityTarget,
|
||||||
|
currentUtilization: roundToTenths(currentUtilization),
|
||||||
|
scenarioUtilization: roundToTenths(scenarioUtilization),
|
||||||
|
utilizationDelta: roundToTenths(scenarioUtilization - currentUtilization),
|
||||||
|
isOverallocated: scenarioUtilization > 100,
|
||||||
|
};
|
||||||
|
}).filter((impact): impact is NonNullable<typeof impact> => impact !== null);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const impact of resourceImpacts) {
|
||||||
|
if (impact.isOverallocated) {
|
||||||
|
warnings.push(
|
||||||
|
`${impact.resourceName} would be at ${impact.scenarioUtilization.toFixed(1)}% utilization (over-allocated)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetCents = project.budgetCents ?? 0;
|
||||||
|
if (budgetCents > 0 && scenarioCostCents > budgetCents) {
|
||||||
|
const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100);
|
||||||
|
warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSkills = new Set<string>();
|
||||||
|
for (const assignment of currentAssignments) {
|
||||||
|
for (const skill of collectScenarioSkillSet(assignment.resource?.skills as Array<{ skill?: string | null }> | null | undefined)) {
|
||||||
|
currentSkills.add(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioSkills = new Set<string>();
|
||||||
|
for (const entry of scenarioEntries) {
|
||||||
|
if (!entry.resourceId) continue;
|
||||||
|
const resource = resourceMap.get(entry.resourceId);
|
||||||
|
for (const skill of collectScenarioSkillSet(resource?.skills as Array<{ skill?: string | null }> | null | undefined)) {
|
||||||
|
scenarioSkills.add(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineSkillCount = currentSkills.size;
|
||||||
|
const scenarioSkillCount = scenarioSkills.size;
|
||||||
|
const skillCoveragePct = baselineSkillCount > 0
|
||||||
|
? Math.round((scenarioSkillCount / baselineSkillCount) * 100)
|
||||||
|
: scenarioSkillCount > 0 ? 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseline: {
|
||||||
|
totalCostCents: baselineCostCents,
|
||||||
|
totalHours: baselineHours,
|
||||||
|
headcount: currentAssignments.length,
|
||||||
|
skillCount: baselineSkillCount,
|
||||||
|
},
|
||||||
|
scenario: {
|
||||||
|
totalCostCents: scenarioCostCents,
|
||||||
|
totalHours: scenarioHours,
|
||||||
|
headcount: scenarioEntries.length,
|
||||||
|
skillCount: scenarioSkillCount,
|
||||||
|
},
|
||||||
|
delta: {
|
||||||
|
costCents: scenarioCostCents - baselineCostCents,
|
||||||
|
hours: scenarioHours - baselineHours,
|
||||||
|
headcount: scenarioEntries.length - currentAssignments.length,
|
||||||
|
skillCoveragePct,
|
||||||
|
},
|
||||||
|
resourceImpacts,
|
||||||
|
warnings,
|
||||||
|
budgetCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,25 +1,9 @@
|
|||||||
import { calculateAllocation } from "@capakraken/engine/allocation";
|
|
||||||
import { PermissionKey } from "@capakraken/shared";
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { applyProjectScenario } from "./scenario-apply.js";
|
||||||
import {
|
import { readProjectScenarioBaseline } from "./scenario-baseline.js";
|
||||||
calculateEffectiveAvailableHours,
|
import { simulateProjectScenario } from "./scenario-simulation.js";
|
||||||
calculateEffectiveBookedHours,
|
|
||||||
loadResourceDailyAvailabilityContexts,
|
|
||||||
} from "../lib/resource-capacity.js";
|
|
||||||
|
|
||||||
const DEFAULT_AVAILABILITY = {
|
|
||||||
monday: 8,
|
|
||||||
tuesday: 8,
|
|
||||||
wednesday: 8,
|
|
||||||
thursday: 8,
|
|
||||||
friday: 8,
|
|
||||||
saturday: 0,
|
|
||||||
sunday: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const ScenarioChangeSchema = z.object({
|
const ScenarioChangeSchema = z.object({
|
||||||
/** Existing assignment to modify — omit to add a new allocation */
|
/** Existing assignment to modify — omit to add a new allocation */
|
||||||
@@ -46,148 +30,7 @@ export const scenarioRouter = createTRPCRouter({
|
|||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||||
|
return readProjectScenarioBaseline(ctx.db, input.projectId);
|
||||||
const project = await ctx.db.project.findUnique({
|
|
||||||
where: { id: input.projectId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
shortCode: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
budgetCents: true,
|
|
||||||
orderType: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!project) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignments = await ctx.db.assignment.findMany({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
status: { not: "CANCELLED" },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
resource: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
eid: true,
|
|
||||||
lcrCents: true,
|
|
||||||
availability: true,
|
|
||||||
chargeabilityTarget: true,
|
|
||||||
skills: true,
|
|
||||||
countryId: true,
|
|
||||||
federalState: true,
|
|
||||||
metroCityId: true,
|
|
||||||
country: { select: { code: true } },
|
|
||||||
metroCity: { select: { name: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roleEntity: { select: { id: true, name: true, color: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const demands = await ctx.db.demandRequirement.findMany({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
status: { not: "CANCELLED" },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
roleEntity: { select: { id: true, name: true, color: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignmentRangeStart = assignments.length > 0
|
|
||||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
|
||||||
: project.startDate;
|
|
||||||
const assignmentRangeEnd = assignments.length > 0
|
|
||||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
|
||||||
: project.endDate;
|
|
||||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
||||||
ctx.db,
|
|
||||||
assignments
|
|
||||||
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : []))
|
|
||||||
.map((resource) => ({
|
|
||||||
id: resource.id,
|
|
||||||
availability: resource.availability as unknown as WeekdayAvailability,
|
|
||||||
countryId: resource.countryId,
|
|
||||||
countryCode: resource.country?.code,
|
|
||||||
federalState: resource.federalState,
|
|
||||||
metroCityId: resource.metroCityId,
|
|
||||||
metroCityName: resource.metroCity?.name,
|
|
||||||
})),
|
|
||||||
assignmentRangeStart,
|
|
||||||
assignmentRangeEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
const baselineAllocations = assignments.map((a) => {
|
|
||||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
|
||||||
const lcrCents = a.resource?.lcrCents ?? 0;
|
|
||||||
const totalHours = a.resourceId
|
|
||||||
? calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
periodStart: assignmentRangeStart,
|
|
||||||
periodEnd: assignmentRangeEnd,
|
|
||||||
context: contexts.get(a.resourceId),
|
|
||||||
})
|
|
||||||
: calculateAllocation({
|
|
||||||
lcrCents,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
availability,
|
|
||||||
}).totalHours;
|
|
||||||
const costCents = Math.round(totalHours * lcrCents);
|
|
||||||
const workingDays = a.hoursPerDay > 0
|
|
||||||
? Math.round((totalHours / a.hoursPerDay) * 100) / 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: a.id,
|
|
||||||
resourceId: a.resourceId,
|
|
||||||
resourceName: a.resource?.displayName ?? "Unknown",
|
|
||||||
resourceEid: a.resource?.eid ?? "",
|
|
||||||
lcrCents,
|
|
||||||
roleId: a.roleId,
|
|
||||||
roleName: a.roleEntity?.name ?? a.role ?? "",
|
|
||||||
roleColor: a.roleEntity?.color ?? null,
|
|
||||||
startDate: a.startDate.toISOString(),
|
|
||||||
endDate: a.endDate.toISOString(),
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
status: a.status,
|
|
||||||
costCents,
|
|
||||||
totalHours,
|
|
||||||
workingDays,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const totalCostCents = baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0);
|
|
||||||
const totalHours = baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0);
|
|
||||||
|
|
||||||
const baselineDemands = demands.map((d) => ({
|
|
||||||
id: d.id,
|
|
||||||
roleId: d.roleId,
|
|
||||||
roleName: d.roleEntity?.name ?? d.role ?? "",
|
|
||||||
roleColor: d.roleEntity?.color ?? null,
|
|
||||||
startDate: d.startDate.toISOString(),
|
|
||||||
endDate: d.endDate.toISOString(),
|
|
||||||
hoursPerDay: d.hoursPerDay,
|
|
||||||
headcount: d.headcount,
|
|
||||||
status: d.status,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
project,
|
|
||||||
assignments: baselineAllocations,
|
|
||||||
demands: baselineDemands,
|
|
||||||
totalCostCents,
|
|
||||||
totalHours,
|
|
||||||
budgetCents: project.budgetCents,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,395 +39,7 @@ export const scenarioRouter = createTRPCRouter({
|
|||||||
*/
|
*/
|
||||||
simulate: controllerProcedure
|
simulate: controllerProcedure
|
||||||
.input(SimulateInputSchema)
|
.input(SimulateInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => simulateProjectScenario(ctx.db, input)),
|
||||||
const { projectId, changes } = input;
|
|
||||||
|
|
||||||
// Load project
|
|
||||||
const project = await ctx.db.project.findUnique({
|
|
||||||
where: { id: projectId },
|
|
||||||
select: { id: true, name: true, budgetCents: true, orderType: true, startDate: true, endDate: true },
|
|
||||||
});
|
|
||||||
if (!project) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load current assignments for baseline
|
|
||||||
const currentAssignments = await ctx.db.assignment.findMany({
|
|
||||||
where: { projectId, status: { not: "CANCELLED" } },
|
|
||||||
include: {
|
|
||||||
resource: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
eid: true,
|
|
||||||
lcrCents: true,
|
|
||||||
availability: true,
|
|
||||||
chargeabilityTarget: true,
|
|
||||||
skills: true,
|
|
||||||
countryId: true,
|
|
||||||
federalState: true,
|
|
||||||
metroCityId: true,
|
|
||||||
country: { select: { code: true } },
|
|
||||||
metroCity: { select: { name: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect all resource IDs we need to look up (from changes)
|
|
||||||
const resourceIds = new Set<string>();
|
|
||||||
for (const c of changes) {
|
|
||||||
if (c.resourceId) resourceIds.add(c.resourceId);
|
|
||||||
}
|
|
||||||
// Also add resources from existing assignments
|
|
||||||
for (const a of currentAssignments) {
|
|
||||||
if (a.resourceId) resourceIds.add(a.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load resources
|
|
||||||
const resources = await ctx.db.resource.findMany({
|
|
||||||
where: { id: { in: [...resourceIds] } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
eid: true,
|
|
||||||
lcrCents: true,
|
|
||||||
availability: true,
|
|
||||||
chargeabilityTarget: true,
|
|
||||||
skills: true,
|
|
||||||
countryId: true,
|
|
||||||
federalState: true,
|
|
||||||
metroCityId: true,
|
|
||||||
country: { select: { code: true } },
|
|
||||||
metroCity: { select: { name: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const resourceMap = new Map(resources.map((r) => [r.id, r]));
|
|
||||||
|
|
||||||
// Load roles referenced in changes
|
|
||||||
const roleIds = new Set<string>();
|
|
||||||
for (const c of changes) {
|
|
||||||
if (c.roleId) roleIds.add(c.roleId);
|
|
||||||
}
|
|
||||||
const roles = roleIds.size > 0
|
|
||||||
? await ctx.db.role.findMany({
|
|
||||||
where: { id: { in: [...roleIds] } },
|
|
||||||
select: { id: true, name: true, color: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const roleMap = new Map(roles.map((r) => [r.id, r]));
|
|
||||||
|
|
||||||
// Build scenario: start from current assignments, apply changes
|
|
||||||
const removedAssignmentIds = new Set(
|
|
||||||
changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!),
|
|
||||||
);
|
|
||||||
const modifiedAssignmentIds = new Set(
|
|
||||||
changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keep untouched assignments
|
|
||||||
const scenarioEntries: Array<{
|
|
||||||
resourceId: string | null;
|
|
||||||
lcrCents: number;
|
|
||||||
hoursPerDay: number;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
availability: typeof DEFAULT_AVAILABILITY;
|
|
||||||
isNew: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const a of currentAssignments) {
|
|
||||||
if (removedAssignmentIds.has(a.id)) continue;
|
|
||||||
if (modifiedAssignmentIds.has(a.id)) continue;
|
|
||||||
|
|
||||||
scenarioEntries.push({
|
|
||||||
resourceId: a.resourceId,
|
|
||||||
lcrCents: a.resource?.lcrCents ?? 0,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
|
|
||||||
isNew: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add modified and new entries from changes
|
|
||||||
for (const c of changes) {
|
|
||||||
if (c.remove) continue;
|
|
||||||
|
|
||||||
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
|
|
||||||
const lcrCents = resource?.lcrCents ?? 0;
|
|
||||||
const availability = (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
|
||||||
|
|
||||||
scenarioEntries.push({
|
|
||||||
resourceId: c.resourceId ?? null,
|
|
||||||
lcrCents,
|
|
||||||
hoursPerDay: c.hoursPerDay,
|
|
||||||
startDate: c.startDate,
|
|
||||||
endDate: c.endDate,
|
|
||||||
availability,
|
|
||||||
isNew: !c.assignmentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute per-resource utilization impact
|
|
||||||
// Load ALL assignments for affected resources (across all projects) to measure total utilization
|
|
||||||
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
|
|
||||||
|
|
||||||
const allAssignmentsForResources = affectedResourceIds.length > 0
|
|
||||||
? await ctx.db.assignment.findMany({
|
|
||||||
where: {
|
|
||||||
resourceId: { in: affectedResourceIds },
|
|
||||||
status: { not: "CANCELLED" },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
resourceId: true,
|
|
||||||
projectId: true,
|
|
||||||
hoursPerDay: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Group by resource
|
|
||||||
const assignmentsByResource = new Map<string, typeof allAssignmentsForResources>();
|
|
||||||
for (const a of allAssignmentsForResources) {
|
|
||||||
if (!a.resourceId) continue;
|
|
||||||
const list = assignmentsByResource.get(a.resourceId) ?? [];
|
|
||||||
list.push(a);
|
|
||||||
assignmentsByResource.set(a.resourceId, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine analysis window (the widest date range from scenario changes)
|
|
||||||
let windowStart = project.startDate;
|
|
||||||
let windowEnd = project.endDate;
|
|
||||||
for (const e of scenarioEntries) {
|
|
||||||
if (e.startDate < windowStart) windowStart = e.startDate;
|
|
||||||
if (e.endDate > windowEnd) windowEnd = e.endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
|
||||||
ctx.db,
|
|
||||||
resources.map((resource) => ({
|
|
||||||
id: resource.id,
|
|
||||||
availability: resource.availability as unknown as WeekdayAvailability,
|
|
||||||
countryId: resource.countryId,
|
|
||||||
countryCode: resource.country?.code,
|
|
||||||
federalState: resource.federalState,
|
|
||||||
metroCityId: resource.metroCityId,
|
|
||||||
metroCityName: resource.metroCity?.name,
|
|
||||||
})),
|
|
||||||
windowStart,
|
|
||||||
windowEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
function calculateEntryHours(entry: {
|
|
||||||
resourceId: string | null;
|
|
||||||
lcrCents: number;
|
|
||||||
hoursPerDay: number;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
availability: typeof DEFAULT_AVAILABILITY;
|
|
||||||
}) {
|
|
||||||
if (!entry.resourceId) {
|
|
||||||
return calculateAllocation({
|
|
||||||
lcrCents: entry.lcrCents,
|
|
||||||
hoursPerDay: entry.hoursPerDay,
|
|
||||||
startDate: entry.startDate,
|
|
||||||
endDate: entry.endDate,
|
|
||||||
availability: entry.availability,
|
|
||||||
}).totalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
return calculateEffectiveBookedHours({
|
|
||||||
availability: entry.availability,
|
|
||||||
startDate: entry.startDate,
|
|
||||||
endDate: entry.endDate,
|
|
||||||
hoursPerDay: entry.hoursPerDay,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context: contexts.get(entry.resourceId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute scenario totals
|
|
||||||
let scenarioCostCents = 0;
|
|
||||||
let scenarioHours = 0;
|
|
||||||
|
|
||||||
for (const entry of scenarioEntries) {
|
|
||||||
const totalHours = calculateEntryHours(entry);
|
|
||||||
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
|
|
||||||
scenarioHours += totalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
let baselineCostCents = 0;
|
|
||||||
let baselineHours = 0;
|
|
||||||
for (const assignment of currentAssignments) {
|
|
||||||
const availability = (assignment.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
|
||||||
const totalHours = assignment.resourceId
|
|
||||||
? calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: assignment.startDate,
|
|
||||||
endDate: assignment.endDate,
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context: contexts.get(assignment.resourceId),
|
|
||||||
})
|
|
||||||
: calculateAllocation({
|
|
||||||
lcrCents: assignment.resource?.lcrCents ?? 0,
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
startDate: assignment.startDate,
|
|
||||||
endDate: assignment.endDate,
|
|
||||||
availability,
|
|
||||||
}).totalHours;
|
|
||||||
baselineHours += totalHours;
|
|
||||||
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceImpacts = affectedResourceIds.map((resId) => {
|
|
||||||
const resource = resourceMap.get(resId);
|
|
||||||
if (!resource) return null;
|
|
||||||
|
|
||||||
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
|
||||||
const context = contexts.get(resId);
|
|
||||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
|
||||||
availability,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Current utilization on this project
|
|
||||||
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
|
||||||
(a) => a.projectId === projectId,
|
|
||||||
);
|
|
||||||
let currentProjectHours = 0;
|
|
||||||
for (const a of currentProjectAssignments) {
|
|
||||||
currentProjectHours += calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scenario hours for this resource on this project
|
|
||||||
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
|
|
||||||
let scenarioProjectHours = 0;
|
|
||||||
for (const e of scenarioResourceEntries) {
|
|
||||||
scenarioProjectHours += calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: e.startDate,
|
|
||||||
endDate: e.endDate,
|
|
||||||
hoursPerDay: e.hoursPerDay,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total hours across all projects (excluding this project's current, adding scenario)
|
|
||||||
const otherProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
|
||||||
(a) => a.projectId !== projectId,
|
|
||||||
);
|
|
||||||
let otherProjectsHours = 0;
|
|
||||||
for (const a of otherProjectAssignments) {
|
|
||||||
otherProjectsHours += calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
periodStart: windowStart,
|
|
||||||
periodEnd: windowEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTotalHours = otherProjectsHours + currentProjectHours;
|
|
||||||
const scenarioTotalHours = otherProjectsHours + scenarioProjectHours;
|
|
||||||
|
|
||||||
const currentUtilization = totalAvailableHours > 0 ? (currentTotalHours / totalAvailableHours) * 100 : 0;
|
|
||||||
const scenarioUtilization = totalAvailableHours > 0 ? (scenarioTotalHours / totalAvailableHours) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
resourceId: resId,
|
|
||||||
resourceName: resource.displayName,
|
|
||||||
chargeabilityTarget: resource.chargeabilityTarget,
|
|
||||||
currentUtilization: Math.round(currentUtilization * 10) / 10,
|
|
||||||
scenarioUtilization: Math.round(scenarioUtilization * 10) / 10,
|
|
||||||
utilizationDelta: Math.round((scenarioUtilization - currentUtilization) * 10) / 10,
|
|
||||||
isOverallocated: scenarioUtilization > 100,
|
|
||||||
};
|
|
||||||
}).filter((x): x is NonNullable<typeof x> => x !== null);
|
|
||||||
|
|
||||||
// Build warnings
|
|
||||||
const warnings: string[] = [];
|
|
||||||
for (const impact of resourceImpacts) {
|
|
||||||
if (impact && impact.isOverallocated) {
|
|
||||||
warnings.push(
|
|
||||||
`${impact.resourceName} would be at ${impact.scenarioUtilization.toFixed(1)}% utilization (over-allocated)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const budgetCents = project.budgetCents ?? 0;
|
|
||||||
if (budgetCents > 0 && scenarioCostCents > budgetCents) {
|
|
||||||
const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100);
|
|
||||||
warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill coverage: how many unique skills does the scenario team bring vs. current?
|
|
||||||
const currentSkills = new Set<string>();
|
|
||||||
const scenarioSkills = new Set<string>();
|
|
||||||
|
|
||||||
for (const a of currentAssignments) {
|
|
||||||
const skills = (a.resource?.skills ?? []) as Array<{ skill: string }>;
|
|
||||||
for (const s of skills) currentSkills.add(s.skill.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of scenarioEntries) {
|
|
||||||
if (!entry.resourceId) continue;
|
|
||||||
const resource = resourceMap.get(entry.resourceId);
|
|
||||||
const skills = (resource?.skills ?? []) as Array<{ skill: string }>;
|
|
||||||
for (const s of skills) scenarioSkills.add(s.skill.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
const baselineSkillCount = currentSkills.size;
|
|
||||||
const scenarioSkillCount = scenarioSkills.size;
|
|
||||||
const skillCoveragePct = baselineSkillCount > 0
|
|
||||||
? Math.round((scenarioSkillCount / baselineSkillCount) * 100)
|
|
||||||
: scenarioSkillCount > 0 ? 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseline: {
|
|
||||||
totalCostCents: baselineCostCents,
|
|
||||||
totalHours: baselineHours,
|
|
||||||
headcount: currentAssignments.length,
|
|
||||||
skillCount: baselineSkillCount,
|
|
||||||
},
|
|
||||||
scenario: {
|
|
||||||
totalCostCents: scenarioCostCents,
|
|
||||||
totalHours: scenarioHours,
|
|
||||||
headcount: scenarioEntries.length,
|
|
||||||
skillCount: scenarioSkillCount,
|
|
||||||
},
|
|
||||||
delta: {
|
|
||||||
costCents: scenarioCostCents - baselineCostCents,
|
|
||||||
hours: scenarioHours - baselineHours,
|
|
||||||
headcount: scenarioEntries.length - currentAssignments.length,
|
|
||||||
skillCoveragePct,
|
|
||||||
},
|
|
||||||
resourceImpacts,
|
|
||||||
warnings,
|
|
||||||
budgetCents,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a scenario: creates real assignments from scenario changes.
|
* Applies a scenario: creates real assignments from scenario changes.
|
||||||
@@ -592,86 +47,8 @@ export const scenarioRouter = createTRPCRouter({
|
|||||||
*/
|
*/
|
||||||
applyScenario: controllerProcedure
|
applyScenario: controllerProcedure
|
||||||
.input(SimulateInputSchema)
|
.input(SimulateInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => applyProjectScenario(ctx.db, {
|
||||||
const { projectId, changes } = input;
|
...input,
|
||||||
|
userId: ctx.dbUser?.id,
|
||||||
const project = await ctx.db.project.findUnique({
|
})),
|
||||||
where: { id: projectId },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
if (!project) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const created: string[] = [];
|
|
||||||
|
|
||||||
for (const change of changes) {
|
|
||||||
if (change.remove && change.assignmentId) {
|
|
||||||
// Cancel the existing assignment
|
|
||||||
await ctx.db.assignment.update({
|
|
||||||
where: { id: change.assignmentId },
|
|
||||||
data: { status: "CANCELLED" },
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (change.assignmentId) {
|
|
||||||
// Modify existing assignment
|
|
||||||
await ctx.db.assignment.update({
|
|
||||||
where: { id: change.assignmentId },
|
|
||||||
data: {
|
|
||||||
startDate: change.startDate,
|
|
||||||
endDate: change.endDate,
|
|
||||||
hoursPerDay: change.hoursPerDay,
|
|
||||||
...(change.resourceId ? { resourceId: change.resourceId } : {}),
|
|
||||||
...(change.roleId ? { roleId: change.roleId } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
created.push(change.assignmentId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!change.resourceId) {
|
|
||||||
// Skip entries without a resource — cannot create an assignment
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the resource LCR for dailyCostCents
|
|
||||||
const resource = await ctx.db.resource.findUnique({
|
|
||||||
where: { id: change.resourceId },
|
|
||||||
select: { lcrCents: true },
|
|
||||||
});
|
|
||||||
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
|
|
||||||
|
|
||||||
const newAssignment = await ctx.db.assignment.create({
|
|
||||||
data: {
|
|
||||||
projectId,
|
|
||||||
resourceId: change.resourceId,
|
|
||||||
...(change.roleId ? { roleId: change.roleId } : {}),
|
|
||||||
startDate: change.startDate,
|
|
||||||
endDate: change.endDate,
|
|
||||||
hoursPerDay: change.hoursPerDay,
|
|
||||||
percentage: 100,
|
|
||||||
dailyCostCents,
|
|
||||||
status: "PROPOSED",
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
created.push(newAssignment.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditEntry({
|
|
||||||
db: ctx.db,
|
|
||||||
entityType: "ScenarioApplication",
|
|
||||||
entityId: projectId,
|
|
||||||
entityName: project.name,
|
|
||||||
action: "CREATE",
|
|
||||||
userId: ctx.dbUser?.id,
|
|
||||||
summary: `Applied scenario to project "${project.name}" (${created.length} allocations created/modified)`,
|
|
||||||
metadata: { appliedCount: created.length, assignmentIds: created },
|
|
||||||
source: "ui",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { appliedCount: created.length };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user