chore(repo): initialize planarchy workspace
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export interface DemandRequirementFillProgressInput {
|
||||
demandRequirementId: string;
|
||||
headcount: number;
|
||||
}
|
||||
|
||||
export const UPDATED_DEMAND_REQUIREMENT_SELECT = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
} as const;
|
||||
|
||||
export async function applyDemandRequirementFillProgress(
|
||||
db: DbClient,
|
||||
input: DemandRequirementFillProgressInput,
|
||||
) {
|
||||
const updatedDemandRequirement = await db.demandRequirement.update({
|
||||
where: { id: input.demandRequirementId },
|
||||
data:
|
||||
input.headcount > 1
|
||||
? { headcount: input.headcount - 1 }
|
||||
: { status: AllocationStatus.COMPLETED },
|
||||
select: UPDATED_DEMAND_REQUIREMENT_SELECT,
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedDemandRequirement,
|
||||
status: updatedDemandRequirement.status as AllocationStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
AllocationLike,
|
||||
AllocationReadModel,
|
||||
Assignment,
|
||||
DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
function toDemandRequirement<TAllocation extends AllocationLike>(
|
||||
allocation: TAllocation,
|
||||
): DemandRequirement<TAllocation> {
|
||||
return {
|
||||
...allocation,
|
||||
kind: "demand",
|
||||
sourceAllocationId: allocation.entityId ?? allocation.id,
|
||||
resourceId: null,
|
||||
isPlaceholder: true,
|
||||
requestedHeadcount: allocation.headcount,
|
||||
unfilledHeadcount: allocation.headcount,
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignment<TAllocation extends AllocationLike>(
|
||||
allocation: TAllocation,
|
||||
): Assignment<TAllocation> {
|
||||
return {
|
||||
...allocation,
|
||||
kind: "assignment",
|
||||
sourceAllocationId: allocation.entityId ?? allocation.id,
|
||||
resourceId: allocation.resourceId as string,
|
||||
isPlaceholder: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAllocationReadModel<TAllocation extends AllocationLike>(
|
||||
allocations: TAllocation[],
|
||||
): AllocationReadModel<TAllocation> {
|
||||
const demands: DemandRequirement<TAllocation>[] = [];
|
||||
const assignments: Assignment<TAllocation>[] = [];
|
||||
|
||||
for (const allocation of allocations) {
|
||||
if (allocation.isPlaceholder || allocation.resourceId === null) {
|
||||
demands.push(toDemandRequirement(allocation));
|
||||
continue;
|
||||
}
|
||||
|
||||
assignments.push(toAssignment(allocation));
|
||||
}
|
||||
|
||||
return {
|
||||
allocations,
|
||||
demands,
|
||||
assignments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
AllocationLike,
|
||||
AllocationReadModel,
|
||||
Assignment,
|
||||
DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
type SplitAllocationEntry = AllocationLike;
|
||||
type SplitProjectSummary = NonNullable<SplitAllocationEntry["project"]>;
|
||||
type SplitResourceSummary = NonNullable<SplitAllocationEntry["resource"]>;
|
||||
type SplitRoleSummary = NonNullable<SplitAllocationEntry["roleEntity"]>;
|
||||
type SplitDemandAllocationEntry = SplitAllocationEntry & {
|
||||
resourceId: null;
|
||||
isPlaceholder: true;
|
||||
};
|
||||
type SplitAssignmentAllocationEntry = SplitAllocationEntry & {
|
||||
resourceId: string;
|
||||
isPlaceholder: false;
|
||||
};
|
||||
|
||||
export interface SplitDemandRequirementRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
headcount: number;
|
||||
status: string;
|
||||
metadata: unknown;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
project?: SplitProjectSummary;
|
||||
roleEntity?: SplitRoleSummary | null;
|
||||
}
|
||||
|
||||
export interface SplitAssignmentRecord {
|
||||
id: string;
|
||||
demandRequirementId?: string | null;
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
metadata: unknown;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
resource?: SplitResourceSummary | null;
|
||||
project?: SplitProjectSummary;
|
||||
roleEntity?: SplitRoleSummary | null;
|
||||
}
|
||||
|
||||
export interface BuildSplitAllocationReadModelInput {
|
||||
demandRequirements: SplitDemandRequirementRecord[];
|
||||
assignments: SplitAssignmentRecord[];
|
||||
}
|
||||
|
||||
function compareEntries(
|
||||
left: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
|
||||
right: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
|
||||
): number {
|
||||
const startDelta =
|
||||
new Date(left.startDate).getTime() - new Date(right.startDate).getTime();
|
||||
if (startDelta !== 0) {
|
||||
return startDelta;
|
||||
}
|
||||
|
||||
const resourceDelta = (left.resourceId ?? "").localeCompare(right.resourceId ?? "");
|
||||
if (resourceDelta !== 0) {
|
||||
return resourceDelta;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function toDemandAllocationEntry(
|
||||
demandRequirement: SplitDemandRequirementRecord,
|
||||
): SplitDemandAllocationEntry {
|
||||
return {
|
||||
id: demandRequirement.id,
|
||||
entityId: demandRequirement.id,
|
||||
resourceId: null,
|
||||
projectId: demandRequirement.projectId,
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay: demandRequirement.hoursPerDay,
|
||||
percentage: demandRequirement.percentage,
|
||||
role: demandRequirement.role,
|
||||
roleId: demandRequirement.roleId,
|
||||
isPlaceholder: true,
|
||||
headcount: demandRequirement.headcount,
|
||||
dailyCostCents: 0,
|
||||
status: demandRequirement.status,
|
||||
metadata: demandRequirement.metadata,
|
||||
createdAt: demandRequirement.createdAt,
|
||||
updatedAt: demandRequirement.updatedAt,
|
||||
...(demandRequirement.project ? { project: demandRequirement.project } : {}),
|
||||
...(demandRequirement.roleEntity !== undefined
|
||||
? { roleEntity: demandRequirement.roleEntity ?? null }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignmentAllocationEntry(
|
||||
assignment: SplitAssignmentRecord,
|
||||
): SplitAssignmentAllocationEntry {
|
||||
return {
|
||||
id: assignment.id,
|
||||
entityId: assignment.id,
|
||||
resourceId: assignment.resourceId,
|
||||
projectId: assignment.projectId,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
percentage: assignment.percentage,
|
||||
role: assignment.role,
|
||||
roleId: assignment.roleId,
|
||||
isPlaceholder: false,
|
||||
headcount: 1,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
metadata: assignment.metadata,
|
||||
createdAt: assignment.createdAt,
|
||||
updatedAt: assignment.updatedAt,
|
||||
...(assignment.resource !== undefined ? { resource: assignment.resource ?? null } : {}),
|
||||
...(assignment.project ? { project: assignment.project } : {}),
|
||||
...(assignment.roleEntity !== undefined ? { roleEntity: assignment.roleEntity ?? null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toDemandReadModelEntry(
|
||||
demandRequirement: SplitDemandRequirementRecord,
|
||||
): DemandRequirement<SplitAllocationEntry> {
|
||||
const entry = toDemandAllocationEntry(demandRequirement);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
kind: "demand",
|
||||
sourceAllocationId: demandRequirement.id,
|
||||
requestedHeadcount: demandRequirement.headcount,
|
||||
unfilledHeadcount: demandRequirement.headcount,
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignmentReadModelEntry(
|
||||
assignment: SplitAssignmentRecord,
|
||||
): Assignment<SplitAllocationEntry> {
|
||||
const entry = toAssignmentAllocationEntry(assignment);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
kind: "assignment",
|
||||
sourceAllocationId: assignment.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}: BuildSplitAllocationReadModelInput): AllocationReadModel<SplitAllocationEntry> {
|
||||
return {
|
||||
allocations: [
|
||||
...demandRequirements.map(toDemandAllocationEntry),
|
||||
...assignments.map(toAssignmentAllocationEntry),
|
||||
].sort(compareEntries),
|
||||
demands: demandRequirements.map(toDemandReadModelEntry).sort(compareEntries),
|
||||
assignments: assignments.map(toAssignmentReadModelEntry).sort(compareEntries),
|
||||
};
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface CountEstimateHandoffPlanningEntriesInput {
|
||||
projectId: string;
|
||||
estimateVersionId: string;
|
||||
}
|
||||
|
||||
export async function countEstimateHandoffPlanningEntries(
|
||||
db: DbClient,
|
||||
input: CountEstimateHandoffPlanningEntriesInput,
|
||||
): Promise<number> {
|
||||
const handoffWhere = {
|
||||
projectId: input.projectId,
|
||||
metadata: {
|
||||
path: ["estimateHandoff", "estimateVersionId"],
|
||||
equals: input.estimateVersionId,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: handoffWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: handoffWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}).allocations.length;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface CountPlanningEntriesInput {
|
||||
projectIds?: string[];
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface CountPlanningEntriesResult {
|
||||
countsByProjectId: Map<string, number>;
|
||||
countsByRoleId: Map<string, number>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
function normalizeIds(ids?: string[]): string[] | undefined {
|
||||
if (!ids) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = [...new Set(ids.filter(Boolean))];
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildScopedWhere(input: CountPlanningEntriesInput) {
|
||||
const projectIds = normalizeIds(input.projectIds);
|
||||
const roleIds = normalizeIds(input.roleIds);
|
||||
|
||||
if (input.projectIds && !projectIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.roleIds && !roleIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
...(roleIds ? { roleId: { in: roleIds } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function countPlanningEntries(
|
||||
db: DbClient,
|
||||
input: CountPlanningEntriesInput = {},
|
||||
): Promise<CountPlanningEntriesResult> {
|
||||
const scopedWhere = buildScopedWhere(input);
|
||||
if (scopedWhere === null) {
|
||||
return {
|
||||
countsByProjectId: new Map(),
|
||||
countsByRoleId: new Map(),
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: scopedWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: scopedWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const readModel = buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
});
|
||||
|
||||
const countsByProjectId = new Map<string, number>();
|
||||
const countsByRoleId = new Map<string, number>();
|
||||
|
||||
for (const allocation of readModel.allocations) {
|
||||
countsByProjectId.set(
|
||||
allocation.projectId,
|
||||
(countsByProjectId.get(allocation.projectId) ?? 0) + 1,
|
||||
);
|
||||
|
||||
if (allocation.roleId) {
|
||||
countsByRoleId.set(
|
||||
allocation.roleId,
|
||||
(countsByRoleId.get(allocation.roleId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countsByProjectId,
|
||||
countsByRoleId,
|
||||
totalCount: readModel.allocations.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { calculateAllocation, validateAvailability } from "@planarchy/engine";
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import {
|
||||
type Allocation,
|
||||
type CreateAssignmentInput,
|
||||
type WeekdayAvailability,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { listAssignmentBookings } from "./list-assignment-bookings.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export const ASSIGNMENT_RELATIONS_INCLUDE = {
|
||||
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
demandRequirement: {
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AssignmentWithRelations = Prisma.AssignmentGetPayload<{
|
||||
include: typeof ASSIGNMENT_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
|
||||
async function getVacationDates(db: DbClient, resourceId: string, startDate: Date, endDate: Date) {
|
||||
const vacationDates: Date[] = [];
|
||||
|
||||
try {
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
});
|
||||
|
||||
for (const vacation of vacations) {
|
||||
const current = new Date(vacation.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const vacationEnd = new Date(vacation.endDate);
|
||||
vacationEnd.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= vacationEnd) {
|
||||
vacationDates.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Vacation persistence may not be available in all environments yet.
|
||||
}
|
||||
|
||||
return vacationDates;
|
||||
}
|
||||
|
||||
export async function createAssignment(
|
||||
db: DbClient,
|
||||
input: CreateAssignmentInput,
|
||||
): Promise<AssignmentWithRelations> {
|
||||
const project = await db.project.findUnique({ where: { id: input.projectId } });
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const resource = await db.resource.findUnique({ where: { id: input.resourceId } });
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
if (input.demandRequirementId) {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id: input.demandRequirementId },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
if (demandRequirement.projectId !== input.projectId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement belongs to a different project",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingBookings = await listAssignmentBookings(
|
||||
db as unknown as Parameters<typeof listAssignmentBookings>[0],
|
||||
{
|
||||
resourceIds: [input.resourceId],
|
||||
},
|
||||
);
|
||||
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const availabilityWindows = existingBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
})) as Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[];
|
||||
|
||||
const availabilityResult = validateAvailability(
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.hoursPerDay,
|
||||
availability,
|
||||
availabilityWindows,
|
||||
);
|
||||
|
||||
if (!availabilityResult.valid && availabilityResult.totalConflictDays > 5) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Resource has availability conflicts on ${availabilityResult.totalConflictDays} days`,
|
||||
});
|
||||
}
|
||||
|
||||
const vacationDates = await getVacationDates(
|
||||
db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const calculation = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
availability,
|
||||
vacationDates,
|
||||
});
|
||||
|
||||
const assignment = await db.assignment.create({
|
||||
data: {
|
||||
demandRequirementId: input.demandRequirementId ?? null,
|
||||
resourceId: input.resourceId,
|
||||
projectId: input.projectId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
percentage: input.percentage,
|
||||
role: input.role ?? null,
|
||||
roleId: input.roleId ?? null,
|
||||
dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents,
|
||||
status: input.status,
|
||||
metadata: input.metadata as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Assignment",
|
||||
entityId: assignment.id,
|
||||
action: "CREATE",
|
||||
changes: { after: assignment } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return assignment;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type CreateDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export const DEMAND_REQUIREMENT_RELATIONS_INCLUDE = {
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
} as const;
|
||||
|
||||
export type DemandRequirementWithRelations = Prisma.DemandRequirementGetPayload<{
|
||||
include: typeof DEMAND_REQUIREMENT_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
|
||||
export async function createDemandRequirement(
|
||||
db: DbClient,
|
||||
input: CreateDemandRequirementInput,
|
||||
): Promise<DemandRequirementWithRelations> {
|
||||
const project = await db.project.findUnique({ where: { id: input.projectId } });
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const demandRequirement = await db.demandRequirement.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
percentage: input.percentage,
|
||||
role: input.role ?? null,
|
||||
roleId: input.roleId ?? null,
|
||||
headcount: input.headcount ?? 1,
|
||||
status: input.status,
|
||||
metadata: input.metadata as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "DemandRequirement",
|
||||
entityId: demandRequirement.id,
|
||||
action: "CREATE",
|
||||
changes: { after: demandRequirement } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return demandRequirement;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { AllocationEntryResolution } from "./load-allocation-entry.js";
|
||||
import { deleteAssignment } from "./delete-assignment.js";
|
||||
import { deleteDemandRequirement } from "./delete-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface DeleteAllocationEntryResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
strategy: "explicit_demand" | "explicit_assignment";
|
||||
}
|
||||
|
||||
export async function deleteAllocationEntry(
|
||||
db: DbClient,
|
||||
resolved: AllocationEntryResolution,
|
||||
): Promise<DeleteAllocationEntryResult> {
|
||||
if (resolved.kind === "demand") {
|
||||
const result = await deleteDemandRequirement(
|
||||
db as Parameters<typeof deleteDemandRequirement>[0],
|
||||
resolved.demandRequirement.id,
|
||||
);
|
||||
|
||||
return {
|
||||
deletedId: result.deletedId,
|
||||
projectId: result.projectId,
|
||||
resourceId: null,
|
||||
strategy: "explicit_demand",
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.kind === "assignment") {
|
||||
const result = await deleteAssignment(
|
||||
db as Parameters<typeof deleteAssignment>[0],
|
||||
resolved.assignment.id,
|
||||
);
|
||||
|
||||
return {
|
||||
deletedId: result.deletedId,
|
||||
projectId: result.projectId,
|
||||
resourceId: result.resourceId,
|
||||
strategy: "explicit_assignment",
|
||||
};
|
||||
}
|
||||
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "assignment">
|
||||
| Pick<Prisma.TransactionClient, "assignment">;
|
||||
|
||||
export interface DeleteAssignmentResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export async function deleteAssignment(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DeleteAssignmentResult> {
|
||||
const assignment = await db.assignment.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
resourceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new Error("Assignment not found");
|
||||
}
|
||||
|
||||
await db.assignment.delete({
|
||||
where: { id: assignment.id },
|
||||
});
|
||||
|
||||
return {
|
||||
deletedId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface DeleteDemandRequirementResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export async function deleteDemandRequirement(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DeleteDemandRequirementResult> {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new Error("Demand requirement not found");
|
||||
}
|
||||
|
||||
await db.assignment.updateMany({
|
||||
where: { demandRequirementId: demandRequirement.id },
|
||||
data: { demandRequirementId: null },
|
||||
});
|
||||
await db.demandRequirement.delete({
|
||||
where: { id: demandRequirement.id },
|
||||
});
|
||||
|
||||
return {
|
||||
deletedId: demandRequirement.id,
|
||||
projectId: demandRequirement.projectId,
|
||||
};
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import {
|
||||
AllocationStatus,
|
||||
type FillDemandRequirementInput,
|
||||
} from "@planarchy/shared";
|
||||
import {
|
||||
createAssignment,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
import { applyDemandRequirementFillProgress } from "./apply-demand-requirement-fill-progress.js";
|
||||
|
||||
export interface DemandRequirementFillTarget {
|
||||
id: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
headcount: number;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
export interface FillDemandRequirementWithLegacySyncResult {
|
||||
assignment: AssignmentWithRelations;
|
||||
updatedDemandRequirement: Awaited<
|
||||
ReturnType<typeof applyDemandRequirementFillProgress>
|
||||
>;
|
||||
}
|
||||
|
||||
export async function fillDemandRequirementWithLegacySync(
|
||||
db: PrismaClient,
|
||||
demandRequirement: DemandRequirementFillTarget,
|
||||
input: FillDemandRequirementInput,
|
||||
): Promise<FillDemandRequirementWithLegacySyncResult> {
|
||||
const hoursPerDay = input.hoursPerDay ?? demandRequirement.hoursPerDay;
|
||||
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const createdAssignment = await createAssignment(tx, {
|
||||
demandRequirementId: demandRequirement.id,
|
||||
resourceId: input.resourceId,
|
||||
projectId: demandRequirement.projectId,
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
role: demandRequirement.role ?? undefined,
|
||||
roleId: demandRequirement.roleId ?? undefined,
|
||||
status: input.status ?? AllocationStatus.PROPOSED,
|
||||
metadata: (demandRequirement.metadata as Record<string, unknown> | null) ?? {},
|
||||
});
|
||||
|
||||
const updatedDemandRequirement = await applyDemandRequirementFillProgress(tx, {
|
||||
demandRequirementId: demandRequirement.id,
|
||||
headcount: demandRequirement.headcount,
|
||||
});
|
||||
|
||||
return {
|
||||
assignment: createdAssignment,
|
||||
updatedDemandRequirement,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { type AssignmentWithRelations } from "./create-assignment.js";
|
||||
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
|
||||
|
||||
export interface FillDemandRequirementResult {
|
||||
assignment: AssignmentWithRelations;
|
||||
updatedDemandRequirement: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
headcount: number;
|
||||
status: AllocationStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FillDemandRequirementOptions {
|
||||
}
|
||||
export async function fillDemandRequirement(
|
||||
db: PrismaClient,
|
||||
input: FillDemandRequirementInput,
|
||||
): Promise<FillDemandRequirementResult> {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id: input.demandRequirementId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
if (demandRequirement.status === AllocationStatus.CANCELLED) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement is cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
if (demandRequirement.status === AllocationStatus.COMPLETED) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement is already completed",
|
||||
});
|
||||
}
|
||||
|
||||
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import type { FillOpenDemandByAllocationInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { fillDemandRequirement } from "./fill-demand-requirement.js";
|
||||
import { loadAllocationEntry } from "./load-allocation-entry.js";
|
||||
|
||||
export interface FillOpenDemandResult {
|
||||
strategy: "demand_requirement" | "placeholder";
|
||||
createdAllocation: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
};
|
||||
updatedAllocation: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function toDemandRequirementFillResult(
|
||||
result: Awaited<ReturnType<typeof fillDemandRequirement>>,
|
||||
): FillOpenDemandResult {
|
||||
return {
|
||||
strategy: "demand_requirement",
|
||||
createdAllocation: {
|
||||
id: result.assignment.id,
|
||||
projectId: result.assignment.projectId,
|
||||
resourceId: result.assignment.resourceId,
|
||||
},
|
||||
updatedAllocation: {
|
||||
id: result.updatedDemandRequirement.id,
|
||||
projectId: result.updatedDemandRequirement.projectId,
|
||||
resourceId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fillOpenDemand(
|
||||
db: PrismaClient,
|
||||
input: FillOpenDemandByAllocationInput,
|
||||
): Promise<FillOpenDemandResult> {
|
||||
const allocation = await loadAllocationEntry(db, input.allocationId);
|
||||
|
||||
if (allocation.kind === "assignment") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Allocation is already filled",
|
||||
});
|
||||
}
|
||||
|
||||
if (allocation.kind === "demand") {
|
||||
const result = await fillDemandRequirement(db, {
|
||||
demandRequirementId: allocation.demandRequirement.id,
|
||||
resourceId: input.resourceId,
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
});
|
||||
|
||||
return toDemandRequirementFillResult(result);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Unsupported allocation entry resolution",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type AssignmentBookingsDbClient = Pick<PrismaClient, "assignment">;
|
||||
|
||||
export interface ListAssignmentBookingsInput {
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
resourceIds?: string[] | undefined;
|
||||
projectIds?: string[] | undefined;
|
||||
excludeAssignmentIds?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface AssignmentBookingWithFallback {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
status: string;
|
||||
orderType: string;
|
||||
};
|
||||
resource: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function listAssignmentBookings(
|
||||
db: AssignmentBookingsDbClient,
|
||||
input: ListAssignmentBookingsInput,
|
||||
): Promise<AssignmentBookingWithFallback[]> {
|
||||
const hasDateBounds = input.startDate !== undefined && input.endDate !== undefined;
|
||||
if (!hasDateBounds && (input.startDate !== undefined || input.endDate !== undefined)) {
|
||||
throw new Error("startDate and endDate must be provided together");
|
||||
}
|
||||
|
||||
const excludeAssignmentIds = input.excludeAssignmentIds?.filter(Boolean) ?? [];
|
||||
|
||||
const assignmentWhere = {
|
||||
status: { not: "CANCELLED" as const },
|
||||
...(hasDateBounds
|
||||
? {
|
||||
startDate: { lte: input.endDate! },
|
||||
endDate: { gte: input.startDate! },
|
||||
}
|
||||
: {}),
|
||||
...(input.resourceIds?.length ? { resourceId: { in: input.resourceIds } } : {}),
|
||||
...(input.projectIds?.length ? { projectId: { in: input.projectIds } } : {}),
|
||||
...(excludeAssignmentIds.length ? { id: { notIn: excludeAssignmentIds } } : {}),
|
||||
} satisfies Prisma.AssignmentWhereInput;
|
||||
|
||||
const assignmentSelect = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
project: {
|
||||
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
|
||||
},
|
||||
resource: {
|
||||
select: { id: true, displayName: true, chapter: true },
|
||||
},
|
||||
} satisfies Prisma.AssignmentSelect;
|
||||
|
||||
const assignments = await db.assignment.findMany({
|
||||
where: assignmentWhere,
|
||||
select: assignmentSelect,
|
||||
});
|
||||
|
||||
return assignments.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
project: assignment.project,
|
||||
resource: assignment.resource,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import type { AllocationWithDetails } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
import {
|
||||
ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
import {
|
||||
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
type DemandRequirementWithRelations,
|
||||
} from "./create-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export type AllocationEntryResolution =
|
||||
| {
|
||||
kind: "demand";
|
||||
entry: AllocationWithDetails;
|
||||
demandRequirement: DemandRequirementWithRelations;
|
||||
projectId: string;
|
||||
resourceId: null;
|
||||
}
|
||||
| {
|
||||
kind: "assignment";
|
||||
entry: AllocationWithDetails;
|
||||
assignment: AssignmentWithRelations;
|
||||
projectId: string;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
function toDemandAllocationEntry(
|
||||
demandRequirement: DemandRequirementWithRelations,
|
||||
): AllocationWithDetails {
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements: [demandRequirement],
|
||||
assignments: [],
|
||||
}).allocations[0] as AllocationWithDetails;
|
||||
}
|
||||
|
||||
function toAssignmentAllocationEntry(
|
||||
assignment: AssignmentWithRelations,
|
||||
): AllocationWithDetails {
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements: [],
|
||||
assignments: [assignment],
|
||||
}).allocations[0] as AllocationWithDetails;
|
||||
}
|
||||
|
||||
async function loadDemandRequirementById(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DemandRequirementWithRelations | null> {
|
||||
return db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssignmentById(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AssignmentWithRelations | null> {
|
||||
return db.assignment.findUnique({
|
||||
where: { id },
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an id to a demand requirement or assignment.
|
||||
*/
|
||||
export async function findAllocationEntry(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AllocationEntryResolution | null> {
|
||||
const [demandRequirement, assignment] = await Promise.all([
|
||||
loadDemandRequirementById(db, id),
|
||||
loadAssignmentById(db, id),
|
||||
]);
|
||||
|
||||
if (demandRequirement) {
|
||||
return {
|
||||
kind: "demand",
|
||||
entry: toDemandAllocationEntry(demandRequirement),
|
||||
demandRequirement,
|
||||
projectId: demandRequirement.projectId,
|
||||
resourceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (assignment) {
|
||||
return {
|
||||
kind: "assignment",
|
||||
entry: toAssignmentAllocationEntry(assignment),
|
||||
assignment,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadAllocationEntry(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AllocationEntryResolution> {
|
||||
const resolved = await findAllocationEntry(db, id);
|
||||
if (!resolved) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import type {
|
||||
AllocationWithDetails,
|
||||
UpdateAssignmentInput,
|
||||
UpdateDemandRequirementInput,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { findAllocationEntry } from "./load-allocation-entry.js";
|
||||
import { updateAssignment } from "./update-assignment.js";
|
||||
import { updateDemandRequirement } from "./update-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface UpdateAllocationEntryInput {
|
||||
id: string;
|
||||
demandRequirementUpdate: UpdateDemandRequirementInput;
|
||||
assignmentUpdate: UpdateAssignmentInput;
|
||||
}
|
||||
|
||||
export interface UpdateAllocationEntryResult {
|
||||
allocation: AllocationWithDetails;
|
||||
strategy: "explicit_demand" | "explicit_assignment";
|
||||
}
|
||||
|
||||
export async function updateAllocationEntry(
|
||||
db: DbClient,
|
||||
input: UpdateAllocationEntryInput,
|
||||
): Promise<UpdateAllocationEntryResult> {
|
||||
const resolved = await findAllocationEntry(db, input.id);
|
||||
|
||||
if (!resolved) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
|
||||
if (resolved.kind === "demand") {
|
||||
const updatedDemandRequirement = await updateDemandRequirement(
|
||||
db as Parameters<typeof updateDemandRequirement>[0],
|
||||
resolved.demandRequirement.id,
|
||||
input.demandRequirementUpdate,
|
||||
);
|
||||
|
||||
return {
|
||||
allocation: {
|
||||
...resolved.entry,
|
||||
...updatedDemandRequirement,
|
||||
id: resolved.entry.id,
|
||||
resourceId: null,
|
||||
isPlaceholder: true,
|
||||
headcount: updatedDemandRequirement.headcount,
|
||||
dailyCostCents: 0,
|
||||
project: updatedDemandRequirement.project,
|
||||
roleEntity: updatedDemandRequirement.roleEntity ?? null,
|
||||
updatedAt: updatedDemandRequirement.updatedAt,
|
||||
} as AllocationWithDetails,
|
||||
strategy: "explicit_demand",
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAssignment = await updateAssignment(
|
||||
db as Parameters<typeof updateAssignment>[0],
|
||||
resolved.assignment.id,
|
||||
input.assignmentUpdate,
|
||||
);
|
||||
|
||||
return {
|
||||
allocation: {
|
||||
...resolved.entry,
|
||||
...updatedAssignment,
|
||||
id: resolved.entry.id,
|
||||
resourceId: updatedAssignment.resourceId,
|
||||
isPlaceholder: false,
|
||||
headcount: 1,
|
||||
project: updatedAssignment.project,
|
||||
resource: updatedAssignment.resource ?? null,
|
||||
roleEntity: updatedAssignment.roleEntity ?? null,
|
||||
updatedAt: updatedAssignment.updatedAt,
|
||||
} as AllocationWithDetails,
|
||||
strategy: "explicit_assignment",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type UpdateAssignmentInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export async function updateAssignment(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
input: UpdateAssignmentInput,
|
||||
): Promise<AssignmentWithRelations> {
|
||||
const existing = await db.assignment.findUnique({
|
||||
where: { id },
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
|
||||
}
|
||||
|
||||
const updatedAssignment = await db.assignment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.demandRequirementId !== undefined
|
||||
? { demandRequirementId: input.demandRequirementId }
|
||||
: {}),
|
||||
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
|
||||
...(input.role !== undefined ? { role: input.role } : {}),
|
||||
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
|
||||
...(input.dailyCostCents !== undefined ? { dailyCostCents: input.dailyCostCents } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Assignment",
|
||||
entityId: id,
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
before: {
|
||||
resourceId: existing.resourceId,
|
||||
projectId: existing.projectId,
|
||||
demandRequirementId: existing.demandRequirementId,
|
||||
startDate: existing.startDate,
|
||||
endDate: existing.endDate,
|
||||
hoursPerDay: existing.hoursPerDay,
|
||||
percentage: existing.percentage,
|
||||
role: existing.role,
|
||||
roleId: existing.roleId,
|
||||
dailyCostCents: existing.dailyCostCents,
|
||||
status: existing.status,
|
||||
},
|
||||
after: {
|
||||
resourceId: updatedAssignment.resourceId,
|
||||
projectId: updatedAssignment.projectId,
|
||||
demandRequirementId: updatedAssignment.demandRequirementId,
|
||||
startDate: updatedAssignment.startDate,
|
||||
endDate: updatedAssignment.endDate,
|
||||
hoursPerDay: updatedAssignment.hoursPerDay,
|
||||
percentage: updatedAssignment.percentage,
|
||||
role: updatedAssignment.role,
|
||||
roleId: updatedAssignment.roleId,
|
||||
dailyCostCents: updatedAssignment.dailyCostCents,
|
||||
status: updatedAssignment.status,
|
||||
},
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedAssignment;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type UpdateDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
type DemandRequirementWithRelations,
|
||||
} from "./create-demand-requirement.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export async function updateDemandRequirement(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
input: UpdateDemandRequirementInput,
|
||||
): Promise<DemandRequirementWithRelations> {
|
||||
const existing = await db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
const updatedDemandRequirement = await db.demandRequirement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
|
||||
...(input.role !== undefined ? { role: input.role } : {}),
|
||||
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
|
||||
...(input.headcount !== undefined ? { headcount: input.headcount } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "DemandRequirement",
|
||||
entityId: id,
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
before: {
|
||||
projectId: existing.projectId,
|
||||
startDate: existing.startDate,
|
||||
endDate: existing.endDate,
|
||||
hoursPerDay: existing.hoursPerDay,
|
||||
percentage: existing.percentage,
|
||||
role: existing.role,
|
||||
roleId: existing.roleId,
|
||||
headcount: existing.headcount,
|
||||
status: existing.status,
|
||||
},
|
||||
after: {
|
||||
projectId: updatedDemandRequirement.projectId,
|
||||
startDate: updatedDemandRequirement.startDate,
|
||||
endDate: updatedDemandRequirement.endDate,
|
||||
hoursPerDay: updatedDemandRequirement.hoursPerDay,
|
||||
percentage: updatedDemandRequirement.percentage,
|
||||
role: updatedDemandRequirement.role,
|
||||
roleId: updatedDemandRequirement.roleId,
|
||||
headcount: updatedDemandRequirement.headcount,
|
||||
status: updatedDemandRequirement.status,
|
||||
},
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedDemandRequirement;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { computeChargeability } from "@planarchy/engine";
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
|
||||
export interface GetDashboardChargeabilityOverviewInput {
|
||||
topN: number;
|
||||
watchlistThreshold: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export async function getDashboardChargeabilityOverview(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardChargeabilityOverviewInput,
|
||||
) {
|
||||
const now = input.now ?? new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
|
||||
const stats = resources.map((resource) => {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const actualAllocations = resourceBookings.filter(
|
||||
(booking) =>
|
||||
(booking.status === "CONFIRMED" || booking.status === "ACTIVE") &&
|
||||
booking.project.status !== "DRAFT" &&
|
||||
booking.project.status !== "CANCELLED",
|
||||
);
|
||||
const actual = computeChargeability(
|
||||
availability,
|
||||
actualAllocations,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const expected = computeChargeability(
|
||||
availability,
|
||||
resourceBookings,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
top: [...stats]
|
||||
.sort((left, right) => right.actualChargeability - left.actualChargeability)
|
||||
.slice(0, input.topN),
|
||||
watchlist: [...stats]
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.actualChargeability <
|
||||
resource.chargeabilityTarget - input.watchlistThreshold,
|
||||
)
|
||||
.sort((left, right) => left.actualChargeability - right.actualChargeability)
|
||||
.slice(0, input.topN),
|
||||
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
|
||||
export interface GetDashboardDemandInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
groupBy: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
staffingReqs: unknown;
|
||||
}
|
||||
|
||||
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
|
||||
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
|
||||
return normalizedPercentage / 100;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
|
||||
return requirements.reduce((sum, requirement) => {
|
||||
if (
|
||||
typeof requirement === "object" &&
|
||||
requirement !== null &&
|
||||
"fteCount" in requirement &&
|
||||
typeof requirement.fteCount === "number"
|
||||
) {
|
||||
return sum + requirement.fteCount;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export async function getDashboardDemand(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardDemandInput,
|
||||
) {
|
||||
const { demandRequirements, assignments, projects, readModel } =
|
||||
await loadDashboardPlanningReadModel(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
|
||||
const demandRequirementById = new Map(
|
||||
demandRequirements.map((demandRequirement) => [
|
||||
demandRequirement.id,
|
||||
demandRequirement,
|
||||
]),
|
||||
);
|
||||
const normalizedAssignments = readModel.assignments;
|
||||
const normalizedDemands = readModel.demands;
|
||||
|
||||
const projectMap = new Map<string, ProjectSummary>(
|
||||
projects.map((project) => [project.id, project]),
|
||||
);
|
||||
for (const allocation of readModel.allocations) {
|
||||
if (!allocation.project || projectMap.has(allocation.project.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projectMap.set(allocation.project.id, {
|
||||
id: allocation.project.id,
|
||||
name: allocation.project.name,
|
||||
shortCode: allocation.project.shortCode,
|
||||
staffingReqs: allocation.project.staffingReqs,
|
||||
});
|
||||
}
|
||||
|
||||
const assignmentCountByDemandRequirementId = new Map<string, number>();
|
||||
for (const assignment of assignments) {
|
||||
if (!assignment.demandRequirementId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assignmentCountByDemandRequirementId.set(
|
||||
assignment.demandRequirementId,
|
||||
(assignmentCountByDemandRequirementId.get(assignment.demandRequirementId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.groupBy === "project") {
|
||||
const projectIds = new Set<string>([
|
||||
...projectMap.keys(),
|
||||
...normalizedAssignments.map((assignment) => assignment.projectId),
|
||||
...normalizedDemands.map((demand) => demand.projectId),
|
||||
]);
|
||||
|
||||
return [...projectIds].map((projectId) => {
|
||||
const project = projectMap.get(projectId) ?? {
|
||||
id: projectId,
|
||||
name: projectId,
|
||||
shortCode: projectId,
|
||||
staffingReqs: [],
|
||||
};
|
||||
|
||||
const projectAssignments = normalizedAssignments.filter(
|
||||
(assignment) => assignment.projectId === projectId,
|
||||
);
|
||||
const projectDemands = normalizedDemands.filter(
|
||||
(demand) => demand.projectId === projectId,
|
||||
);
|
||||
|
||||
const allocatedHours = projectAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum +
|
||||
calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const requiredFTEs =
|
||||
projectDemands.length > 0
|
||||
? projectDemands.reduce((sum, demand) => {
|
||||
const demandFteFactor = getDemandFteFactor(
|
||||
demand.hoursPerDay,
|
||||
demand.percentage,
|
||||
);
|
||||
const explicitDemand = demandRequirementById.get(demand.id);
|
||||
if (!explicitDemand) {
|
||||
return sum + demand.requestedHeadcount * demandFteFactor;
|
||||
}
|
||||
|
||||
const linkedAssignmentCount =
|
||||
assignmentCountByDemandRequirementId.get(explicitDemand.id) ?? 0;
|
||||
const plannedHeadcount =
|
||||
linkedAssignmentCount +
|
||||
(explicitDemand.status === "COMPLETED" ? 0 : explicitDemand.headcount);
|
||||
return sum + plannedHeadcount * demandFteFactor;
|
||||
}, 0)
|
||||
: getProjectRequiredFTEs(project.staffingReqs);
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
shortCode: project.shortCode,
|
||||
allocatedHours: Math.round(allocatedHours),
|
||||
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
||||
resourceCount: new Set(
|
||||
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
|
||||
).size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (input.groupBy === "chapter") {
|
||||
const chapterMap = new Map<
|
||||
string,
|
||||
{ allocatedHours: number; resourceIds: Set<string> }
|
||||
>();
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
const chapter = assignment.resource?.chapter ?? "Unassigned";
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
};
|
||||
|
||||
if (assignment.resource?.id) {
|
||||
existing.resourceIds.add(assignment.resource.id);
|
||||
}
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
|
||||
chapterMap.set(chapter, existing);
|
||||
}
|
||||
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
}));
|
||||
}
|
||||
|
||||
const personMap = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
chapter: string | null;
|
||||
allocatedHours: number;
|
||||
projectIds: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
if (!assignment.resource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = personMap.get(assignment.resource.id) ?? {
|
||||
name: assignment.resource.displayName,
|
||||
chapter: assignment.resource.chapter ?? null,
|
||||
allocatedHours: 0,
|
||||
projectIds: new Set<string>(),
|
||||
};
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.projectIds.add(assignment.projectId);
|
||||
|
||||
personMap.set(assignment.resource.id, existing);
|
||||
}
|
||||
|
||||
return [...personMap.entries()].map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
|
||||
export async function getDashboardOverview(db: PrismaClient) {
|
||||
const [
|
||||
totalResources,
|
||||
activeResources,
|
||||
totalProjects,
|
||||
allProjects,
|
||||
allDemandRequirements,
|
||||
allAssignments,
|
||||
budgetBookings,
|
||||
recentActivity,
|
||||
allResources,
|
||||
] = await Promise.all([
|
||||
db.resource.count(),
|
||||
db.resource.count({ where: { isActive: true } }),
|
||||
db.project.count(),
|
||||
db.project.findMany({ select: { status: true, budgetCents: true } }),
|
||||
db.demandRequirement.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
listAssignmentBookings(db, {}),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, entityType: true, action: true, createdAt: true },
|
||||
}),
|
||||
db.resource.findMany({
|
||||
select: { chapter: true, chargeabilityTarget: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const planningReadModel = buildSplitAllocationReadModel({
|
||||
demandRequirements: allDemandRequirements,
|
||||
assignments: allAssignments,
|
||||
});
|
||||
const totalAllocations = planningReadModel.allocations.length;
|
||||
const activeAllocations = planningReadModel.allocations.filter(
|
||||
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
|
||||
).length;
|
||||
|
||||
const totalCostCents = budgetBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum +
|
||||
(booking.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(booking.startDate, booking.endDate),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalBudgetCents = allProjects.reduce(
|
||||
(sum, project) => sum + (project.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const avgUtilizationPercent =
|
||||
totalBudgetCents > 0
|
||||
? Math.round((totalCostCents / totalBudgetCents) * 100)
|
||||
: 0;
|
||||
|
||||
const statusCountMap = new Map<string, number>();
|
||||
for (const project of allProjects) {
|
||||
statusCountMap.set(
|
||||
project.status,
|
||||
(statusCountMap.get(project.status) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const chapterMap = new Map<
|
||||
string,
|
||||
{ resourceCount: number; chargeabilitySum: number }
|
||||
>();
|
||||
for (const resource of allResources) {
|
||||
const chapter = resource.chapter ?? "Unassigned";
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
resourceCount: 0,
|
||||
chargeabilitySum: 0,
|
||||
};
|
||||
|
||||
chapterMap.set(chapter, {
|
||||
resourceCount: existing.resourceCount + 1,
|
||||
chargeabilitySum:
|
||||
existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalResources,
|
||||
activeResources,
|
||||
totalProjects,
|
||||
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
|
||||
.length,
|
||||
totalAllocations,
|
||||
activeAllocations,
|
||||
budgetSummary: {
|
||||
totalBudgetCents,
|
||||
totalCostCents,
|
||||
avgUtilizationPercent,
|
||||
},
|
||||
recentActivity: recentActivity.map((activity) => ({
|
||||
id: activity.id,
|
||||
entityType: activity.entityType,
|
||||
action: activity.action,
|
||||
createdAt: activity.createdAt,
|
||||
})),
|
||||
projectsByStatus: [...statusCountMap.entries()].map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
})),
|
||||
chapterUtilization: [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
chapter,
|
||||
resourceCount: data.resourceCount,
|
||||
avgChargeabilityTarget:
|
||||
data.resourceCount > 0
|
||||
? Math.round(data.chargeabilitySum / data.resourceCount)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
|
||||
export interface GetDashboardPeakTimesInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
granularity: "week" | "month";
|
||||
groupBy: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export async function getDashboardPeakTimes(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardPeakTimesInput,
|
||||
) {
|
||||
const allocations = await listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
|
||||
const buckets = new Map<string, Map<string, number>>();
|
||||
|
||||
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
|
||||
|
||||
for (const allocation of allocations) {
|
||||
const allocStart = new Date(
|
||||
Math.max(allocation.startDate.getTime(), input.startDate.getTime()),
|
||||
);
|
||||
const allocEnd = new Date(
|
||||
Math.min(allocation.endDate.getTime(), input.endDate.getTime()),
|
||||
);
|
||||
const group =
|
||||
input.groupBy === "project"
|
||||
? allocation.project.shortCode
|
||||
: input.groupBy === "chapter"
|
||||
? allocation.resource?.chapter ?? "Unassigned"
|
||||
: allocation.resource?.displayName ?? "Unknown";
|
||||
|
||||
const cursor = new Date(allocStart);
|
||||
while (cursor <= allocEnd) {
|
||||
const bucketKey = getBucketKey(cursor);
|
||||
if (!buckets.has(bucketKey)) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
}
|
||||
|
||||
const bucket = buckets.get(bucketKey)!;
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + allocation.hoursPerDay);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { availability: true },
|
||||
});
|
||||
|
||||
const dailyCapacityHours = resources.reduce(
|
||||
(sum, resource) =>
|
||||
sum +
|
||||
getAverageDailyAvailabilityHours(
|
||||
resource.availability as Record<string, number | null | undefined>,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return [...buckets.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([period, groups]) => ({
|
||||
period,
|
||||
groups: [...groups.entries()].map(([name, hours]) => ({ name, hours })),
|
||||
totalHours: [...groups.values()].reduce((sum, hours) => sum + hours, 0),
|
||||
capacityHours:
|
||||
dailyCapacityHours * (input.granularity === "week" ? 5 : 22),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
|
||||
export interface GetDashboardTopValueResourcesInput {
|
||||
limit: number;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
export async function getDashboardTopValueResources(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardTopValueResourcesInput,
|
||||
) {
|
||||
const settings = await db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
const visibleRoles =
|
||||
(settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
|
||||
|
||||
if (!visibleRoles.includes(input.userRole)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return db.resource.findMany({
|
||||
where: { isActive: true, valueScore: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
valueScore: true,
|
||||
lcrCents: true,
|
||||
},
|
||||
orderBy: { valueScore: "desc" },
|
||||
take: input.limit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
getDashboardOverview,
|
||||
} from "./get-overview.js";
|
||||
|
||||
export {
|
||||
getDashboardPeakTimes,
|
||||
type GetDashboardPeakTimesInput,
|
||||
} from "./get-peak-times.js";
|
||||
|
||||
export {
|
||||
getDashboardTopValueResources,
|
||||
type GetDashboardTopValueResourcesInput,
|
||||
} from "./get-top-value-resources.js";
|
||||
|
||||
export {
|
||||
getDashboardDemand,
|
||||
type GetDashboardDemandInput,
|
||||
} from "./get-demand.js";
|
||||
|
||||
export {
|
||||
getDashboardChargeabilityOverview,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
} from "./get-chargeability-overview.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
|
||||
export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
staffingReqs: true,
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DASHBOARD_PLANNING_DEMAND_INCLUDE = {
|
||||
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
|
||||
} as const;
|
||||
|
||||
export const DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE = {
|
||||
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
|
||||
resource: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.resource,
|
||||
} as const;
|
||||
|
||||
type DashboardPlanningReadDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment" | "project"
|
||||
>;
|
||||
|
||||
export interface LoadDashboardPlanningReadModelInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export async function loadDashboardPlanningReadModel(
|
||||
db: DashboardPlanningReadDbClient,
|
||||
input: LoadDashboardPlanningReadModelInput,
|
||||
) {
|
||||
const activeWindowFilter = {
|
||||
status: { not: AllocationStatus.CANCELLED },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
} as const;
|
||||
|
||||
const [demandRequirements, assignments, projects] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: activeWindowFilter,
|
||||
include: DASHBOARD_PLANNING_DEMAND_INCLUDE,
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: activeWindowFilter,
|
||||
include: DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE,
|
||||
}),
|
||||
db.project.findMany({
|
||||
where: activeWindowFilter,
|
||||
select: { id: true, shortCode: true, name: true, staffingReqs: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
demandRequirements,
|
||||
assignments,
|
||||
projects,
|
||||
readModel: buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
export function calculateInclusiveDays(startDate: Date, endDate: Date): number {
|
||||
return (endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY + 1;
|
||||
}
|
||||
|
||||
export function calculateAllocationHours(input: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
}): number {
|
||||
return input.hoursPerDay * calculateInclusiveDays(input.startDate, input.endDate);
|
||||
}
|
||||
|
||||
export function getMonthBucketKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function getWeekBucketKey(date: Date): string {
|
||||
const weekStart = new Date(date);
|
||||
const day = weekStart.getDay();
|
||||
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
|
||||
weekStart.setDate(diff);
|
||||
return weekStart.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function getAverageDailyAvailabilityHours(
|
||||
availability: Record<string, number | null | undefined> | null | undefined,
|
||||
): number {
|
||||
if (!availability) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalWeeklyHours = Object.values(availability).reduce(
|
||||
(sum: number, hours) => sum + (hours ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return totalWeeklyHours / 5;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { ImportBatchStatus, type Prisma } from "@planarchy/db";
|
||||
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
|
||||
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
|
||||
import { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
|
||||
import { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
|
||||
import { readWorksheetMatrix } from "./read-workbook.js";
|
||||
import {
|
||||
DISPO_REFERENCE_SHEET,
|
||||
type DispoImportDbClient,
|
||||
isPseudoDemandResourceIdentity,
|
||||
normalizeText,
|
||||
toJsonObject,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface AssessDispoImportReadinessInput {
|
||||
chargeabilityWorkbookPath: string;
|
||||
costWorkbookPath?: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningWorkbookPath: string;
|
||||
referenceWorkbookPath: string;
|
||||
rosterWorkbookPath?: string;
|
||||
}
|
||||
|
||||
export interface DispoImportReadinessIssue {
|
||||
code:
|
||||
| "FALLBACK_EMAIL_REQUIRED"
|
||||
| "FALLBACK_LCR_REQUIRED"
|
||||
| "FALLBACK_UCR_REQUIRED"
|
||||
| "PLANNING_RESOURCE_MISSING_FROM_ROSTER"
|
||||
| "REFERENCE_RESOURCE_MASTER_MISSING"
|
||||
| "UNRESOLVED_RECORDS_PRESENT";
|
||||
count: number;
|
||||
message: string;
|
||||
resolution: string;
|
||||
severity: "blocker" | "warning";
|
||||
}
|
||||
|
||||
export interface DispoImportReadinessReport {
|
||||
assignmentCount: number;
|
||||
availabilityRuleCount: number;
|
||||
canCommitWithFallbacks: boolean;
|
||||
canCommitWithStrictSourceData: boolean;
|
||||
fallbackAssumptions: string[];
|
||||
issues: DispoImportReadinessIssue[];
|
||||
projectCount: number;
|
||||
resourceCount: number;
|
||||
unresolvedCount: number;
|
||||
vacationCount: number;
|
||||
}
|
||||
|
||||
interface MergedResourceReadinessRecord {
|
||||
canonicalExternalId: string;
|
||||
email: string | null;
|
||||
lcrCents: number | null;
|
||||
ucrCents: number | null;
|
||||
}
|
||||
|
||||
function filterUnresolvedCount(
|
||||
unresolved: ReadonlyArray<{ resourceExternalId?: string | null }>,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
) {
|
||||
return unresolved.filter(
|
||||
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
|
||||
).length;
|
||||
}
|
||||
|
||||
function buildReadinessIssue(issue: DispoImportReadinessIssue): DispoImportReadinessIssue {
|
||||
return issue;
|
||||
}
|
||||
|
||||
function derivePlanningResourceIds(input: {
|
||||
assignments: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["assignments"];
|
||||
availabilityRules: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["availabilityRules"];
|
||||
vacations: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["vacations"];
|
||||
}) {
|
||||
const resourceIds = new Set<string>();
|
||||
|
||||
for (const assignment of input.assignments) {
|
||||
if (isPseudoDemandResourceIdentity(assignment.resourceExternalId)) {
|
||||
continue;
|
||||
}
|
||||
resourceIds.add(assignment.resourceExternalId);
|
||||
}
|
||||
for (const vacation of input.vacations) {
|
||||
if (isPseudoDemandResourceIdentity(vacation.resourceExternalId)) {
|
||||
continue;
|
||||
}
|
||||
resourceIds.add(vacation.resourceExternalId);
|
||||
}
|
||||
for (const rule of input.availabilityRules) {
|
||||
if (isPseudoDemandResourceIdentity(rule.resourceExternalId)) {
|
||||
continue;
|
||||
}
|
||||
resourceIds.add(rule.resourceExternalId);
|
||||
}
|
||||
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
async function hasResourceMasterRows(referenceWorkbookPath: string) {
|
||||
const rows = await readWorksheetMatrix(referenceWorkbookPath, DISPO_REFERENCE_SHEET);
|
||||
|
||||
for (let index = 1; index < rows.length; index += 1) {
|
||||
const firstCell = normalizeText(rows[index]?.[0]);
|
||||
if (!firstCell) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[a-z0-9]+(?:\.[a-z0-9]+)+$/i.test(firstCell)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function assessDispoImportReadiness(
|
||||
input: AssessDispoImportReadinessInput,
|
||||
): Promise<DispoImportReadinessReport> {
|
||||
const [
|
||||
referenceWorkbook,
|
||||
chargeabilityWorkbook,
|
||||
planningWorkbook,
|
||||
resourceMasterPresent,
|
||||
rosterWorkbook,
|
||||
] =
|
||||
await Promise.all([
|
||||
parseMandatoryDispoReferenceWorkbook(input.referenceWorkbookPath),
|
||||
parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath),
|
||||
parseDispoPlanningWorkbook(input.planningWorkbookPath),
|
||||
hasResourceMasterRows(input.referenceWorkbookPath),
|
||||
input.rosterWorkbookPath
|
||||
? parseDispoRosterWorkbook(input.rosterWorkbookPath, {
|
||||
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const mergedResources = new Map<string, MergedResourceReadinessRecord>();
|
||||
const excludedIds = new Set(rosterWorkbook?.excludedCanonicalExternalIds ?? []);
|
||||
for (const resource of chargeabilityWorkbook.resources) {
|
||||
if (excludedIds.has(resource.canonicalExternalId)) {
|
||||
continue;
|
||||
}
|
||||
mergedResources.set(resource.canonicalExternalId, {
|
||||
canonicalExternalId: resource.canonicalExternalId,
|
||||
email: resource.email,
|
||||
lcrCents: null,
|
||||
ucrCents: null,
|
||||
});
|
||||
}
|
||||
for (const resource of rosterWorkbook?.resources ?? []) {
|
||||
if (excludedIds.has(resource.canonicalExternalId)) {
|
||||
continue;
|
||||
}
|
||||
const existing = mergedResources.get(resource.canonicalExternalId);
|
||||
mergedResources.set(resource.canonicalExternalId, {
|
||||
canonicalExternalId: resource.canonicalExternalId,
|
||||
email: resource.email ?? existing?.email ?? null,
|
||||
lcrCents: resource.lcrCents ?? existing?.lcrCents ?? null,
|
||||
ucrCents: resource.ucrCents ?? existing?.ucrCents ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const rosterIds = new Set(mergedResources.keys());
|
||||
const planningResourceIds = derivePlanningResourceIds(planningWorkbook);
|
||||
const planningResourceMissingFromRoster = Array.from(planningResourceIds).filter(
|
||||
(resourceId) => !excludedIds.has(resourceId) && !rosterIds.has(resourceId),
|
||||
);
|
||||
const unresolvedCount =
|
||||
filterUnresolvedCount(chargeabilityWorkbook.unresolved, excludedIds) +
|
||||
filterUnresolvedCount(planningWorkbook.unresolved, excludedIds) +
|
||||
filterUnresolvedCount(rosterWorkbook?.unresolved ?? [], excludedIds);
|
||||
const missingEmailCount = Array.from(mergedResources.values()).filter(
|
||||
(resource) => !resource.email,
|
||||
).length;
|
||||
const missingLcrCount = Array.from(mergedResources.values()).filter(
|
||||
(resource) => resource.lcrCents === null,
|
||||
).length;
|
||||
const missingUcrCount = Array.from(mergedResources.values()).filter(
|
||||
(resource) => resource.ucrCents === null,
|
||||
).length;
|
||||
|
||||
const issues: DispoImportReadinessIssue[] = [];
|
||||
|
||||
if (!resourceMasterPresent && !rosterWorkbook) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "REFERENCE_RESOURCE_MASTER_MISSING",
|
||||
count: 1,
|
||||
message:
|
||||
"MandatoryDispoCategories_V3.xlsx contains reference/glossary sections only; it does not contain row-based resource master records.",
|
||||
resolution:
|
||||
"Provide a real resource master source or explicitly approve generated fallback values for missing required Resource fields.",
|
||||
severity: "blocker",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (missingEmailCount > 0) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "FALLBACK_EMAIL_REQUIRED",
|
||||
count: missingEmailCount,
|
||||
message:
|
||||
"Some imported resources still do not have real email addresses after merging the Dispo roster and SAP source data.",
|
||||
resolution:
|
||||
"Approve generated placeholder emails for the remaining resources or provide a missing roster/SAP source with email addresses.",
|
||||
severity: "blocker",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (missingLcrCount > 0) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "FALLBACK_LCR_REQUIRED",
|
||||
count: missingLcrCount,
|
||||
message:
|
||||
"Some staged resources still do not have resolved LCR values after merging roster data with the cost-rate workbook.",
|
||||
resolution:
|
||||
"Provide missing per-resource LCR values or approve placeholders only for the remaining unresolved resources.",
|
||||
severity: "blocker",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (missingUcrCount > 0) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "FALLBACK_UCR_REQUIRED",
|
||||
count: missingUcrCount,
|
||||
message:
|
||||
"Some staged resources still do not have resolved UCR values after merging roster data with the cost-rate workbook.",
|
||||
resolution:
|
||||
"Provide missing per-resource UCR values or approve placeholders only for the remaining unresolved resources.",
|
||||
severity: "blocker",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (planningResourceMissingFromRoster.length > 0) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "PLANNING_RESOURCE_MISSING_FROM_ROSTER",
|
||||
count: planningResourceMissingFromRoster.length,
|
||||
message:
|
||||
"Some resource identities appear in planning data but not in the merged roster/resource-master inputs.",
|
||||
resolution:
|
||||
"Add the missing resources to the roster or chargeability inputs, or decide how planning-only identities should be imported.",
|
||||
severity: "blocker",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (unresolvedCount > 0) {
|
||||
issues.push(
|
||||
buildReadinessIssue({
|
||||
code: "UNRESOLVED_RECORDS_PRESENT",
|
||||
count: unresolvedCount,
|
||||
message:
|
||||
"The staging inputs contain unresolved rows, primarily [tbd] project references that must stay out of final project commit.",
|
||||
resolution:
|
||||
"Review unresolved rows before final commit or keep automatic commit scoped to resolved rows only.",
|
||||
severity: "warning",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const strictBlockers = issues.filter((issue) => issue.severity === "blocker");
|
||||
const fallbackOnlyBlockers = new Set<DispoImportReadinessIssue["code"]>([
|
||||
"FALLBACK_EMAIL_REQUIRED",
|
||||
"FALLBACK_LCR_REQUIRED",
|
||||
"FALLBACK_UCR_REQUIRED",
|
||||
"REFERENCE_RESOURCE_MASTER_MISSING",
|
||||
]);
|
||||
|
||||
const canCommitWithStrictSourceData = strictBlockers.length === 0;
|
||||
const canCommitWithFallbacks = strictBlockers.every((issue) =>
|
||||
fallbackOnlyBlockers.has(issue.code),
|
||||
);
|
||||
|
||||
return {
|
||||
resourceCount: mergedResources.size,
|
||||
projectCount: planningWorkbook.assignments.filter(
|
||||
(assignment) =>
|
||||
assignment.projectKey !== null && !assignment.isTbd && !assignment.isUnassigned,
|
||||
).length,
|
||||
assignmentCount: planningWorkbook.assignments.length,
|
||||
vacationCount: planningWorkbook.vacations.length,
|
||||
availabilityRuleCount: planningWorkbook.availabilityRules.length,
|
||||
unresolvedCount,
|
||||
canCommitWithStrictSourceData,
|
||||
canCommitWithFallbacks,
|
||||
fallbackAssumptions: canCommitWithFallbacks
|
||||
? [
|
||||
"Generate fallback email as <enterpriseId>@accenture.com for imported resources that do not have one in the source files.",
|
||||
"Commit placeholder LCR/UCR values only for resources still unresolved after roster-to-rate matching and level-average fallback.",
|
||||
"Keep unresolved [tbd] rows staged and exclude them from final project creation.",
|
||||
]
|
||||
: [],
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistDispoImportReadiness(
|
||||
db: DispoImportDbClient,
|
||||
input: AssessDispoImportReadinessInput & { importBatchId: string },
|
||||
) {
|
||||
const report = await assessDispoImportReadiness(input);
|
||||
const batch = await db.importBatch.findUnique({
|
||||
where: { id: input.importBatchId },
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||
}
|
||||
|
||||
const nextSummary = {
|
||||
...toJsonObject(batch.summary),
|
||||
readiness: report,
|
||||
};
|
||||
|
||||
await db.importBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
status: ImportBatchStatus.STAGED,
|
||||
summary: nextSummary as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
|
||||
export {
|
||||
assessDispoImportReadiness,
|
||||
persistDispoImportReadiness,
|
||||
type AssessDispoImportReadinessInput,
|
||||
type DispoImportReadinessIssue,
|
||||
type DispoImportReadinessReport,
|
||||
} from "./assess-import-readiness.js";
|
||||
export { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
|
||||
export { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
|
||||
export { parseResourceRosterMasterWorkbook } from "./parse-resource-roster-master-workbook.js";
|
||||
export { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
|
||||
export {
|
||||
stageDispoReferenceData,
|
||||
type StageDispoReferenceDataResult,
|
||||
} from "./stage-reference-data.js";
|
||||
export {
|
||||
stageDispoChargeabilityResources,
|
||||
type StageDispoChargeabilityResourcesResult,
|
||||
} from "./stage-chargeability-resources.js";
|
||||
export {
|
||||
stageDispoRosterResources,
|
||||
type StageDispoRosterResourcesResult,
|
||||
} from "./stage-dispo-roster-resources.js";
|
||||
export { stageDispoPlanningData, type StageDispoPlanningResult } from "./stage-dispo-planning.js";
|
||||
export { stageDispoProjects, type StageDispoProjectsResult } from "./stage-dispo-projects.js";
|
||||
export {
|
||||
stageDispoImportBatch,
|
||||
type StageDispoImportBatchInput,
|
||||
type StageDispoImportBatchResult,
|
||||
} from "./stage-dispo-import-batch.js";
|
||||
@@ -0,0 +1,206 @@
|
||||
import { DispoStagedRecordType } from "@planarchy/db";
|
||||
import { DISPO_CHARGEABILITY_SHEET, type ParsedChargeabilityResource, type ParsedChargeabilityWorkbook, type ParsedUnresolvedRecord, buildFallbackAccentureEmail, createAvailabilityFromFte, deriveCountryCodeFromMetroCity, deriveDisplayNameFromEnterpriseId, deriveNormalizedChapter, deriveRoleTokens, ensurePercentageValue, mapChargeabilityResourceType, normalizeNullableWorkbookValue, normalizeText, resolveCanonicalEnterpriseIdentity } from "./shared.js";
|
||||
import { readWorksheetMatrix } from "./read-workbook.js";
|
||||
|
||||
const CHGFC_HEADERS = {
|
||||
clientUnit: "MV Client Unit",
|
||||
enterpriseId: "Enterprise ID",
|
||||
fte: "FTE",
|
||||
managementLevelGroup: "Management Level Group",
|
||||
metroCity: "Metro City",
|
||||
orgUnitLevel6: "Org Unit Level 6",
|
||||
rawChapter: "MV Org Unit 1 / Chapter",
|
||||
rawResourceType: "MV Ressource Type",
|
||||
target: "Target (per Level)",
|
||||
} as const;
|
||||
|
||||
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
|
||||
const headerMap = new Map<string, number>();
|
||||
|
||||
headerRow.forEach((value, index) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (normalized) {
|
||||
headerMap.set(normalized, index);
|
||||
}
|
||||
});
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
function getCellValue(
|
||||
row: ReadonlyArray<unknown>,
|
||||
headerMap: Map<string, number>,
|
||||
headerName: string,
|
||||
): unknown {
|
||||
const index = headerMap.get(headerName);
|
||||
if (index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return row[index] ?? null;
|
||||
}
|
||||
|
||||
function buildResourceSignature(resource: ParsedChargeabilityResource): string {
|
||||
return JSON.stringify({
|
||||
chapter: resource.chapter,
|
||||
chapterCode: resource.chapterCode,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
countryCode: resource.countryCode,
|
||||
fte: resource.fte,
|
||||
managementLevelGroupName: resource.managementLevelGroupName,
|
||||
metroCityName: resource.metroCityName,
|
||||
resourceType: resource.resourceType,
|
||||
roleTokens: resource.roleTokens,
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseDispoChargeabilityWorkbook(
|
||||
workbookPath: string,
|
||||
): Promise<ParsedChargeabilityWorkbook> {
|
||||
const rows = await readWorksheetMatrix(workbookPath, DISPO_CHARGEABILITY_SHEET);
|
||||
const headerMap = buildHeaderMap(rows[0] ?? []);
|
||||
const warnings: string[] = [];
|
||||
const unresolved: ParsedUnresolvedRecord[] = [];
|
||||
const resourceByCanonicalId = new Map<string, ParsedChargeabilityResource>();
|
||||
|
||||
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const enterpriseIdValue = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.enterpriseId),
|
||||
);
|
||||
|
||||
if (!enterpriseIdValue) {
|
||||
if (row.some((value) => normalizeText(value) !== null)) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: null,
|
||||
message: "Missing Enterprise ID in ChgFC row",
|
||||
resolutionHint: "Populate Enterprise ID before staging resource data",
|
||||
warnings: [],
|
||||
normalizedData: {},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
|
||||
if (!canonicalExternalId) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: enterpriseIdValue,
|
||||
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
|
||||
resolutionHint: "Validate Enterprise ID formatting in ChgFC",
|
||||
warnings: [],
|
||||
normalizedData: {
|
||||
enterpriseId: enterpriseIdValue,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const managementLevelGroupName = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.managementLevelGroup),
|
||||
);
|
||||
const rawTarget = getCellValue(row, headerMap, CHGFC_HEADERS.target);
|
||||
const fte = typeof getCellValue(row, headerMap, CHGFC_HEADERS.fte) === "number"
|
||||
? Number(getCellValue(row, headerMap, CHGFC_HEADERS.fte))
|
||||
: null;
|
||||
const metroCityName = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.metroCity),
|
||||
);
|
||||
const rawResourceType = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.rawResourceType),
|
||||
);
|
||||
const levelSixName = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.orgUnitLevel6),
|
||||
);
|
||||
const rawChapter = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.rawChapter),
|
||||
);
|
||||
const clientUnitName = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, CHGFC_HEADERS.clientUnit),
|
||||
);
|
||||
|
||||
const roleTokens = deriveRoleTokens(levelSixName, rawChapter);
|
||||
const normalizedChapter = deriveNormalizedChapter(rawChapter, roleTokens);
|
||||
const resourceTypeResult = mapChargeabilityResourceType(rawResourceType);
|
||||
const recordWarnings = resourceTypeResult.warning ? [resourceTypeResult.warning] : [];
|
||||
const chargeabilityTarget =
|
||||
typeof rawTarget === "number" ? ensurePercentageValue(rawTarget) : null;
|
||||
|
||||
const resource: ParsedChargeabilityResource = {
|
||||
sourceRow: rowNumber,
|
||||
canonicalExternalId,
|
||||
enterpriseId: canonicalExternalId,
|
||||
eid: canonicalExternalId,
|
||||
displayName: deriveDisplayNameFromEnterpriseId(canonicalExternalId),
|
||||
email: buildFallbackAccentureEmail(canonicalExternalId),
|
||||
chapter: normalizedChapter.chapter,
|
||||
chapterCode: normalizedChapter.chapterCode,
|
||||
managementLevelGroupName,
|
||||
managementLevelName: null,
|
||||
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
|
||||
metroCityName,
|
||||
clientUnitName,
|
||||
rawResourceType,
|
||||
resourceType: resourceTypeResult.resourceType,
|
||||
chargeabilityTarget,
|
||||
fte,
|
||||
availability: createAvailabilityFromFte(fte),
|
||||
roleTokens,
|
||||
warnings: recordWarnings,
|
||||
};
|
||||
|
||||
const existing = resourceByCanonicalId.get(canonicalExternalId);
|
||||
if (!existing) {
|
||||
resourceByCanonicalId.set(canonicalExternalId, resource);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSignature = buildResourceSignature(existing);
|
||||
const nextSignature = buildResourceSignature(resource);
|
||||
|
||||
if (existingSignature === nextSignature) {
|
||||
existing.warnings.push(`Duplicate ChgFC row ${rowNumber} ignored for ${canonicalExternalId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.warnings.push(`Conflicting duplicate ChgFC row ${rowNumber} found for ${canonicalExternalId}`);
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: canonicalExternalId,
|
||||
message: `Conflicting resource roster rows found for ${canonicalExternalId}`,
|
||||
resolutionHint: "Resolve the differing ChgFC roster values before commit",
|
||||
warnings: [...recordWarnings],
|
||||
normalizedData: {
|
||||
existing: {
|
||||
sourceRow: existing.sourceRow,
|
||||
chapter: existing.chapter,
|
||||
clientUnitName: existing.clientUnitName,
|
||||
fte: existing.fte,
|
||||
metroCityName: existing.metroCityName,
|
||||
},
|
||||
conflicting: {
|
||||
sourceRow: resource.sourceRow,
|
||||
chapter: resource.chapter,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
fte: resource.fte,
|
||||
metroCityName: resource.metroCityName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resources: Array.from(resourceByCanonicalId.values()),
|
||||
unresolved,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { DispoStagedRecordType } from "@planarchy/db";
|
||||
import {
|
||||
VacationType,
|
||||
normalizeCanonicalResourceIdentity,
|
||||
normalizeDispoRoleToken,
|
||||
normalizeDispoUtilizationToken,
|
||||
} from "@planarchy/shared";
|
||||
import { readWorksheetMatrix, toColumnLetter, type WorksheetCellValue } from "./read-workbook.js";
|
||||
import {
|
||||
DISPO_PLANNING_SHEET,
|
||||
type ParsedPlanningAssignment,
|
||||
type ParsedPlanningAvailabilityRule,
|
||||
type ParsedPlanningVacation,
|
||||
type ParsedPlanningWorkbook,
|
||||
type ParsedUnresolvedRecord,
|
||||
deriveRoleTokens,
|
||||
normalizeNullableWorkbookValue,
|
||||
normalizeText,
|
||||
} from "./shared.js";
|
||||
|
||||
const DISPO_HEADER_ROW = 5;
|
||||
const DISPO_DATE_ROW = 2;
|
||||
const DISPO_SLOT_ROW = 3;
|
||||
const DISPO_DATA_START_ROW = 6;
|
||||
const DISPO_EID_COLUMN = 3;
|
||||
const DISPO_CHAPTER_COLUMN = 4;
|
||||
const DISPO_TYPE_OF_WORK_COLUMN = 5;
|
||||
const DISPO_UNIT_SPECIFIC_FIELD_COLUMN = 7;
|
||||
const DISPO_PLANNING_START_COLUMN = 11;
|
||||
const SLOT_HOURS = 4;
|
||||
const WEEKDAY_LABELS = new Set(["MO", "DI", "MI", "DO", "FR", "SA", "SO"]);
|
||||
const BERLIN_DATE_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
interface PlanningColumn {
|
||||
assignmentDate: Date;
|
||||
columnLetter: string;
|
||||
columnNumber: number;
|
||||
halfDayPart: "AFTERNOON" | "MORNING" | null;
|
||||
slotLabel: string;
|
||||
weekdayLabel: string | null;
|
||||
}
|
||||
|
||||
interface PlanningRowMetadata {
|
||||
chapter: string | null;
|
||||
eid: string;
|
||||
typeOfWork: string | null;
|
||||
unitSpecificField: string | null;
|
||||
}
|
||||
|
||||
interface AssignmentAccumulator {
|
||||
assignmentDate: Date;
|
||||
chapterToken: string | null;
|
||||
firstColumnNumber: number;
|
||||
hoursPerDay: number;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
isUnassigned: boolean;
|
||||
projectKey: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
roleName: string | null;
|
||||
roleToken: string | null;
|
||||
slotCount: number;
|
||||
sourceRow: number;
|
||||
utilizationCategoryCode: string | null;
|
||||
warnings: Set<string>;
|
||||
winProbability: number | null;
|
||||
}
|
||||
|
||||
interface VacationAccumulator {
|
||||
endDate: Date;
|
||||
firstColumnNumber: number;
|
||||
halfDayParts: Set<string>;
|
||||
holidayName: string | null;
|
||||
isPublicHoliday: boolean;
|
||||
note: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
sourceRow: number;
|
||||
startDate: Date;
|
||||
vacationType: VacationType;
|
||||
warnings: Set<string>;
|
||||
}
|
||||
|
||||
interface AvailabilityAccumulator {
|
||||
availableHours: number | null;
|
||||
effectiveEndDate: Date;
|
||||
effectiveStartDate: Date;
|
||||
firstColumnNumber: number;
|
||||
isResolved: boolean;
|
||||
percentage: number | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
ruleType: string;
|
||||
sourceRow: number;
|
||||
warnings: Set<string>;
|
||||
}
|
||||
|
||||
interface ParsedAssignmentToken {
|
||||
chapterToken: string | null;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
isUnassigned: boolean;
|
||||
projectKey: string | null;
|
||||
roleName: string | null;
|
||||
roleToken: string | null;
|
||||
utilizationCategoryCode: string | null;
|
||||
winProbability: number | null;
|
||||
}
|
||||
|
||||
function isWeekdayLabel(value: string | null): boolean {
|
||||
return value !== null && WEEKDAY_LABELS.has(value.toUpperCase());
|
||||
}
|
||||
|
||||
function toDateOnlyInBerlin(value: WorksheetCellValue): Date | null {
|
||||
if (!(value instanceof Date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = BERLIN_DATE_FORMATTER.formatToParts(value);
|
||||
const year = Number(parts.find((part) => part.type === "year")?.value);
|
||||
const month = Number(parts.find((part) => part.type === "month")?.value);
|
||||
const day = Number(parts.find((part) => part.type === "day")?.value);
|
||||
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function getDateKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getSlotHalfDayPart(slotLabel: string | null): "AFTERNOON" | "MORNING" | null {
|
||||
const normalized = normalizeText(slotLabel)?.toLowerCase() ?? null;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.includes("9.-13")) {
|
||||
return "MORNING";
|
||||
}
|
||||
if (normalized.includes("14.-18")) {
|
||||
return "AFTERNOON";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPlanningColumns(rows: ReadonlyArray<ReadonlyArray<WorksheetCellValue>>) {
|
||||
const columns: PlanningColumn[] = [];
|
||||
const headerWidth = Math.max(rows[DISPO_DATE_ROW - 1]?.length ?? 0, rows[DISPO_SLOT_ROW - 1]?.length ?? 0);
|
||||
|
||||
for (let columnNumber = DISPO_PLANNING_START_COLUMN; columnNumber <= headerWidth; columnNumber += 1) {
|
||||
const slotLabel = normalizeNullableWorkbookValue(rows[DISPO_SLOT_ROW - 1]?.[columnNumber - 1]);
|
||||
if (!slotLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber - 1] ?? null;
|
||||
const previousHeaderLabel = normalizeNullableWorkbookValue(rows[DISPO_DATE_ROW - 1]?.[columnNumber - 2]);
|
||||
const currentHeaderLabel = normalizeNullableWorkbookValue(currentHeaderValue);
|
||||
const nextHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber] ?? null;
|
||||
|
||||
const assignmentDate =
|
||||
toDateOnlyInBerlin(currentHeaderValue) ??
|
||||
toDateOnlyInBerlin(nextHeaderValue);
|
||||
|
||||
if (!assignmentDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const weekdayLabel = isWeekdayLabel(currentHeaderLabel)
|
||||
? currentHeaderLabel
|
||||
: isWeekdayLabel(previousHeaderLabel)
|
||||
? previousHeaderLabel
|
||||
: null;
|
||||
|
||||
columns.push({
|
||||
assignmentDate,
|
||||
columnLetter: toColumnLetter(columnNumber),
|
||||
columnNumber,
|
||||
halfDayPart: getSlotHalfDayPart(slotLabel),
|
||||
slotLabel,
|
||||
weekdayLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function normalizePlanningToken(token: string): string {
|
||||
return token
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s+(?:HB|SB)_?\s*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractBracketTokens(token: string): string[] {
|
||||
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUtilizationToken(token: string): { utilizationToken: string | null; winProbability: number | null } {
|
||||
const matches = Array.from(token.matchAll(/\{([A-Z]+)(\d{0,3})\}/gi));
|
||||
const lastMatch = matches.at(-1);
|
||||
if (!lastMatch) {
|
||||
return {
|
||||
utilizationToken: null,
|
||||
winProbability: null,
|
||||
};
|
||||
}
|
||||
|
||||
const utilizationToken = lastMatch[1]?.toUpperCase() ?? null;
|
||||
const winProbability = lastMatch[2] ? Number(lastMatch[2]) : null;
|
||||
return {
|
||||
utilizationToken,
|
||||
winProbability: Number.isFinite(winProbability) ? winProbability : null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractRoleToken(token: string, metadata: PlanningRowMetadata): string | null {
|
||||
const explicitRoleToken = token.match(/^(2D|3D|PM|AD)\b/i)?.[1]?.toUpperCase() ?? null;
|
||||
if (explicitRoleToken) {
|
||||
return explicitRoleToken;
|
||||
}
|
||||
|
||||
return deriveRoleTokens(metadata.chapter, metadata.typeOfWork, metadata.unitSpecificField)[0] ?? null;
|
||||
}
|
||||
|
||||
function extractProjectKey(token: string): string | null {
|
||||
const bracketTokens = extractBracketTokens(token).filter((entry) => !entry.startsWith("_"));
|
||||
const lastToken = bracketTokens.at(-1) ?? null;
|
||||
return lastToken && lastToken.toLowerCase() !== "tbd" ? lastToken : null;
|
||||
}
|
||||
|
||||
function extractLabel(token: string): string | null {
|
||||
const stripped = token
|
||||
.replace(/^(2D|3D|PM|AD)\s+/i, "")
|
||||
.replace(/\[[^\]]+\]/g, " ")
|
||||
.replace(/\{[^}]+\}/g, " ")
|
||||
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return stripped.length > 0 ? stripped : null;
|
||||
}
|
||||
|
||||
function parsePercentage(value: string): number | null {
|
||||
const percentageMatch = value.match(/(\d+(?:[.,]\d+)?)\s*%/);
|
||||
if (percentageMatch) {
|
||||
const normalized = Number(percentageMatch[1]?.replace(",", "."));
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
const fteMatch = value.match(/FTE:\s*(\d+(?:[.,]\d+)?)/i);
|
||||
if (fteMatch) {
|
||||
const normalized = Number(fteMatch[1]?.replace(",", "."));
|
||||
return Number.isFinite(normalized) ? Math.round(normalized * 10000) / 100 : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAssignmentAccumulator(
|
||||
column: PlanningColumn,
|
||||
metadata: PlanningRowMetadata,
|
||||
rawToken: string,
|
||||
): AssignmentAccumulator | null {
|
||||
const roleToken = extractRoleToken(rawToken, metadata);
|
||||
const roleName = normalizeDispoRoleToken(roleToken);
|
||||
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
|
||||
const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken);
|
||||
const projectKey = extractProjectKey(rawToken);
|
||||
const isTbd = /\[tbd\]/i.test(rawToken);
|
||||
const isUnassigned = utilizationToken === "UN";
|
||||
const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? "");
|
||||
|
||||
if (isUnassigned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
assignmentDate: column.assignmentDate,
|
||||
chapterToken: roleToken,
|
||||
firstColumnNumber: column.columnNumber,
|
||||
hoursPerDay: SLOT_HOURS,
|
||||
isInternal,
|
||||
isTbd,
|
||||
isUnassigned: false,
|
||||
projectKey,
|
||||
rawToken,
|
||||
resourceExternalId: metadata.eid,
|
||||
roleName,
|
||||
roleToken,
|
||||
slotCount: 1,
|
||||
sourceRow: 0,
|
||||
utilizationCategoryCode,
|
||||
warnings: new Set<string>(),
|
||||
winProbability,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVacationAccumulator(
|
||||
column: PlanningColumn,
|
||||
metadata: PlanningRowMetadata,
|
||||
rawToken: string,
|
||||
vacationType: VacationType,
|
||||
input: { holidayName?: string | null; isPublicHoliday: boolean; note?: string | null },
|
||||
): VacationAccumulator {
|
||||
const halfDayParts = new Set<string>();
|
||||
if (column.halfDayPart) {
|
||||
halfDayParts.add(column.halfDayPart);
|
||||
}
|
||||
|
||||
return {
|
||||
endDate: column.assignmentDate,
|
||||
firstColumnNumber: column.columnNumber,
|
||||
halfDayParts,
|
||||
holidayName: input.holidayName ?? null,
|
||||
isPublicHoliday: input.isPublicHoliday,
|
||||
note: input.note ?? null,
|
||||
rawToken,
|
||||
resourceExternalId: metadata.eid,
|
||||
sourceRow: 0,
|
||||
startDate: column.assignmentDate,
|
||||
vacationType,
|
||||
warnings: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAvailabilityAccumulator(
|
||||
column: PlanningColumn,
|
||||
metadata: PlanningRowMetadata,
|
||||
rawToken: string,
|
||||
): AvailabilityAccumulator {
|
||||
const percentage = parsePercentage(rawToken);
|
||||
const availableHours = percentage !== null
|
||||
? Math.round((percentage / 100) * 8 * 100) / 100
|
||||
: 8 - SLOT_HOURS;
|
||||
|
||||
return {
|
||||
availableHours,
|
||||
effectiveEndDate: column.assignmentDate,
|
||||
effectiveStartDate: column.assignmentDate,
|
||||
firstColumnNumber: column.columnNumber,
|
||||
isResolved: false,
|
||||
percentage,
|
||||
rawToken,
|
||||
resourceExternalId: metadata.eid,
|
||||
ruleType: "PART_TIME",
|
||||
sourceRow: 0,
|
||||
warnings: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseDispoPlanningWorkbook(
|
||||
workbookPath: string,
|
||||
): Promise<ParsedPlanningWorkbook> {
|
||||
const rows = await readWorksheetMatrix(workbookPath, DISPO_PLANNING_SHEET);
|
||||
const planningColumns = buildPlanningColumns(rows);
|
||||
const assignments = new Map<string, AssignmentAccumulator>();
|
||||
const vacations = new Map<string, VacationAccumulator>();
|
||||
const availabilityRules = new Map<string, AvailabilityAccumulator>();
|
||||
const unresolved: ParsedUnresolvedRecord[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (let rowNumber = DISPO_DATA_START_ROW; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const eid = normalizeNullableWorkbookValue(row[DISPO_EID_COLUMN - 1]);
|
||||
|
||||
if (!eid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata: PlanningRowMetadata = {
|
||||
chapter: normalizeNullableWorkbookValue(row[DISPO_CHAPTER_COLUMN - 1]),
|
||||
eid: normalizeCanonicalResourceIdentity(eid),
|
||||
typeOfWork: normalizeNullableWorkbookValue(row[DISPO_TYPE_OF_WORK_COLUMN - 1]),
|
||||
unitSpecificField: normalizeNullableWorkbookValue(row[DISPO_UNIT_SPECIFIC_FIELD_COLUMN - 1]),
|
||||
};
|
||||
|
||||
for (const column of planningColumns) {
|
||||
const rawCellValue = normalizeNullableWorkbookValue(row[column.columnNumber - 1]);
|
||||
if (!rawCellValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawToken = normalizePlanningToken(rawCellValue);
|
||||
const normalizedToken = rawToken.toUpperCase();
|
||||
|
||||
if (normalizedToken === "[_NA] WEEKEND {NA}") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedToken.startsWith("[_AB]")) {
|
||||
const note = extractLabel(rawToken);
|
||||
const vacationType = note?.toLowerCase().includes("sick") ? VacationType.SICK : VacationType.ANNUAL;
|
||||
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|VAC|${rawToken}`;
|
||||
const existing = vacations.get(key);
|
||||
if (existing) {
|
||||
existing.endDate = column.assignmentDate;
|
||||
if (column.halfDayPart) {
|
||||
existing.halfDayParts.add(column.halfDayPart);
|
||||
}
|
||||
} else {
|
||||
const vacation = buildVacationAccumulator(column, metadata, rawToken, vacationType, {
|
||||
isPublicHoliday: false,
|
||||
note,
|
||||
});
|
||||
vacation.sourceRow = rowNumber;
|
||||
vacations.set(key, vacation);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PUBLIC HOLIDAY")) {
|
||||
const holidayName = extractLabel(rawToken);
|
||||
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PH|${rawToken}`;
|
||||
const existing = vacations.get(key);
|
||||
if (existing) {
|
||||
existing.endDate = column.assignmentDate;
|
||||
if (column.halfDayPart) {
|
||||
existing.halfDayParts.add(column.halfDayPart);
|
||||
}
|
||||
} else {
|
||||
const vacation = buildVacationAccumulator(column, metadata, rawToken, VacationType.PUBLIC_HOLIDAY, {
|
||||
holidayName,
|
||||
isPublicHoliday: true,
|
||||
note: holidayName,
|
||||
});
|
||||
vacation.sourceRow = rowNumber;
|
||||
vacations.set(key, vacation);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PART-TIME")) {
|
||||
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`;
|
||||
const existing = availabilityRules.get(key);
|
||||
if (existing) {
|
||||
existing.availableHours = buildAvailabilityAccumulator(column, metadata, rawToken).availableHours;
|
||||
existing.percentage = buildAvailabilityAccumulator(column, metadata, rawToken).percentage;
|
||||
} else {
|
||||
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken);
|
||||
availabilityRule.sourceRow = rowNumber;
|
||||
availabilityRules.set(key, availabilityRule);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedToken.startsWith("[_UN]")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignment = buildAssignmentAccumulator(column, metadata, rawToken);
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assignment.sourceRow = rowNumber;
|
||||
if (!assignment.utilizationCategoryCode) {
|
||||
assignment.warnings.add(`Unable to resolve utilization category from token "${rawToken}"`);
|
||||
}
|
||||
|
||||
if (!assignment.projectKey && !assignment.isInternal && !assignment.isTbd) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: column.columnLetter,
|
||||
recordType: DispoStagedRecordType.ASSIGNMENT,
|
||||
resourceExternalId: metadata.eid,
|
||||
projectKey: null,
|
||||
message: `Unable to resolve project key from planning token "${rawToken}"`,
|
||||
resolutionHint: "Add a WBS token or classify this cell as an internal bucket before commit",
|
||||
warnings: Array.from(assignment.warnings),
|
||||
normalizedData: {
|
||||
assignmentDate: getDateKey(column.assignmentDate),
|
||||
rawToken,
|
||||
roleToken: assignment.roleToken,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.isTbd) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: column.columnLetter,
|
||||
recordType: DispoStagedRecordType.PROJECT,
|
||||
resourceExternalId: metadata.eid,
|
||||
projectKey: null,
|
||||
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
|
||||
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
|
||||
warnings: Array.from(assignment.warnings),
|
||||
normalizedData: {
|
||||
assignmentDate: getDateKey(column.assignmentDate),
|
||||
rawToken,
|
||||
roleToken: assignment.roleToken,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
winProbability: assignment.winProbability,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|ASN|${rawToken}`;
|
||||
const existing = assignments.get(key);
|
||||
if (existing) {
|
||||
existing.hoursPerDay += SLOT_HOURS;
|
||||
existing.slotCount += 1;
|
||||
} else {
|
||||
assignments.set(key, assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsedAssignments: ParsedPlanningAssignment[] = Array.from(assignments.values()).map((entry) => ({
|
||||
assignmentDate: entry.assignmentDate,
|
||||
chapterToken: entry.chapterToken,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
isInternal: entry.isInternal,
|
||||
isTbd: entry.isTbd,
|
||||
isUnassigned: entry.isUnassigned,
|
||||
percentage: entry.slotCount * 50,
|
||||
projectKey: entry.projectKey,
|
||||
rawToken: entry.rawToken,
|
||||
resourceExternalId: entry.resourceExternalId,
|
||||
roleName: entry.roleName,
|
||||
roleToken: entry.roleToken,
|
||||
slotFraction: entry.slotCount / 2,
|
||||
sourceColumn: toColumnLetter(entry.firstColumnNumber),
|
||||
sourceRow: entry.sourceRow,
|
||||
utilizationCategoryCode: entry.utilizationCategoryCode,
|
||||
warnings: Array.from(entry.warnings),
|
||||
winProbability: entry.winProbability,
|
||||
}));
|
||||
|
||||
const parsedVacations: ParsedPlanningVacation[] = Array.from(vacations.values()).map((entry) => ({
|
||||
endDate: entry.endDate,
|
||||
halfDayPart: entry.halfDayParts.size === 1 ? Array.from(entry.halfDayParts)[0] ?? null : null,
|
||||
holidayName: entry.holidayName,
|
||||
isHalfDay: entry.halfDayParts.size === 1,
|
||||
isPublicHoliday: entry.isPublicHoliday,
|
||||
note: entry.note,
|
||||
rawToken: entry.rawToken,
|
||||
resourceExternalId: entry.resourceExternalId,
|
||||
sourceColumn: toColumnLetter(entry.firstColumnNumber),
|
||||
sourceRow: entry.sourceRow,
|
||||
startDate: entry.startDate,
|
||||
vacationType: entry.vacationType,
|
||||
warnings: Array.from(entry.warnings),
|
||||
}));
|
||||
|
||||
const parsedAvailabilityRules: ParsedPlanningAvailabilityRule[] = Array.from(availabilityRules.values()).map((entry) => ({
|
||||
availableHours: entry.availableHours,
|
||||
effectiveEndDate: entry.effectiveEndDate,
|
||||
effectiveStartDate: entry.effectiveStartDate,
|
||||
isResolved: entry.isResolved,
|
||||
percentage: entry.percentage,
|
||||
rawToken: entry.rawToken,
|
||||
resourceExternalId: entry.resourceExternalId,
|
||||
ruleType: entry.ruleType,
|
||||
sourceColumn: toColumnLetter(entry.firstColumnNumber),
|
||||
sourceRow: entry.sourceRow,
|
||||
warnings: Array.from(entry.warnings),
|
||||
}));
|
||||
|
||||
return {
|
||||
assignments: parsedAssignments,
|
||||
availabilityRules: parsedAvailabilityRules,
|
||||
unresolved,
|
||||
vacations: parsedVacations,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { DispoStagedRecordType, ResourceType } from "@planarchy/db";
|
||||
import { createWeekdayAvailabilityFromFte } from "@planarchy/shared";
|
||||
import {
|
||||
parseResourceRosterMasterWorkbook,
|
||||
type ParsedResourceRosterLevelAverage,
|
||||
type ParsedResourceRosterMasterWorkbook,
|
||||
type ParsedResourceRosterRate,
|
||||
} from "./parse-resource-roster-master-workbook.js";
|
||||
import {
|
||||
DISPO_ROSTER_SAP_SHEET,
|
||||
DISPO_ROSTER_SHEET,
|
||||
type ParsedRosterResource,
|
||||
type ParsedRosterWorkbook,
|
||||
type ParsedUnresolvedRecord,
|
||||
buildFallbackAccentureEmail,
|
||||
deriveCountryCodeFromMetroCity,
|
||||
deriveDisplayNameFromEnterpriseId,
|
||||
deriveNormalizedChapter,
|
||||
deriveRoleTokens,
|
||||
isPseudoDemandResourceIdentity,
|
||||
mapChargeabilityResourceType,
|
||||
normalizeNullableWorkbookValue,
|
||||
normalizeText,
|
||||
resolveCanonicalEnterpriseIdentity,
|
||||
} from "./shared.js";
|
||||
import { readWorksheetMatrix } from "./read-workbook.js";
|
||||
|
||||
const ROSTER_HEADERS = {
|
||||
clientUnit: "MV Client Unit",
|
||||
dailyWorkingHoursPerFte: "Daily Working Hours/FTE",
|
||||
department: "MV Org Unit 2 / Department",
|
||||
eid: "EID",
|
||||
firstDayInDispo: "First day in dispo",
|
||||
fte: "FTE",
|
||||
lastDayInDispo: "Last day in dispo",
|
||||
mainSkillset: "MV Main Skillset",
|
||||
managementLevel: "Management Level",
|
||||
managementLevelGroup: "Management Level Group",
|
||||
metroCity: "Metro City",
|
||||
rawChapter: "MV Org Unit 1 / Chapter",
|
||||
rawResourceType: "MV Ressource Type",
|
||||
resourceHoursPerWeek: "Resource Hours/Week",
|
||||
vacationDaysPerYear: "Vacation days / year",
|
||||
} as const;
|
||||
|
||||
const SAP_HEADERS = {
|
||||
employeeEmail: "Employee Email",
|
||||
employeeName: "Employee Name",
|
||||
enterpriseId: "Enterprise ID",
|
||||
fte: "FTE",
|
||||
managementLevel: "Management Level",
|
||||
managementLevelGroup: "Management Level Group",
|
||||
metroCity: "Metro City",
|
||||
orgUnitLevel5: "Org Unit Level 5",
|
||||
orgUnitLevel6: "Org Unit Level 6",
|
||||
orgUnitLevel7: "Org Unit Level 7",
|
||||
} as const;
|
||||
|
||||
interface RosterSourceRow {
|
||||
canonicalExternalId: string;
|
||||
clientUnitName: string | null;
|
||||
dailyWorkingHoursPerFte: number | null;
|
||||
department: string | null;
|
||||
fte: number | null;
|
||||
mainSkillset: string | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawChapter: string | null;
|
||||
rawResourceType: string | null;
|
||||
resourceHoursPerWeek: number | null;
|
||||
rowNumber: number;
|
||||
vacationDaysPerYear: number | null;
|
||||
firstDayInDispo: Date | null;
|
||||
lastDayInDispo: Date | null;
|
||||
}
|
||||
|
||||
interface SapSourceRow {
|
||||
canonicalExternalId: string;
|
||||
employeeEmail: string | null;
|
||||
employeeName: string | null;
|
||||
fte: number | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
orgUnitLevelFive: string | null;
|
||||
orgUnitLevelSix: string | null;
|
||||
orgUnitLevelSeven: string | null;
|
||||
rowNumber: number;
|
||||
}
|
||||
|
||||
interface ParseDispoRosterWorkbookOptions {
|
||||
costWorkbookPath?: string;
|
||||
}
|
||||
|
||||
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
|
||||
const headerMap = new Map<string, number>();
|
||||
|
||||
headerRow.forEach((value, index) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (normalized) {
|
||||
headerMap.set(normalized, index);
|
||||
}
|
||||
});
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
function getCellValue(
|
||||
row: ReadonlyArray<unknown>,
|
||||
headerMap: Map<string, number>,
|
||||
headerName: string,
|
||||
): unknown {
|
||||
const index = headerMap.get(headerName);
|
||||
if (index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return row[index] ?? null;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = normalizeNullableWorkbookValue(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized.replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value: unknown): Date | null {
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = normalizeNullableWorkbookValue(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.valueOf()) ? null : parsed;
|
||||
}
|
||||
|
||||
function normalizeSapDisplayName(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.split(",")
|
||||
.map((part) => normalizeText(part))
|
||||
.filter((part): part is string => Boolean(part));
|
||||
|
||||
if (normalized.length === 2) {
|
||||
return `${normalized[1]} ${normalized[0]}`;
|
||||
}
|
||||
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function buildResourceWarnings(
|
||||
resourceType: ResourceType,
|
||||
resourceTypeWarning: string | null,
|
||||
roster: RosterSourceRow | null,
|
||||
sap: SapSourceRow | null,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (resourceTypeWarning) {
|
||||
warnings.push(resourceTypeWarning);
|
||||
}
|
||||
if (!roster) {
|
||||
warnings.push("Missing DispoRoster row; resource imported from SAP_data only");
|
||||
}
|
||||
if (!sap) {
|
||||
warnings.push("Missing SAP_data row; email and display name fall back to derived values");
|
||||
}
|
||||
if (resourceType === ResourceType.FREELANCER && !roster?.dailyWorkingHoursPerFte) {
|
||||
warnings.push("Freelancer row has no daily working hours value; defaulting to 8h/day");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function shouldExcludeImportedResource(resource: {
|
||||
canonicalExternalId: string;
|
||||
sourceEmail: string | null;
|
||||
managementLevelName: string | null;
|
||||
}) {
|
||||
return !resource.sourceEmail && !resource.managementLevelName;
|
||||
}
|
||||
|
||||
function applyRateResolution(input: {
|
||||
canonicalExternalId: string;
|
||||
level: string | null;
|
||||
rateRecord: ParsedResourceRosterRate | null;
|
||||
levelAverage: ParsedResourceRosterLevelAverage | null;
|
||||
warnings: string[];
|
||||
}) {
|
||||
const { canonicalExternalId, level, rateRecord, levelAverage, warnings } = input;
|
||||
|
||||
const exactLcr = rateRecord?.lcrCents ?? null;
|
||||
const exactUcr = rateRecord?.ucrCents ?? null;
|
||||
const fallbackLcr = levelAverage?.lcrCents ?? null;
|
||||
const fallbackUcr = levelAverage?.ucrCents ?? null;
|
||||
|
||||
const lcrCents = exactLcr ?? fallbackLcr;
|
||||
const ucrCents = exactUcr ?? fallbackUcr;
|
||||
|
||||
if (!rateRecord) {
|
||||
if (levelAverage && lcrCents !== null && ucrCents !== null) {
|
||||
warnings.push(
|
||||
`Applied level-average rates for ${canonicalExternalId} using management level ${levelAverage.level}`,
|
||||
);
|
||||
return {
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
rateResolution: "LEVEL_AVERAGE" as const,
|
||||
rateResolutionLevel: levelAverage.level,
|
||||
};
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
level
|
||||
? `Missing rate row for ${canonicalExternalId}; no usable level-average rate found for ${level}`
|
||||
: `Missing rate row for ${canonicalExternalId}; management level unavailable for fallback`,
|
||||
);
|
||||
return {
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
rateResolution: "MISSING" as const,
|
||||
rateResolutionLevel: levelAverage?.level ?? level ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (exactLcr !== null && exactUcr !== null) {
|
||||
return {
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
rateResolution: "EXACT" as const,
|
||||
rateResolutionLevel: rateRecord.level ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (levelAverage && lcrCents !== null && ucrCents !== null) {
|
||||
warnings.push(
|
||||
`Completed incomplete rate row for ${canonicalExternalId} with level-average rates from ${levelAverage.level}`,
|
||||
);
|
||||
return {
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
rateResolution: "LEVEL_AVERAGE" as const,
|
||||
rateResolutionLevel: levelAverage.level,
|
||||
};
|
||||
}
|
||||
|
||||
warnings.push(`Incomplete rate row for ${canonicalExternalId} could not be fully resolved`);
|
||||
return {
|
||||
lcrCents,
|
||||
ucrCents,
|
||||
rateResolution: "MISSING" as const,
|
||||
rateResolutionLevel: rateRecord.level ?? level ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseDispoRosterWorkbook(
|
||||
workbookPath: string,
|
||||
options: ParseDispoRosterWorkbookOptions = {},
|
||||
): Promise<ParsedRosterWorkbook> {
|
||||
const [rosterRows, sapRows] = await Promise.all([
|
||||
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SHEET),
|
||||
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SAP_SHEET),
|
||||
]);
|
||||
const rateWorkbook: ParsedResourceRosterMasterWorkbook | null = options.costWorkbookPath
|
||||
? await parseResourceRosterMasterWorkbook(options.costWorkbookPath)
|
||||
: null;
|
||||
const rosterHeaderMap = buildHeaderMap(rosterRows[0] ?? []);
|
||||
const sapHeaderMap = buildHeaderMap(sapRows[1] ?? []);
|
||||
|
||||
const warnings: string[] = [...(rateWorkbook?.warnings ?? [])];
|
||||
const unresolved: ParsedUnresolvedRecord[] = [];
|
||||
const rosterById = new Map<string, RosterSourceRow>();
|
||||
const sapById = new Map<string, SapSourceRow>();
|
||||
let ignoredPseudoDemandRows = 0;
|
||||
|
||||
for (let rowNumber = 2; rowNumber <= rosterRows.length; rowNumber += 1) {
|
||||
const row = rosterRows[rowNumber - 1] ?? [];
|
||||
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
|
||||
if (!eidValue) {
|
||||
if (row.some((value) => normalizeText(value) !== null)) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: null,
|
||||
message: "Missing EID in DispoRoster row",
|
||||
resolutionHint: "Populate EID before staging roster resource data",
|
||||
warnings: [],
|
||||
normalizedData: {},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPseudoDemandResourceIdentity(eidValue)) {
|
||||
ignoredPseudoDemandRows += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(eidValue);
|
||||
if (!canonicalExternalId) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: eidValue,
|
||||
message: `Unable to normalize EID "${eidValue}"`,
|
||||
resolutionHint: "Validate EID formatting in DispoRoster",
|
||||
warnings: [],
|
||||
normalizedData: { eid: eidValue },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rosterById.has(canonicalExternalId)) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: canonicalExternalId,
|
||||
message: `Duplicate DispoRoster row found for ${canonicalExternalId}`,
|
||||
resolutionHint: "Keep exactly one operational roster row per EID",
|
||||
warnings: [],
|
||||
normalizedData: { eid: canonicalExternalId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterById.set(canonicalExternalId, {
|
||||
canonicalExternalId,
|
||||
rowNumber,
|
||||
metroCityName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.metroCity),
|
||||
),
|
||||
managementLevelGroupName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevelGroup),
|
||||
),
|
||||
managementLevelName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevel),
|
||||
),
|
||||
fte: parseOptionalNumber(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.fte)),
|
||||
dailyWorkingHoursPerFte: parseOptionalNumber(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.dailyWorkingHoursPerFte),
|
||||
),
|
||||
resourceHoursPerWeek: parseOptionalNumber(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.resourceHoursPerWeek),
|
||||
),
|
||||
rawResourceType: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawResourceType),
|
||||
),
|
||||
clientUnitName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.clientUnit),
|
||||
),
|
||||
rawChapter: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawChapter),
|
||||
),
|
||||
department: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.department),
|
||||
),
|
||||
mainSkillset: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.mainSkillset),
|
||||
),
|
||||
firstDayInDispo: parseOptionalDate(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.firstDayInDispo),
|
||||
),
|
||||
lastDayInDispo: parseOptionalDate(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.lastDayInDispo),
|
||||
),
|
||||
vacationDaysPerYear: parseOptionalNumber(
|
||||
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.vacationDaysPerYear),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (let rowNumber = 3; rowNumber <= sapRows.length; rowNumber += 1) {
|
||||
const row = sapRows[rowNumber - 1] ?? [];
|
||||
const enterpriseIdValue = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.enterpriseId),
|
||||
);
|
||||
if (!enterpriseIdValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
|
||||
if (!canonicalExternalId) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "C",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: enterpriseIdValue,
|
||||
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
|
||||
resolutionHint: "Validate Enterprise ID formatting in SAP_data",
|
||||
warnings: [],
|
||||
normalizedData: { enterpriseId: enterpriseIdValue },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sapById.has(canonicalExternalId)) {
|
||||
unresolved.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "C",
|
||||
recordType: DispoStagedRecordType.RESOURCE,
|
||||
resourceExternalId: canonicalExternalId,
|
||||
message: `Duplicate SAP_data row found for ${canonicalExternalId}`,
|
||||
resolutionHint: "Keep exactly one SAP_data row per Enterprise ID",
|
||||
warnings: [],
|
||||
normalizedData: { enterpriseId: canonicalExternalId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
sapById.set(canonicalExternalId, {
|
||||
canonicalExternalId,
|
||||
rowNumber,
|
||||
employeeName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeName),
|
||||
),
|
||||
employeeEmail: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeEmail),
|
||||
)?.toLowerCase() ?? null,
|
||||
metroCityName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.metroCity),
|
||||
),
|
||||
managementLevelGroupName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevelGroup),
|
||||
),
|
||||
managementLevelName: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevel),
|
||||
),
|
||||
orgUnitLevelFive: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel5),
|
||||
),
|
||||
orgUnitLevelSix: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel6),
|
||||
),
|
||||
orgUnitLevelSeven: normalizeNullableWorkbookValue(
|
||||
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel7),
|
||||
),
|
||||
fte: parseOptionalNumber(getCellValue(row, sapHeaderMap, SAP_HEADERS.fte)),
|
||||
});
|
||||
}
|
||||
|
||||
const resourceIds = new Set<string>([...rosterById.keys(), ...sapById.keys()]);
|
||||
const resources: ParsedRosterResource[] = [];
|
||||
const excludedCanonicalExternalIds = new Set<string>();
|
||||
|
||||
for (const canonicalExternalId of resourceIds) {
|
||||
const roster = rosterById.get(canonicalExternalId) ?? null;
|
||||
const sap = sapById.get(canonicalExternalId) ?? null;
|
||||
const roleTokens = deriveRoleTokens(
|
||||
roster?.department,
|
||||
roster?.rawChapter,
|
||||
roster?.mainSkillset,
|
||||
sap?.orgUnitLevelSix,
|
||||
sap?.orgUnitLevelSeven,
|
||||
);
|
||||
const normalizedChapter = deriveNormalizedChapter(roster?.rawChapter ?? null, roleTokens);
|
||||
const resourceTypeResult = mapChargeabilityResourceType(roster?.rawResourceType ?? null);
|
||||
const resourceType =
|
||||
roster?.rawResourceType || sap ? resourceTypeResult.resourceType : ResourceType.EMPLOYEE;
|
||||
const fte = sap?.fte ?? roster?.fte ?? null;
|
||||
const dailyWorkingHoursPerFte = roster?.dailyWorkingHoursPerFte ?? null;
|
||||
const displayName =
|
||||
normalizeSapDisplayName(sap?.employeeName ?? null) ??
|
||||
deriveDisplayNameFromEnterpriseId(canonicalExternalId);
|
||||
const metroCityName = sap?.metroCityName ?? roster?.metroCityName ?? null;
|
||||
const managementLevelName =
|
||||
sap?.managementLevelName ?? roster?.managementLevelName ?? null;
|
||||
const resourceWarnings = buildResourceWarnings(resourceType, resourceTypeResult.warning, roster, sap);
|
||||
const rateResolution = applyRateResolution({
|
||||
canonicalExternalId,
|
||||
level: managementLevelName,
|
||||
rateRecord: rateWorkbook?.rates.get(canonicalExternalId) ?? null,
|
||||
levelAverage: managementLevelName
|
||||
? rateWorkbook?.levelAverages.get(managementLevelName) ?? null
|
||||
: null,
|
||||
warnings: resourceWarnings,
|
||||
});
|
||||
|
||||
const resource: ParsedRosterResource = {
|
||||
sourceRow: roster?.rowNumber ?? sap?.rowNumber ?? 0,
|
||||
sourceSheet: roster ? DISPO_ROSTER_SHEET : DISPO_ROSTER_SAP_SHEET,
|
||||
canonicalExternalId,
|
||||
enterpriseId: canonicalExternalId,
|
||||
eid: canonicalExternalId,
|
||||
displayName,
|
||||
email: sap?.employeeEmail ?? buildFallbackAccentureEmail(canonicalExternalId),
|
||||
chapter: normalizedChapter.chapter,
|
||||
chapterCode: normalizedChapter.chapterCode,
|
||||
managementLevelGroupName: sap?.managementLevelGroupName ?? roster?.managementLevelGroupName ?? null,
|
||||
managementLevelName,
|
||||
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
|
||||
metroCityName,
|
||||
clientUnitName: roster?.clientUnitName ?? null,
|
||||
rawResourceType: roster?.rawResourceType ?? null,
|
||||
resourceType,
|
||||
fte,
|
||||
lcrCents: rateResolution.lcrCents,
|
||||
ucrCents: rateResolution.ucrCents,
|
||||
rateResolution: rateResolution.rateResolution,
|
||||
rateResolutionLevel: rateResolution.rateResolutionLevel,
|
||||
availability: createWeekdayAvailabilityFromFte(
|
||||
fte ?? 1,
|
||||
dailyWorkingHoursPerFte ?? 8,
|
||||
) as unknown as ParsedRosterResource["availability"],
|
||||
roleTokens,
|
||||
dailyWorkingHoursPerFte,
|
||||
department: roster?.department ?? null,
|
||||
mainSkillset: roster?.mainSkillset ?? null,
|
||||
resourceHoursPerWeek: roster?.resourceHoursPerWeek ?? null,
|
||||
firstDayInDispo: roster?.firstDayInDispo ?? null,
|
||||
lastDayInDispo: roster?.lastDayInDispo ?? null,
|
||||
vacationDaysPerYear: roster?.vacationDaysPerYear ?? null,
|
||||
sapEmployeeName: sap?.employeeName ?? null,
|
||||
sapOrgUnitLevelFive: sap?.orgUnitLevelFive ?? null,
|
||||
sapOrgUnitLevelSix: sap?.orgUnitLevelSix ?? null,
|
||||
sapOrgUnitLevelSeven: sap?.orgUnitLevelSeven ?? null,
|
||||
warnings: resourceWarnings,
|
||||
};
|
||||
|
||||
if (
|
||||
shouldExcludeImportedResource({
|
||||
canonicalExternalId,
|
||||
sourceEmail: sap?.employeeEmail ?? null,
|
||||
managementLevelName,
|
||||
})
|
||||
) {
|
||||
excludedCanonicalExternalIds.add(canonicalExternalId);
|
||||
warnings.push(
|
||||
`Excluded ${canonicalExternalId} from import because neither email nor management level is present in the supplied sources`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
resources.push(resource);
|
||||
}
|
||||
|
||||
if (ignoredPseudoDemandRows > 0) {
|
||||
warnings.push(`Ignored ${ignoredPseudoDemandRows} pseudo-demand rows from DispoRoster`);
|
||||
}
|
||||
|
||||
resources.sort((left, right) => left.canonicalExternalId.localeCompare(right.canonicalExternalId));
|
||||
|
||||
return {
|
||||
excludedCanonicalExternalIds: Array.from(excludedCanonicalExternalIds).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
resources,
|
||||
unresolved,
|
||||
warnings,
|
||||
ignoredPseudoDemandRows,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
DISPO_PROJECT_REFERENCE_SHEET,
|
||||
DISPO_REFERENCE_SHEET,
|
||||
type ParsedClientReference,
|
||||
type ParsedCountryReference,
|
||||
type ParsedManagementLevelGroupReference,
|
||||
type ParsedOrgUnitReference,
|
||||
type ParsedReferenceWorkbook,
|
||||
findSectionRow,
|
||||
getCountryReferenceConfig,
|
||||
normalizeClientCode,
|
||||
normalizeNullableWorkbookValue,
|
||||
normalizeText,
|
||||
sanitizeClientName,
|
||||
} from "./shared.js";
|
||||
import { readWorksheetMatrix, toColumnLetter } from "./read-workbook.js";
|
||||
|
||||
function isTerminalSectionName(value: string | null, names: readonly string[]): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = value.toLowerCase();
|
||||
return names.some((name) => name.toLowerCase() === normalizedValue);
|
||||
}
|
||||
|
||||
function parseCountryReferences(
|
||||
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
|
||||
): { countries: ParsedCountryReference[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const countries: ParsedCountryReference[] = [];
|
||||
const startRow = findSectionRow(rows, "Country/Territory") + 1;
|
||||
|
||||
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const firstCell = normalizeText(row[0]);
|
||||
if (!firstCell) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTerminalSectionName(firstCell, ["Org Unit Level 5"])) {
|
||||
break;
|
||||
}
|
||||
|
||||
const config = getCountryReferenceConfig(firstCell);
|
||||
if (!config) {
|
||||
warnings.push(`Unsupported country reference "${firstCell}" in EID-Attr row ${rowNumber}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metroCities = row
|
||||
.slice(1)
|
||||
.map((value) => normalizeNullableWorkbookValue(value))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
countries.push({
|
||||
sourceRow: rowNumber,
|
||||
countryCode: config.code,
|
||||
name: firstCell,
|
||||
dailyWorkingHours: config.dailyWorkingHours,
|
||||
metroCities,
|
||||
...("scheduleRules" in config ? { scheduleRules: config.scheduleRules } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return { countries, warnings };
|
||||
}
|
||||
|
||||
function parseOrgUnitReferences(
|
||||
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
|
||||
): { orgUnits: ParsedOrgUnitReference[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const orgUnits: ParsedOrgUnitReference[] = [];
|
||||
|
||||
const levelFiveHeaderRow = findSectionRow(rows, "Org Unit Level 5");
|
||||
const levelSixHeaderRow = findSectionRow(rows, "Org Unit Level 6");
|
||||
const managementLevelRow = findSectionRow(rows, "Management Level Group");
|
||||
|
||||
for (let rowNumber = levelFiveHeaderRow + 1; rowNumber < levelSixHeaderRow; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const levelFiveName = normalizeNullableWorkbookValue(row[0]);
|
||||
if (!levelFiveName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secondCell = normalizeText(row[1]);
|
||||
if (secondCell?.includes("wird nicht mehr benötigt")) {
|
||||
warnings.push(`Ignored deprecated org unit row "${levelFiveName}" in EID-Attr row ${rowNumber}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
orgUnits.push({
|
||||
sourceRow: rowNumber,
|
||||
level: 5,
|
||||
name: levelFiveName,
|
||||
parentName: null,
|
||||
sortOrder: orgUnits.filter((entry) => entry.level === 5).length + 1,
|
||||
});
|
||||
|
||||
row
|
||||
.slice(1)
|
||||
.map((value) => normalizeNullableWorkbookValue(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.forEach((levelSixName, index) => {
|
||||
orgUnits.push({
|
||||
sourceRow: rowNumber,
|
||||
level: 6,
|
||||
name: levelSixName,
|
||||
parentName: levelFiveName,
|
||||
sortOrder: index + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (let rowNumber = levelSixHeaderRow + 1; rowNumber < managementLevelRow; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const levelSixName = normalizeNullableWorkbookValue(row[0]);
|
||||
if (!levelSixName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
row
|
||||
.slice(1)
|
||||
.map((value) => normalizeNullableWorkbookValue(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.forEach((levelSevenName, index) => {
|
||||
orgUnits.push({
|
||||
sourceRow: rowNumber,
|
||||
level: 7,
|
||||
name: levelSevenName,
|
||||
parentName: levelSixName,
|
||||
sortOrder: index + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { orgUnits, warnings };
|
||||
}
|
||||
|
||||
function parseManagementLevelReferences(
|
||||
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
|
||||
): { managementLevelGroups: ParsedManagementLevelGroupReference[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const managementLevelGroups: ParsedManagementLevelGroupReference[] = [];
|
||||
const startRow = findSectionRow(rows, "Management Level Group") + 1;
|
||||
|
||||
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const groupName = normalizeNullableWorkbookValue(row[0]);
|
||||
if (!groupName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTerminalSectionName(groupName, ["FTE"])) {
|
||||
break;
|
||||
}
|
||||
|
||||
const targetPercentage = typeof row[1] === "number" ? row[1] : null;
|
||||
if (targetPercentage === null) {
|
||||
warnings.push(`Missing target percentage for management level group "${groupName}" in row ${rowNumber}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const levels = row
|
||||
.slice(2)
|
||||
.map((value) => normalizeNullableWorkbookValue(value))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
managementLevelGroups.push({
|
||||
sourceRow: rowNumber,
|
||||
name: groupName,
|
||||
targetPercentage,
|
||||
sortOrder: managementLevelGroups.length + 1,
|
||||
levels,
|
||||
});
|
||||
}
|
||||
|
||||
return { managementLevelGroups, warnings };
|
||||
}
|
||||
|
||||
function parseClientReferences(
|
||||
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
|
||||
): { clients: ParsedClientReference[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const clients: ParsedClientReference[] = [];
|
||||
const startRow = findSectionRow(rows, "WBS Master Client") + 1;
|
||||
|
||||
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const masterClientName = normalizeNullableWorkbookValue(row[0]);
|
||||
if (!masterClientName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedMasterName = sanitizeClientName(masterClientName);
|
||||
const masterClientCode = normalizeClientCode(normalizedMasterName);
|
||||
|
||||
clients.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: "A",
|
||||
clientCode: masterClientCode,
|
||||
name: normalizedMasterName,
|
||||
parentClientCode: null,
|
||||
parentName: null,
|
||||
sortOrder: clients.filter((entry) => entry.parentName === null).length + 1,
|
||||
});
|
||||
|
||||
row
|
||||
.slice(1)
|
||||
.map((value) => normalizeNullableWorkbookValue(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.forEach((childName, index) => {
|
||||
clients.push({
|
||||
sourceRow: rowNumber,
|
||||
sourceColumn: toColumnLetter(index + 2),
|
||||
clientCode: null,
|
||||
name: sanitizeClientName(childName),
|
||||
parentClientCode: masterClientCode,
|
||||
parentName: normalizedMasterName,
|
||||
sortOrder: index + 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { clients, warnings };
|
||||
}
|
||||
|
||||
export async function parseMandatoryDispoReferenceWorkbook(
|
||||
workbookPath: string,
|
||||
): Promise<ParsedReferenceWorkbook> {
|
||||
const eidAttrRows = await readWorksheetMatrix(workbookPath, DISPO_REFERENCE_SHEET);
|
||||
const projectAttrRows = await readWorksheetMatrix(workbookPath, DISPO_PROJECT_REFERENCE_SHEET);
|
||||
|
||||
const countryResult = parseCountryReferences(eidAttrRows);
|
||||
const orgUnitResult = parseOrgUnitReferences(eidAttrRows);
|
||||
const managementLevelResult = parseManagementLevelReferences(eidAttrRows);
|
||||
const clientResult = parseClientReferences(projectAttrRows);
|
||||
|
||||
return {
|
||||
countries: countryResult.countries,
|
||||
orgUnits: orgUnitResult.orgUnits,
|
||||
managementLevelGroups: managementLevelResult.managementLevelGroups,
|
||||
clients: clientResult.clients,
|
||||
warnings: [
|
||||
...countryResult.warnings,
|
||||
...orgUnitResult.warnings,
|
||||
...managementLevelResult.warnings,
|
||||
...clientResult.warnings,
|
||||
],
|
||||
};
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { normalizeCanonicalResourceIdentity } from "@planarchy/shared";
|
||||
import { readWorksheetMatrix } from "./read-workbook.js";
|
||||
import { normalizeNullableWorkbookValue, normalizeText } from "./shared.js";
|
||||
|
||||
const RESOURCE_ROSTER_MASTER_SHEET = "Dispo Namen";
|
||||
|
||||
const HEADERS = {
|
||||
chapter: "Chapter",
|
||||
employeeName: "Mitarbeiter (laut Dispo)",
|
||||
experience: "Experience",
|
||||
fte: "FTE",
|
||||
lcr: "LCR (EUR)",
|
||||
level: "Level",
|
||||
location: "Location",
|
||||
status: "Status",
|
||||
typeOfWork: "Type of work",
|
||||
ucr: "UCR (EUR)",
|
||||
} as const;
|
||||
|
||||
export interface ParsedResourceRosterRate {
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
experience: string | null;
|
||||
fte: number | null;
|
||||
lcrCents: number | null;
|
||||
level: string | null;
|
||||
location: string | null;
|
||||
sourceRow: number;
|
||||
status: string | null;
|
||||
typeOfWork: string | null;
|
||||
ucrCents: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedResourceRosterLevelAverage {
|
||||
lcrCents: number | null;
|
||||
level: string;
|
||||
sampleCount: number;
|
||||
ucrCents: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedResourceRosterMasterWorkbook {
|
||||
levelAverages: Map<string, ParsedResourceRosterLevelAverage>;
|
||||
rates: Map<string, ParsedResourceRosterRate>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
|
||||
const headerMap = new Map<string, number>();
|
||||
|
||||
headerRow.forEach((value, index) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (normalized) {
|
||||
headerMap.set(normalized, index);
|
||||
}
|
||||
});
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
function getCellValue(
|
||||
row: ReadonlyArray<unknown>,
|
||||
headerMap: Map<string, number>,
|
||||
headerName: string,
|
||||
): unknown {
|
||||
const index = headerMap.get(headerName);
|
||||
if (index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return row[index] ?? null;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = normalizeNullableWorkbookValue(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized.replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toCents(value: number | null): number | null {
|
||||
return value === null ? null : Math.round(value * 100);
|
||||
}
|
||||
|
||||
export async function parseResourceRosterMasterWorkbook(
|
||||
workbookPath: string,
|
||||
): Promise<ParsedResourceRosterMasterWorkbook> {
|
||||
const rows = await readWorksheetMatrix(workbookPath, RESOURCE_ROSTER_MASTER_SHEET);
|
||||
const headerMap = buildHeaderMap(rows[0] ?? []);
|
||||
const warnings: string[] = [];
|
||||
const rates = new Map<string, ParsedResourceRosterRate>();
|
||||
const levelBuckets = new Map<string, { lcr: number[]; ucr: number[] }>();
|
||||
|
||||
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
|
||||
const row = rows[rowNumber - 1] ?? [];
|
||||
const employeeName = normalizeNullableWorkbookValue(
|
||||
getCellValue(row, headerMap, HEADERS.employeeName),
|
||||
);
|
||||
|
||||
if (!employeeName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalExternalId = normalizeCanonicalResourceIdentity(employeeName);
|
||||
const level = normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.level));
|
||||
const ucrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.ucr));
|
||||
const lcrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.lcr));
|
||||
const recordWarnings: string[] = [];
|
||||
|
||||
if (rates.has(canonicalExternalId)) {
|
||||
recordWarnings.push(`Duplicate rate row ${rowNumber} ignored for ${canonicalExternalId}`);
|
||||
warnings.push(recordWarnings[0] ?? `Duplicate rate row ${rowNumber} ignored`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ucrValue === null || lcrValue === null) {
|
||||
recordWarnings.push(`Incomplete rate row for ${canonicalExternalId}`);
|
||||
}
|
||||
|
||||
rates.set(canonicalExternalId, {
|
||||
canonicalExternalId,
|
||||
sourceRow: rowNumber,
|
||||
ucrCents: toCents(ucrValue),
|
||||
lcrCents: toCents(lcrValue),
|
||||
fte: parseOptionalNumber(getCellValue(row, headerMap, HEADERS.fte)),
|
||||
level,
|
||||
typeOfWork: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.typeOfWork)),
|
||||
chapter: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.chapter)),
|
||||
location: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.location)),
|
||||
status: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.status)),
|
||||
experience: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.experience)),
|
||||
warnings: recordWarnings,
|
||||
});
|
||||
|
||||
if (level) {
|
||||
const bucket = levelBuckets.get(level) ?? { lcr: [], ucr: [] };
|
||||
if (lcrValue !== null) {
|
||||
bucket.lcr.push(lcrValue);
|
||||
}
|
||||
if (ucrValue !== null) {
|
||||
bucket.ucr.push(ucrValue);
|
||||
}
|
||||
levelBuckets.set(level, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
const levelAverages = new Map<string, ParsedResourceRosterLevelAverage>();
|
||||
for (const [level, bucket] of levelBuckets.entries()) {
|
||||
const lcrAverage =
|
||||
bucket.lcr.length > 0
|
||||
? Math.round((bucket.lcr.reduce((sum, value) => sum + value, 0) / bucket.lcr.length) * 100)
|
||||
: null;
|
||||
const ucrAverage =
|
||||
bucket.ucr.length > 0
|
||||
? Math.round((bucket.ucr.reduce((sum, value) => sum + value, 0) / bucket.ucr.length) * 100)
|
||||
: null;
|
||||
|
||||
levelAverages.set(level, {
|
||||
level,
|
||||
sampleCount: Math.max(bucket.lcr.length, bucket.ucr.length),
|
||||
lcrCents: lcrAverage,
|
||||
ucrCents: ucrAverage,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rates,
|
||||
levelAverages,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export type WorksheetCellValue = boolean | Date | number | string | null;
|
||||
export type WorksheetMatrix = WorksheetCellValue[][];
|
||||
|
||||
function normalizeWorksheetCellValue(value: unknown): WorksheetCellValue {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export async function readWorksheetMatrix(
|
||||
workbookPath: string,
|
||||
sheetName: string,
|
||||
): Promise<WorksheetMatrix> {
|
||||
const workbook = XLSX.readFile(workbookPath, {
|
||||
cellDates: true,
|
||||
dense: true,
|
||||
});
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
if (!worksheet) {
|
||||
throw new Error(`Worksheet "${sheetName}" not found in workbook "${workbookPath}"`);
|
||||
}
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json<(WorksheetCellValue | null)[]>(worksheet, {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
});
|
||||
|
||||
return rows.map((row) => row.map((value) => normalizeWorksheetCellValue(value)));
|
||||
}
|
||||
|
||||
export function getCellString(
|
||||
rows: WorksheetMatrix,
|
||||
rowNumber: number,
|
||||
columnNumber: number,
|
||||
): string | null {
|
||||
const value = rows[rowNumber - 1]?.[columnNumber - 1];
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function toColumnLetter(columnNumber: number): string {
|
||||
let current = columnNumber;
|
||||
let result = "";
|
||||
|
||||
while (current > 0) {
|
||||
const remainder = (current - 1) % 26;
|
||||
result = String.fromCharCode(65 + remainder) + result;
|
||||
current = Math.floor((current - 1) / 26);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import path from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import {
|
||||
DispoImportSourceKind,
|
||||
DispoStagedRecordType,
|
||||
ImportBatchStatus,
|
||||
ResourceType,
|
||||
StagedRecordStatus,
|
||||
} from "@planarchy/db";
|
||||
import {
|
||||
createWeekdayAvailabilityFromFte,
|
||||
normalizeCanonicalResourceIdentity,
|
||||
normalizeDispoChapterToken,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export type DispoImportDbClient = Pick<
|
||||
PrismaClient,
|
||||
| "client"
|
||||
| "country"
|
||||
| "importBatch"
|
||||
| "managementLevel"
|
||||
| "managementLevelGroup"
|
||||
| "metroCity"
|
||||
| "orgUnit"
|
||||
| "stagedAssignment"
|
||||
| "stagedAvailabilityRule"
|
||||
| "stagedClient"
|
||||
| "stagedProject"
|
||||
| "stagedResource"
|
||||
| "stagedVacation"
|
||||
| "stagedUnresolvedRecord"
|
||||
>;
|
||||
|
||||
export interface DispoReferenceImportInput {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
referenceWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface DispoChargeabilityImportInput {
|
||||
chargeabilityWorkbookPath: string;
|
||||
excludedResourceExternalIds?: string[];
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface DispoRosterImportInput {
|
||||
costWorkbookPath?: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
rosterWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface DispoPlanningImportInput {
|
||||
excludedResourceExternalIds?: string[];
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface ParsedCountryReference {
|
||||
sourceRow: number;
|
||||
countryCode: string;
|
||||
name: string;
|
||||
dailyWorkingHours: number;
|
||||
metroCities: string[];
|
||||
scheduleRules?: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export interface ParsedOrgUnitReference {
|
||||
sourceRow: number;
|
||||
level: number;
|
||||
name: string;
|
||||
parentName: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ParsedManagementLevelGroupReference {
|
||||
sourceRow: number;
|
||||
name: string;
|
||||
targetPercentage: number;
|
||||
sortOrder: number;
|
||||
levels: string[];
|
||||
}
|
||||
|
||||
export interface ParsedClientReference {
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
clientCode: string | null;
|
||||
name: string;
|
||||
parentClientCode: string | null;
|
||||
parentName: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ParsedReferenceWorkbook {
|
||||
clients: ParsedClientReference[];
|
||||
countries: ParsedCountryReference[];
|
||||
managementLevelGroups: ParsedManagementLevelGroupReference[];
|
||||
orgUnits: ParsedOrgUnitReference[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedChargeabilityResource {
|
||||
availability: Prisma.InputJsonValue;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chapterCode: string | null;
|
||||
chargeabilityTarget: number | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
email: string | null;
|
||||
enterpriseId: string;
|
||||
fte: number | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawResourceType: string | null;
|
||||
resourceType: ResourceType;
|
||||
roleTokens: string[];
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedUnresolvedRecord {
|
||||
message: string;
|
||||
normalizedData: Record<string, unknown>;
|
||||
projectKey?: string | null;
|
||||
recordType: DispoStagedRecordType;
|
||||
resolutionHint?: string | null;
|
||||
resourceExternalId?: string | null;
|
||||
sourceColumn?: string | null;
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedChargeabilityWorkbook {
|
||||
resources: ParsedChargeabilityResource[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedRosterResource {
|
||||
availability: Prisma.InputJsonValue;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chapterCode: string | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
dailyWorkingHoursPerFte: number | null;
|
||||
department: string | null;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
email: string | null;
|
||||
enterpriseId: string;
|
||||
firstDayInDispo: Date | null;
|
||||
fte: number | null;
|
||||
lastDayInDispo: Date | null;
|
||||
lcrCents: number | null;
|
||||
mainSkillset: string | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawResourceType: string | null;
|
||||
resourceHoursPerWeek: number | null;
|
||||
resourceType: ResourceType;
|
||||
rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING";
|
||||
rateResolutionLevel: string | null;
|
||||
roleTokens: string[];
|
||||
sapEmployeeName: string | null;
|
||||
sapOrgUnitLevelFive: string | null;
|
||||
sapOrgUnitLevelSix: string | null;
|
||||
sapOrgUnitLevelSeven: string | null;
|
||||
sourceRow: number;
|
||||
sourceSheet: string;
|
||||
ucrCents: number | null;
|
||||
vacationDaysPerYear: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedRosterWorkbook {
|
||||
excludedCanonicalExternalIds: string[];
|
||||
ignoredPseudoDemandRows: number;
|
||||
resources: ParsedRosterResource[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningAssignment {
|
||||
assignmentDate: Date;
|
||||
chapterToken: string | null;
|
||||
hoursPerDay: number;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
isUnassigned: boolean;
|
||||
percentage: number;
|
||||
projectKey: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
roleName: string | null;
|
||||
roleToken: string | null;
|
||||
slotFraction: number;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
utilizationCategoryCode: string | null;
|
||||
warnings: string[];
|
||||
winProbability: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedPlanningVacation {
|
||||
endDate: Date;
|
||||
halfDayPart: string | null;
|
||||
holidayName: string | null;
|
||||
isHalfDay: boolean;
|
||||
isPublicHoliday: boolean;
|
||||
note: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
startDate: Date;
|
||||
vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK";
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningAvailabilityRule {
|
||||
availableHours: number | null;
|
||||
effectiveEndDate: Date | null;
|
||||
effectiveStartDate: Date | null;
|
||||
isResolved: boolean;
|
||||
percentage: number | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
ruleType: string;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningWorkbook {
|
||||
assignments: ParsedPlanningAssignment[];
|
||||
availabilityRules: ParsedPlanningAvailabilityRule[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
vacations: ParsedPlanningVacation[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const COUNTRY_REFERENCE_CONFIG = {
|
||||
"Costa Rica": {
|
||||
code: "CR",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Germany: {
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Hungary: {
|
||||
code: "HU",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
India: {
|
||||
code: "IN",
|
||||
dailyWorkingHours: 9,
|
||||
},
|
||||
Italy: {
|
||||
code: "IT",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Portugal: {
|
||||
code: "PT",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Spain: {
|
||||
code: "ES",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
},
|
||||
},
|
||||
"United Kingdom": {
|
||||
code: "GB",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{
|
||||
code: string;
|
||||
dailyWorkingHours: number;
|
||||
scheduleRules?: Prisma.InputJsonValue;
|
||||
}
|
||||
>;
|
||||
|
||||
const CLIENT_CODE_OVERRIDES = {
|
||||
BMW: "BMW",
|
||||
DAIMLER: "DAIMLER",
|
||||
"EXOR-STELLANTIS": "STELLANTIS",
|
||||
VOLKSWAGEN: "VW",
|
||||
"TATA MOTORS GROUP": "JLR",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]);
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = collapseWhitespace(String(value));
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeNullableWorkbookValue(value: unknown): string | null {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NULLISH_TOKENS.has(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
export function buildFallbackAccentureEmail(canonicalExternalId: string): string {
|
||||
return `${canonicalExternalId}@accenture.com`;
|
||||
}
|
||||
|
||||
export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean {
|
||||
return typeof value === "string" && value.toLowerCase().startsWith("demand_");
|
||||
}
|
||||
|
||||
export function sanitizeClientName(value: string): string {
|
||||
return collapseWhitespace(value.replace(/\s*-\s*$/, ""));
|
||||
}
|
||||
|
||||
export function getWorkbookFileName(workbookPath: string): string {
|
||||
return path.basename(workbookPath);
|
||||
}
|
||||
|
||||
export function findSectionRow(
|
||||
rows: ReadonlyArray<ReadonlyArray<unknown>>,
|
||||
firstCellValue: string,
|
||||
): number {
|
||||
const normalizedTarget = firstCellValue.toLowerCase();
|
||||
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const current = normalizeText(rows[index]?.[0]);
|
||||
if (current?.toLowerCase() === normalizedTarget) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Section row "${firstCellValue}" not found`);
|
||||
}
|
||||
|
||||
export function getCountryReferenceConfig(countryName: string) {
|
||||
return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null;
|
||||
}
|
||||
|
||||
export function deriveCountryCodeFromMetroCity(
|
||||
metroCityName: string | null | undefined,
|
||||
): string | null {
|
||||
if (!metroCityName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) {
|
||||
if (metroCityName === countryName) {
|
||||
return config.code;
|
||||
}
|
||||
|
||||
const isGermanCity =
|
||||
countryName === "Germany" &&
|
||||
["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName);
|
||||
const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon";
|
||||
const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham";
|
||||
const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica";
|
||||
|
||||
if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) {
|
||||
return config.code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeClientCode(masterClientName: string): string | null {
|
||||
return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null;
|
||||
}
|
||||
|
||||
export function ensurePercentageValue(value: number | null): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value <= 1 ? Math.round(value * 10000) / 100 : value;
|
||||
}
|
||||
|
||||
export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string {
|
||||
return enterpriseId
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function deriveRoleTokens(...values: Array<string | null | undefined>): string[] {
|
||||
const tokenSet = new Set<string>();
|
||||
const combinedValue = values
|
||||
.map((value) => normalizeText(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
|
||||
if (combinedValue.includes("2D")) {
|
||||
tokenSet.add("2D");
|
||||
}
|
||||
if (combinedValue.includes("3D")) {
|
||||
tokenSet.add("3D");
|
||||
}
|
||||
if (combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT")) {
|
||||
tokenSet.add("PM");
|
||||
}
|
||||
if (combinedValue.includes("ART DIRECTION")) {
|
||||
tokenSet.add("AD");
|
||||
}
|
||||
|
||||
return Array.from(tokenSet);
|
||||
}
|
||||
|
||||
export function deriveNormalizedChapter(
|
||||
rawChapter: string | null,
|
||||
roleTokens: string[],
|
||||
): { chapter: string | null; chapterCode: string | null } {
|
||||
const firstRoleToken = roleTokens[0] ?? null;
|
||||
if (firstRoleToken) {
|
||||
const normalizedChapter = normalizeDispoChapterToken(firstRoleToken);
|
||||
if (normalizedChapter) {
|
||||
return {
|
||||
chapter: normalizedChapter,
|
||||
chapterCode: firstRoleToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chapter: rawChapter,
|
||||
chapterCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapChargeabilityResourceType(rawValue: string | null): {
|
||||
resourceType: ResourceType;
|
||||
warning: string | null;
|
||||
} {
|
||||
if (!rawValue) {
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedValue = rawValue.toLowerCase();
|
||||
if (normalizedValue.includes("freelancer")) {
|
||||
return { resourceType: ResourceType.FREELANCER, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("intern")) {
|
||||
return { resourceType: ResourceType.INTERN, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("student")) {
|
||||
return { resourceType: ResourceType.STUDENT, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("apprentice")) {
|
||||
return { resourceType: ResourceType.APPRENTICE, warning: null };
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedValue === "production studios" ||
|
||||
normalizedValue === "near&offshore" ||
|
||||
normalizedValue === "accenture" ||
|
||||
normalizedValue === "long-term absence"
|
||||
) {
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue {
|
||||
return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export function buildBatchSummaryEntry(summary: Record<string, unknown>): Prisma.InputJsonValue {
|
||||
return summary as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export async function ensureImportBatch(
|
||||
db: Pick<PrismaClient, "importBatch">,
|
||||
input: {
|
||||
chargeabilitySourceFile?: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningSourceFile?: string;
|
||||
referenceSourceFile?: string;
|
||||
},
|
||||
): Promise<{ id: string; summary: Record<string, unknown> }> {
|
||||
if (input.importBatchId) {
|
||||
const existing = await db.importBatch.findUnique({
|
||||
where: { id: input.importBatchId },
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||
}
|
||||
|
||||
const updated = await db.importBatch.update({
|
||||
where: { id: input.importBatchId },
|
||||
data: {
|
||||
status: ImportBatchStatus.STAGING,
|
||||
...(input.referenceSourceFile !== undefined
|
||||
? { referenceSourceFile: input.referenceSourceFile }
|
||||
: {}),
|
||||
...(input.chargeabilitySourceFile !== undefined
|
||||
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
|
||||
: {}),
|
||||
...(input.planningSourceFile !== undefined
|
||||
? { planningSourceFile: input.planningSourceFile }
|
||||
: {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
startedAt: new Date(),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
summary: toJsonObject(updated.summary),
|
||||
};
|
||||
}
|
||||
|
||||
const created = await db.importBatch.create({
|
||||
data: {
|
||||
sourceSystem: "DISPO_V2",
|
||||
status: ImportBatchStatus.STAGING,
|
||||
...(input.referenceSourceFile !== undefined
|
||||
? { referenceSourceFile: input.referenceSourceFile }
|
||||
: {}),
|
||||
...(input.chargeabilitySourceFile !== undefined
|
||||
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
|
||||
: {}),
|
||||
...(input.planningSourceFile !== undefined
|
||||
? { planningSourceFile: input.planningSourceFile }
|
||||
: {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
startedAt: new Date(),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
summary: toJsonObject(created.summary),
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalizeImportBatchStage(
|
||||
db: Pick<PrismaClient, "importBatch">,
|
||||
input: {
|
||||
batchId: string;
|
||||
existingSummary: Record<string, unknown>;
|
||||
key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster";
|
||||
summary: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const nextSummary = {
|
||||
...input.existingSummary,
|
||||
[input.key]: input.summary,
|
||||
};
|
||||
|
||||
await db.importBatch.update({
|
||||
where: { id: input.batchId },
|
||||
data: {
|
||||
status: ImportBatchStatus.STAGED,
|
||||
stagedAt: new Date(),
|
||||
summary: buildBatchSummaryEntry(nextSummary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null {
|
||||
return value ? normalizeCanonicalResourceIdentity(value) : null;
|
||||
}
|
||||
|
||||
export function createSourceTrace(
|
||||
sourceKind: DispoImportSourceKind,
|
||||
sourceWorkbook: string,
|
||||
sourceSheet: string,
|
||||
sourceRow: number,
|
||||
sourceColumn?: string | null,
|
||||
) {
|
||||
return {
|
||||
sourceKind,
|
||||
sourceWorkbook,
|
||||
sourceSheet,
|
||||
sourceRow,
|
||||
...(sourceColumn !== undefined ? { sourceColumn } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const DISPO_REFERENCE_SHEET = "EID-Attr";
|
||||
export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr";
|
||||
export const DISPO_CHARGEABILITY_SHEET = "ChgFC";
|
||||
export const DISPO_PLANNING_SHEET = "Dispo";
|
||||
export const DISPO_ROSTER_SHEET = "DispoRoster";
|
||||
export const DISPO_ROSTER_SAP_SHEET = "SAP_data";
|
||||
|
||||
export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
|
||||
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
|
||||
import { ensureImportBatch, finalizeImportBatchStage, getWorkbookFileName, type DispoChargeabilityImportInput, type DispoImportDbClient } from "./shared.js";
|
||||
|
||||
export interface StageDispoChargeabilityResourcesResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
stagedResources: number;
|
||||
unresolved: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageDispoChargeabilityResources(
|
||||
db: DispoImportDbClient,
|
||||
input: DispoChargeabilityImportInput,
|
||||
): Promise<StageDispoChargeabilityResourcesResult> {
|
||||
const batchInput: {
|
||||
chargeabilitySourceFile: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
} = {
|
||||
chargeabilitySourceFile: getWorkbookFileName(input.chargeabilityWorkbookPath),
|
||||
};
|
||||
|
||||
if (input.importBatchId !== undefined) {
|
||||
batchInput.importBatchId = input.importBatchId;
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
batchInput.notes = input.notes;
|
||||
}
|
||||
|
||||
const batch = await ensureImportBatch(db, batchInput);
|
||||
const parsed = await parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath);
|
||||
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
|
||||
const filteredResources = parsed.resources.filter(
|
||||
(resource) => !excludedIds.has(resource.canonicalExternalId),
|
||||
);
|
||||
const filteredUnresolved = parsed.unresolved.filter(
|
||||
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
|
||||
);
|
||||
const sourceWorkbook = getWorkbookFileName(input.chargeabilityWorkbookPath);
|
||||
|
||||
await db.stagedResource.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.CHARGEABILITY,
|
||||
},
|
||||
});
|
||||
|
||||
if (filteredResources.length > 0) {
|
||||
await db.stagedResource.createMany({
|
||||
data: filteredResources.map((resource) => ({
|
||||
importBatchId: batch.id,
|
||||
status: resource.warnings.length > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.CHARGEABILITY,
|
||||
sourceWorkbook,
|
||||
sourceSheet: "ChgFC",
|
||||
sourceRow: resource.sourceRow,
|
||||
canonicalExternalId: resource.canonicalExternalId,
|
||||
enterpriseId: resource.enterpriseId,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
email: resource.email,
|
||||
chapter: resource.chapter,
|
||||
chapterCode: resource.chapterCode,
|
||||
managementLevelGroupName: resource.managementLevelGroupName,
|
||||
managementLevelName: resource.managementLevelName,
|
||||
countryCode: resource.countryCode,
|
||||
metroCityName: resource.metroCityName,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
resourceType: resource.resourceType,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
fte: resource.fte,
|
||||
availability: resource.availability,
|
||||
roleTokens: resource.roleTokens,
|
||||
warnings: resource.warnings,
|
||||
rawPayload: {
|
||||
rawResourceType: resource.rawResourceType,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
chapter: resource.chapter,
|
||||
chapterCode: resource.chapterCode,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
countryCode: resource.countryCode,
|
||||
fte: resource.fte,
|
||||
managementLevelGroupName: resource.managementLevelGroupName,
|
||||
metroCityName: resource.metroCityName,
|
||||
roleTokens: resource.roleTokens,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await db.stagedUnresolvedRecord.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.CHARGEABILITY,
|
||||
},
|
||||
});
|
||||
|
||||
if (filteredUnresolved.length > 0) {
|
||||
await db.stagedUnresolvedRecord.createMany({
|
||||
data: filteredUnresolved.map((record) => ({
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.UNRESOLVED,
|
||||
sourceKind: DispoImportSourceKind.CHARGEABILITY,
|
||||
sourceWorkbook,
|
||||
sourceSheet: "ChgFC",
|
||||
sourceRow: record.sourceRow,
|
||||
sourceColumn: record.sourceColumn ?? null,
|
||||
recordType: record.recordType,
|
||||
resourceExternalId: record.resourceExternalId ?? null,
|
||||
projectKey: record.projectKey ?? null,
|
||||
message: record.message,
|
||||
resolutionHint: record.resolutionHint ?? null,
|
||||
warnings: record.warnings,
|
||||
rawPayload: {} as Prisma.InputJsonValue,
|
||||
normalizedData: record.normalizedData as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const warningCount =
|
||||
parsed.warnings.length +
|
||||
filteredResources.reduce((count, resource) => count + resource.warnings.length, 0);
|
||||
const summary = {
|
||||
stagedResources: filteredResources.length,
|
||||
unresolved: filteredUnresolved.length,
|
||||
warnings: warningCount,
|
||||
};
|
||||
|
||||
await finalizeImportBatchStage(db, {
|
||||
batchId: batch.id,
|
||||
existingSummary: batch.summary,
|
||||
key: "chargeability",
|
||||
summary,
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
assessDispoImportReadiness,
|
||||
persistDispoImportReadiness,
|
||||
type DispoImportReadinessReport,
|
||||
} from "./assess-import-readiness.js";
|
||||
import { type DispoImportDbClient } from "./shared.js";
|
||||
import { stageDispoChargeabilityResources } from "./stage-chargeability-resources.js";
|
||||
import { stageDispoPlanningData } from "./stage-dispo-planning.js";
|
||||
import { stageDispoProjects } from "./stage-dispo-projects.js";
|
||||
import { stageDispoRosterResources } from "./stage-dispo-roster-resources.js";
|
||||
import { stageDispoReferenceData } from "./stage-reference-data.js";
|
||||
|
||||
export interface StageDispoImportBatchInput {
|
||||
chargeabilityWorkbookPath: string;
|
||||
costWorkbookPath?: string;
|
||||
notes?: string | null;
|
||||
planningWorkbookPath: string;
|
||||
referenceWorkbookPath: string;
|
||||
rosterWorkbookPath?: string;
|
||||
}
|
||||
|
||||
export interface StageDispoImportBatchResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
stagedAssignments: number;
|
||||
stagedAvailabilityRules: number;
|
||||
stagedClients: number;
|
||||
stagedProjects: number;
|
||||
stagedResources: number;
|
||||
stagedRosterResources: number;
|
||||
stagedVacations: number;
|
||||
unresolved: number;
|
||||
};
|
||||
readiness: DispoImportReadinessReport;
|
||||
}
|
||||
|
||||
export async function stageDispoImportBatch(
|
||||
db: DispoImportDbClient,
|
||||
input: StageDispoImportBatchInput,
|
||||
): Promise<StageDispoImportBatchResult> {
|
||||
const referenceResult = await stageDispoReferenceData(db, {
|
||||
referenceWorkbookPath: input.referenceWorkbookPath,
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
});
|
||||
|
||||
const batchId = referenceResult.batchId;
|
||||
|
||||
const rosterResult = input.rosterWorkbookPath
|
||||
? await stageDispoRosterResources(db, {
|
||||
importBatchId: batchId,
|
||||
rosterWorkbookPath: input.rosterWorkbookPath,
|
||||
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
})
|
||||
: { counts: { stagedResources: 0, unresolved: 0, warnings: 0, ignoredPseudoDemandRows: 0, excludedResources: 0 }, excludedCanonicalExternalIds: [] };
|
||||
|
||||
const chargeabilityResult = await stageDispoChargeabilityResources(db, {
|
||||
importBatchId: batchId,
|
||||
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
|
||||
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
});
|
||||
|
||||
const planningResult = await stageDispoPlanningData(db, {
|
||||
importBatchId: batchId,
|
||||
planningWorkbookPath: input.planningWorkbookPath,
|
||||
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
});
|
||||
|
||||
const projectResult = await stageDispoProjects(db, {
|
||||
importBatchId: batchId,
|
||||
planningWorkbookPath: input.planningWorkbookPath,
|
||||
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
});
|
||||
|
||||
const readiness = await persistDispoImportReadiness(db, {
|
||||
importBatchId: batchId,
|
||||
referenceWorkbookPath: input.referenceWorkbookPath,
|
||||
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
|
||||
planningWorkbookPath: input.planningWorkbookPath,
|
||||
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
...(input.rosterWorkbookPath ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
batchId,
|
||||
counts: {
|
||||
stagedClients: referenceResult.counts.stagedClients,
|
||||
stagedResources:
|
||||
chargeabilityResult.counts.stagedResources + (rosterResult?.counts.stagedResources ?? 0),
|
||||
stagedRosterResources: rosterResult?.counts.stagedResources ?? 0,
|
||||
stagedProjects: projectResult.counts.stagedProjects,
|
||||
stagedAssignments: planningResult.counts.stagedAssignments,
|
||||
stagedVacations: planningResult.counts.stagedVacations,
|
||||
stagedAvailabilityRules: planningResult.counts.stagedAvailabilityRules,
|
||||
unresolved:
|
||||
chargeabilityResult.counts.unresolved +
|
||||
planningResult.counts.unresolved +
|
||||
(rosterResult?.counts.unresolved ?? 0),
|
||||
},
|
||||
readiness,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
|
||||
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
|
||||
import {
|
||||
DISPO_PLANNING_SHEET,
|
||||
ensureImportBatch,
|
||||
finalizeImportBatchStage,
|
||||
getWorkbookFileName,
|
||||
isPseudoDemandResourceIdentity,
|
||||
type DispoImportDbClient,
|
||||
type DispoPlanningImportInput,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface StageDispoPlanningResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
stagedAssignments: number;
|
||||
stagedAvailabilityRules: number;
|
||||
stagedVacations: number;
|
||||
unresolved: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageDispoPlanningData(
|
||||
db: DispoImportDbClient,
|
||||
input: DispoPlanningImportInput,
|
||||
): Promise<StageDispoPlanningResult> {
|
||||
const batchInput: {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningSourceFile: string;
|
||||
} = {
|
||||
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
|
||||
};
|
||||
|
||||
if (input.importBatchId !== undefined) {
|
||||
batchInput.importBatchId = input.importBatchId;
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
batchInput.notes = input.notes;
|
||||
}
|
||||
|
||||
const batch = await ensureImportBatch(db, batchInput);
|
||||
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
|
||||
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
|
||||
const filteredAssignments = parsed.assignments.filter(
|
||||
(assignment) =>
|
||||
!excludedIds.has(assignment.resourceExternalId) &&
|
||||
!isPseudoDemandResourceIdentity(assignment.resourceExternalId),
|
||||
);
|
||||
const filteredVacations = parsed.vacations.filter(
|
||||
(vacation) =>
|
||||
!excludedIds.has(vacation.resourceExternalId) &&
|
||||
!isPseudoDemandResourceIdentity(vacation.resourceExternalId),
|
||||
);
|
||||
const filteredAvailabilityRules = parsed.availabilityRules.filter(
|
||||
(rule) =>
|
||||
!excludedIds.has(rule.resourceExternalId) &&
|
||||
!isPseudoDemandResourceIdentity(rule.resourceExternalId),
|
||||
);
|
||||
const filteredUnresolved = parsed.unresolved.filter(
|
||||
(record) =>
|
||||
!record.resourceExternalId ||
|
||||
(!excludedIds.has(record.resourceExternalId) &&
|
||||
!isPseudoDemandResourceIdentity(record.resourceExternalId)),
|
||||
);
|
||||
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
|
||||
|
||||
await db.stagedAssignment.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
},
|
||||
});
|
||||
await db.stagedVacation.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
},
|
||||
});
|
||||
await db.stagedAvailabilityRule.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
},
|
||||
});
|
||||
await db.stagedUnresolvedRecord.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
},
|
||||
});
|
||||
|
||||
if (filteredAssignments.length > 0) {
|
||||
await db.stagedAssignment.createMany({
|
||||
data: filteredAssignments.map((assignment) => ({
|
||||
importBatchId: batch.id,
|
||||
status: assignment.warnings.length > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
sourceWorkbook,
|
||||
sourceSheet: DISPO_PLANNING_SHEET,
|
||||
sourceRow: assignment.sourceRow,
|
||||
sourceColumn: assignment.sourceColumn,
|
||||
resourceExternalId: assignment.resourceExternalId,
|
||||
projectKey: assignment.projectKey,
|
||||
assignmentDate: assignment.assignmentDate,
|
||||
startDate: assignment.assignmentDate,
|
||||
endDate: assignment.assignmentDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
percentage: assignment.percentage,
|
||||
slotFraction: assignment.slotFraction,
|
||||
roleToken: assignment.roleToken,
|
||||
roleName: assignment.roleName,
|
||||
chapterToken: assignment.chapterToken,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
winProbability: assignment.winProbability,
|
||||
isInternal: assignment.isInternal,
|
||||
isUnassigned: assignment.isUnassigned,
|
||||
isTbd: assignment.isTbd,
|
||||
warnings: assignment.warnings,
|
||||
rawPayload: {
|
||||
rawToken: assignment.rawToken,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
assignmentDate: assignment.assignmentDate.toISOString().slice(0, 10),
|
||||
chapterToken: assignment.chapterToken,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
percentage: assignment.percentage,
|
||||
roleToken: assignment.roleToken,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
winProbability: assignment.winProbability,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredVacations.length > 0) {
|
||||
await db.stagedVacation.createMany({
|
||||
data: filteredVacations.map((vacation) => ({
|
||||
importBatchId: batch.id,
|
||||
status: vacation.warnings.length > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
sourceWorkbook,
|
||||
sourceSheet: DISPO_PLANNING_SHEET,
|
||||
sourceRow: vacation.sourceRow,
|
||||
sourceColumn: vacation.sourceColumn,
|
||||
resourceExternalId: vacation.resourceExternalId,
|
||||
vacationType: vacation.vacationType,
|
||||
startDate: vacation.startDate,
|
||||
endDate: vacation.endDate,
|
||||
note: vacation.note,
|
||||
holidayName: vacation.holidayName,
|
||||
isHalfDay: vacation.isHalfDay,
|
||||
halfDayPart: vacation.halfDayPart,
|
||||
isPublicHoliday: vacation.isPublicHoliday,
|
||||
warnings: vacation.warnings,
|
||||
rawPayload: {
|
||||
rawToken: vacation.rawToken,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
holidayName: vacation.holidayName,
|
||||
isHalfDay: vacation.isHalfDay,
|
||||
isPublicHoliday: vacation.isPublicHoliday,
|
||||
note: vacation.note,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredAvailabilityRules.length > 0) {
|
||||
await db.stagedAvailabilityRule.createMany({
|
||||
data: filteredAvailabilityRules.map((rule) => ({
|
||||
importBatchId: batch.id,
|
||||
status: rule.warnings.length > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
sourceWorkbook,
|
||||
sourceSheet: DISPO_PLANNING_SHEET,
|
||||
sourceRow: rule.sourceRow,
|
||||
sourceColumn: rule.sourceColumn,
|
||||
resourceExternalId: rule.resourceExternalId,
|
||||
ruleType: rule.ruleType,
|
||||
weekday: null,
|
||||
effectiveStartDate: rule.effectiveStartDate,
|
||||
effectiveEndDate: rule.effectiveEndDate,
|
||||
availableHours: rule.availableHours,
|
||||
percentage: rule.percentage,
|
||||
isResolved: rule.isResolved,
|
||||
warnings: rule.warnings,
|
||||
rawPayload: {
|
||||
rawToken: rule.rawToken,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
availableHours: rule.availableHours,
|
||||
percentage: rule.percentage,
|
||||
ruleType: rule.ruleType,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredUnresolved.length > 0) {
|
||||
await db.stagedUnresolvedRecord.createMany({
|
||||
data: filteredUnresolved.map((record) => ({
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.UNRESOLVED,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
sourceWorkbook,
|
||||
sourceSheet: DISPO_PLANNING_SHEET,
|
||||
sourceRow: record.sourceRow,
|
||||
sourceColumn: record.sourceColumn ?? null,
|
||||
recordType: record.recordType,
|
||||
resourceExternalId: record.resourceExternalId ?? null,
|
||||
projectKey: record.projectKey ?? null,
|
||||
message: record.message,
|
||||
resolutionHint: record.resolutionHint ?? null,
|
||||
warnings: record.warnings,
|
||||
rawPayload: {} as Prisma.InputJsonValue,
|
||||
normalizedData: record.normalizedData as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const warningCount =
|
||||
parsed.warnings.length +
|
||||
filteredAssignments.reduce((count, assignment) => count + assignment.warnings.length, 0) +
|
||||
filteredVacations.reduce((count, vacation) => count + vacation.warnings.length, 0) +
|
||||
filteredAvailabilityRules.reduce((count, rule) => count + rule.warnings.length, 0);
|
||||
const summary = {
|
||||
stagedAssignments: filteredAssignments.length,
|
||||
stagedAvailabilityRules: filteredAvailabilityRules.length,
|
||||
stagedVacations: filteredVacations.length,
|
||||
unresolved: filteredUnresolved.length,
|
||||
warnings: warningCount,
|
||||
};
|
||||
|
||||
await finalizeImportBatchStage(db, {
|
||||
batchId: batch.id,
|
||||
existingSummary: batch.summary,
|
||||
key: "planning",
|
||||
summary,
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { AllocationType, DispoImportSourceKind, OrderType, StagedRecordStatus } from "@planarchy/db";
|
||||
import { DISPO_INTERNAL_PROJECT_BUCKETS } from "@planarchy/shared";
|
||||
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
|
||||
import {
|
||||
DISPO_PLANNING_SHEET,
|
||||
ensureImportBatch,
|
||||
finalizeImportBatchStage,
|
||||
getWorkbookFileName,
|
||||
isPseudoDemandResourceIdentity,
|
||||
type DispoImportDbClient,
|
||||
type DispoPlanningImportInput,
|
||||
normalizeText,
|
||||
} from "./shared.js";
|
||||
|
||||
interface ResolvedStagedProject {
|
||||
allocationType: AllocationType;
|
||||
clientCode: string | null;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
name: string;
|
||||
orderType: OrderType;
|
||||
projectKey: string;
|
||||
rawTokens: Set<string>;
|
||||
shortCode: string;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
utilizationCategoryCode: string | null;
|
||||
warnings: Set<string>;
|
||||
winProbability: number | null;
|
||||
}
|
||||
|
||||
function extractBracketTokens(token: string): string[] {
|
||||
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
function extractClientCode(token: string): string | null {
|
||||
const candidates = extractBracketTokens(token).filter(
|
||||
(entry) =>
|
||||
entry.length > 0 &&
|
||||
!entry.startsWith("_") &&
|
||||
!/^\d+$/.test(entry) &&
|
||||
entry.toLowerCase() !== "tbd",
|
||||
);
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function deriveProjectName(token: string, fallbackProjectKey: string): string {
|
||||
const normalized = token
|
||||
.replace(/^(2D|3D|PM|AD)\s+/i, "")
|
||||
.replace(/\[[^\]]+\]/g, " ")
|
||||
.replace(/\{[^}]+\}/g, " ")
|
||||
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return normalized.length > 0 ? normalized : `Project ${fallbackProjectKey}`;
|
||||
}
|
||||
|
||||
function updateDateRange(project: ResolvedStagedProject, assignmentDate: Date) {
|
||||
if (assignmentDate < project.startDate) {
|
||||
project.startDate = assignmentDate;
|
||||
}
|
||||
if (assignmentDate > project.endDate) {
|
||||
project.endDate = assignmentDate;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StageDispoProjectsResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
stagedProjects: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageDispoProjects(
|
||||
db: DispoImportDbClient,
|
||||
input: DispoPlanningImportInput,
|
||||
): Promise<StageDispoProjectsResult> {
|
||||
const batchInput: {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningSourceFile: string;
|
||||
} = {
|
||||
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
|
||||
};
|
||||
|
||||
if (input.importBatchId !== undefined) {
|
||||
batchInput.importBatchId = input.importBatchId;
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
batchInput.notes = input.notes;
|
||||
}
|
||||
|
||||
const batch = await ensureImportBatch(db, batchInput);
|
||||
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
|
||||
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
|
||||
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
|
||||
const projects = new Map<string, ResolvedStagedProject>();
|
||||
|
||||
for (const bucket of DISPO_INTERNAL_PROJECT_BUCKETS) {
|
||||
projects.set(bucket.shortCode, {
|
||||
allocationType: AllocationType.INT,
|
||||
clientCode: null,
|
||||
isInternal: true,
|
||||
isTbd: false,
|
||||
name: bucket.name,
|
||||
orderType: OrderType.INTERNAL,
|
||||
projectKey: bucket.shortCode,
|
||||
rawTokens: new Set<string>([`{${bucket.sourceToken}}`]),
|
||||
shortCode: bucket.shortCode,
|
||||
sourceColumn: "A",
|
||||
sourceRow: 0,
|
||||
startDate: new Date("2100-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("1970-01-01T00:00:00.000Z"),
|
||||
utilizationCategoryCode: bucket.utilizationCategoryCode,
|
||||
warnings: new Set<string>(),
|
||||
winProbability: 100,
|
||||
});
|
||||
}
|
||||
|
||||
for (const assignment of parsed.assignments) {
|
||||
if (
|
||||
excludedIds.has(assignment.resourceExternalId) ||
|
||||
isPseudoDemandResourceIdentity(assignment.resourceExternalId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.isTbd || assignment.isUnassigned) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.isInternal) {
|
||||
const internalBucket = DISPO_INTERNAL_PROJECT_BUCKETS.find(
|
||||
(bucket) => assignment.rawToken.includes(`{${bucket.sourceToken}}`),
|
||||
);
|
||||
if (!internalBucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const project = projects.get(internalBucket.shortCode);
|
||||
if (!project) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updateDateRange(project, assignment.assignmentDate);
|
||||
project.rawTokens.add(assignment.rawToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!assignment.projectKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedClientCode = extractClientCode(assignment.rawToken);
|
||||
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
|
||||
const shortCode = assignment.projectKey;
|
||||
const existing = projects.get(assignment.projectKey);
|
||||
|
||||
if (!existing) {
|
||||
projects.set(assignment.projectKey, {
|
||||
allocationType: assignment.utilizationCategoryCode === "Chg"
|
||||
? AllocationType.EXT
|
||||
: AllocationType.INT,
|
||||
clientCode: derivedClientCode,
|
||||
isInternal: false,
|
||||
isTbd: false,
|
||||
name: derivedName,
|
||||
orderType: assignment.utilizationCategoryCode === "Chg"
|
||||
? OrderType.CHARGEABLE
|
||||
: assignment.utilizationCategoryCode === "BD"
|
||||
? OrderType.BD
|
||||
: OrderType.INTERNAL,
|
||||
projectKey: assignment.projectKey,
|
||||
rawTokens: new Set<string>([assignment.rawToken]),
|
||||
shortCode,
|
||||
sourceColumn: assignment.sourceColumn,
|
||||
sourceRow: assignment.sourceRow,
|
||||
startDate: assignment.assignmentDate,
|
||||
endDate: assignment.assignmentDate,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
warnings: new Set<string>(assignment.warnings),
|
||||
winProbability: assignment.winProbability,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
updateDateRange(existing, assignment.assignmentDate);
|
||||
existing.rawTokens.add(assignment.rawToken);
|
||||
|
||||
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
|
||||
existing.warnings.add(
|
||||
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existing.clientCode && derivedClientCode) {
|
||||
existing.clientCode = derivedClientCode;
|
||||
}
|
||||
|
||||
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
|
||||
existing.warnings.add(
|
||||
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existing.utilizationCategoryCode &&
|
||||
assignment.utilizationCategoryCode &&
|
||||
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
|
||||
) {
|
||||
existing.warnings.add(
|
||||
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existing.utilizationCategoryCode && assignment.utilizationCategoryCode) {
|
||||
existing.utilizationCategoryCode = assignment.utilizationCategoryCode;
|
||||
}
|
||||
|
||||
if (
|
||||
existing.winProbability !== null &&
|
||||
assignment.winProbability !== null &&
|
||||
existing.winProbability !== assignment.winProbability
|
||||
) {
|
||||
existing.warnings.add(
|
||||
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.winProbability === null && assignment.winProbability !== null) {
|
||||
existing.winProbability = assignment.winProbability;
|
||||
}
|
||||
}
|
||||
|
||||
await db.stagedProject.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
},
|
||||
});
|
||||
|
||||
const stagedProjects = Array.from(projects.values())
|
||||
.filter((project) => {
|
||||
if (!project.isInternal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return project.startDate <= project.endDate;
|
||||
})
|
||||
.map((project) => ({
|
||||
importBatchId: batch.id,
|
||||
status: project.warnings.size > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.PLANNING,
|
||||
sourceWorkbook,
|
||||
sourceSheet: DISPO_PLANNING_SHEET,
|
||||
sourceRow: project.sourceRow,
|
||||
sourceColumn: project.sourceColumn,
|
||||
projectKey: project.projectKey,
|
||||
shortCode: project.shortCode,
|
||||
name: project.name,
|
||||
clientCode: project.clientCode,
|
||||
utilizationCategoryCode: project.utilizationCategoryCode,
|
||||
orderType: project.orderType,
|
||||
allocationType: project.allocationType,
|
||||
winProbability: project.winProbability,
|
||||
isInternal: project.isInternal,
|
||||
isTbd: project.isTbd,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
warnings: Array.from(project.warnings),
|
||||
rawPayload: {
|
||||
rawTokens: Array.from(project.rawTokens),
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
clientCode: project.clientCode,
|
||||
name: project.name,
|
||||
utilizationCategoryCode: project.utilizationCategoryCode,
|
||||
winProbability: project.winProbability,
|
||||
} as Prisma.InputJsonValue,
|
||||
}));
|
||||
|
||||
if (stagedProjects.length > 0) {
|
||||
await db.stagedProject.createMany({
|
||||
data: stagedProjects,
|
||||
});
|
||||
}
|
||||
|
||||
const warningCount = stagedProjects.reduce((count, project) => count + project.warnings.length, 0);
|
||||
const summary = {
|
||||
stagedProjects: stagedProjects.length,
|
||||
warnings: warningCount,
|
||||
};
|
||||
|
||||
await finalizeImportBatchStage(db, {
|
||||
batchId: batch.id,
|
||||
existingSummary: batch.summary,
|
||||
key: "projectResolution",
|
||||
summary,
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
|
||||
import { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
|
||||
import {
|
||||
ensureImportBatch,
|
||||
finalizeImportBatchStage,
|
||||
getWorkbookFileName,
|
||||
type DispoImportDbClient,
|
||||
type DispoRosterImportInput,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface StageDispoRosterResourcesResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
excludedResources: number;
|
||||
ignoredPseudoDemandRows: number;
|
||||
stagedResources: number;
|
||||
unresolved: number;
|
||||
warnings: number;
|
||||
};
|
||||
excludedCanonicalExternalIds: string[];
|
||||
}
|
||||
|
||||
export async function stageDispoRosterResources(
|
||||
db: DispoImportDbClient,
|
||||
input: DispoRosterImportInput,
|
||||
): Promise<StageDispoRosterResourcesResult> {
|
||||
const batchInput: {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
} = {};
|
||||
if (input.importBatchId !== undefined) {
|
||||
batchInput.importBatchId = input.importBatchId;
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
batchInput.notes = input.notes;
|
||||
}
|
||||
|
||||
const batch = await ensureImportBatch(db, batchInput);
|
||||
const parsed = await parseDispoRosterWorkbook(input.rosterWorkbookPath, {
|
||||
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
});
|
||||
const sourceWorkbook = getWorkbookFileName(input.rosterWorkbookPath);
|
||||
|
||||
await db.stagedResource.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.ROSTER,
|
||||
},
|
||||
});
|
||||
|
||||
if (parsed.resources.length > 0) {
|
||||
await db.stagedResource.createMany({
|
||||
data: parsed.resources.map((resource) => ({
|
||||
importBatchId: batch.id,
|
||||
status: resource.warnings.length > 0
|
||||
? StagedRecordStatus.PARSED
|
||||
: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.ROSTER,
|
||||
sourceWorkbook,
|
||||
sourceSheet: resource.sourceSheet,
|
||||
sourceRow: resource.sourceRow,
|
||||
canonicalExternalId: resource.canonicalExternalId,
|
||||
enterpriseId: resource.enterpriseId,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
email: resource.email,
|
||||
chapter: resource.chapter,
|
||||
chapterCode: resource.chapterCode,
|
||||
managementLevelGroupName: resource.managementLevelGroupName,
|
||||
managementLevelName: resource.managementLevelName,
|
||||
countryCode: resource.countryCode,
|
||||
metroCityName: resource.metroCityName,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
resourceType: resource.resourceType,
|
||||
chargeabilityTarget: null,
|
||||
fte: resource.fte,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
availability: resource.availability,
|
||||
roleTokens: resource.roleTokens,
|
||||
warnings: resource.warnings,
|
||||
rawPayload: {
|
||||
dailyWorkingHoursPerFte: resource.dailyWorkingHoursPerFte,
|
||||
department: resource.department,
|
||||
firstDayInDispo: resource.firstDayInDispo?.toISOString() ?? null,
|
||||
lastDayInDispo: resource.lastDayInDispo?.toISOString() ?? null,
|
||||
mainSkillset: resource.mainSkillset,
|
||||
rawResourceType: resource.rawResourceType,
|
||||
resourceHoursPerWeek: resource.resourceHoursPerWeek,
|
||||
rateResolution: resource.rateResolution,
|
||||
rateResolutionLevel: resource.rateResolutionLevel,
|
||||
sapEmployeeName: resource.sapEmployeeName,
|
||||
sapOrgUnitLevelFive: resource.sapOrgUnitLevelFive,
|
||||
sapOrgUnitLevelSix: resource.sapOrgUnitLevelSix,
|
||||
sapOrgUnitLevelSeven: resource.sapOrgUnitLevelSeven,
|
||||
vacationDaysPerYear: resource.vacationDaysPerYear,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
chapter: resource.chapter,
|
||||
chapterCode: resource.chapterCode,
|
||||
clientUnitName: resource.clientUnitName,
|
||||
countryCode: resource.countryCode,
|
||||
department: resource.department,
|
||||
email: resource.email,
|
||||
fte: resource.fte,
|
||||
managementLevelGroupName: resource.managementLevelGroupName,
|
||||
managementLevelName: resource.managementLevelName,
|
||||
metroCityName: resource.metroCityName,
|
||||
roleTokens: resource.roleTokens,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
rateResolution: resource.rateResolution,
|
||||
rateResolutionLevel: resource.rateResolutionLevel,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await db.stagedUnresolvedRecord.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.ROSTER,
|
||||
},
|
||||
});
|
||||
|
||||
if (parsed.unresolved.length > 0) {
|
||||
await db.stagedUnresolvedRecord.createMany({
|
||||
data: parsed.unresolved.map((record) => ({
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.UNRESOLVED,
|
||||
sourceKind: DispoImportSourceKind.ROSTER,
|
||||
sourceWorkbook,
|
||||
sourceSheet: record.sourceRow >= 3 && record.sourceColumn === "C" ? "SAP_data" : "DispoRoster",
|
||||
sourceRow: record.sourceRow,
|
||||
sourceColumn: record.sourceColumn ?? null,
|
||||
recordType: record.recordType,
|
||||
resourceExternalId: record.resourceExternalId ?? null,
|
||||
projectKey: record.projectKey ?? null,
|
||||
message: record.message,
|
||||
resolutionHint: record.resolutionHint ?? null,
|
||||
warnings: record.warnings,
|
||||
rawPayload: {} as Prisma.InputJsonValue,
|
||||
normalizedData: record.normalizedData as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const warningCount =
|
||||
parsed.warnings.length +
|
||||
parsed.resources.reduce((count, resource) => count + resource.warnings.length, 0);
|
||||
const summary = {
|
||||
stagedResources: parsed.resources.length,
|
||||
unresolved: parsed.unresolved.length,
|
||||
warnings: warningCount,
|
||||
excludedResources: parsed.excludedCanonicalExternalIds.length,
|
||||
ignoredPseudoDemandRows: parsed.ignoredPseudoDemandRows,
|
||||
};
|
||||
|
||||
await finalizeImportBatchStage(db, {
|
||||
batchId: batch.id,
|
||||
existingSummary: batch.summary,
|
||||
key: "roster",
|
||||
summary,
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: summary,
|
||||
excludedCanonicalExternalIds: parsed.excludedCanonicalExternalIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
|
||||
import { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
|
||||
import { ensureImportBatch, finalizeImportBatchStage, getWorkbookFileName, type DispoImportDbClient, type DispoReferenceImportInput, toJsonObject } from "./shared.js";
|
||||
|
||||
async function upsertRootClient(
|
||||
db: DispoImportDbClient,
|
||||
input: { clientCode: string | null; name: string; sortOrder: number },
|
||||
) {
|
||||
const existing = await db.client.findFirst({
|
||||
where: { name: input.name, parentId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return db.client.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.clientCode ? { code: input.clientCode } : {}),
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
return db.client.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
...(input.clientCode ? { code: input.clientCode } : {}),
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRootOrgUnit(
|
||||
db: DispoImportDbClient,
|
||||
input: { name: string; level: number; sortOrder: number },
|
||||
) {
|
||||
const existing = await db.orgUnit.findFirst({
|
||||
where: { name: input.name, level: input.level, parentId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return db.orgUnit.update({
|
||||
where: { id: existing.id },
|
||||
data: { sortOrder: input.sortOrder, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
return db.orgUnit.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
level: input.level,
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
export interface StageDispoReferenceDataResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
countries: number;
|
||||
managementLevelGroups: number;
|
||||
managementLevels: number;
|
||||
metroCities: number;
|
||||
orgUnits: number;
|
||||
stagedClients: number;
|
||||
unresolved: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageDispoReferenceData(
|
||||
db: DispoImportDbClient,
|
||||
input: DispoReferenceImportInput,
|
||||
): Promise<StageDispoReferenceDataResult> {
|
||||
const batchInput: {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
referenceSourceFile: string;
|
||||
} = {
|
||||
referenceSourceFile: getWorkbookFileName(input.referenceWorkbookPath),
|
||||
};
|
||||
|
||||
if (input.importBatchId !== undefined) {
|
||||
batchInput.importBatchId = input.importBatchId;
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
batchInput.notes = input.notes;
|
||||
}
|
||||
|
||||
const batch = await ensureImportBatch(db, batchInput);
|
||||
const parsed = await parseMandatoryDispoReferenceWorkbook(input.referenceWorkbookPath);
|
||||
|
||||
for (const country of parsed.countries) {
|
||||
const createdCountry = await db.country.upsert({
|
||||
where: { code: country.countryCode },
|
||||
update: {
|
||||
name: country.name,
|
||||
dailyWorkingHours: country.dailyWorkingHours,
|
||||
...(country.scheduleRules !== undefined ? { scheduleRules: country.scheduleRules } : {}),
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
code: country.countryCode,
|
||||
name: country.name,
|
||||
dailyWorkingHours: country.dailyWorkingHours,
|
||||
...(country.scheduleRules !== undefined ? { scheduleRules: country.scheduleRules } : {}),
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const metroCityName of country.metroCities) {
|
||||
await db.metroCity.upsert({
|
||||
where: {
|
||||
countryId_name: {
|
||||
countryId: createdCountry.id,
|
||||
name: metroCityName,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
countryId: createdCountry.id,
|
||||
name: metroCityName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootOrgUnits = parsed.orgUnits.filter((orgUnit) => orgUnit.parentName === null);
|
||||
const rootOrgUnitIdByName = new Map<string, string>();
|
||||
for (const rootOrgUnit of rootOrgUnits) {
|
||||
const created = await upsertRootOrgUnit(db, {
|
||||
name: rootOrgUnit.name,
|
||||
level: rootOrgUnit.level,
|
||||
sortOrder: rootOrgUnit.sortOrder,
|
||||
});
|
||||
rootOrgUnitIdByName.set(rootOrgUnit.name, created.id);
|
||||
}
|
||||
|
||||
const orgUnitIdByKey = new Map<string, string>();
|
||||
for (const rootOrgUnit of rootOrgUnits) {
|
||||
const rootId = rootOrgUnitIdByName.get(rootOrgUnit.name);
|
||||
if (rootId) {
|
||||
orgUnitIdByKey.set(`${rootOrgUnit.level}:${rootOrgUnit.name}`, rootId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const orgUnit of parsed.orgUnits.filter((entry) => entry.parentName !== null)) {
|
||||
const parentLevel = orgUnit.level - 1;
|
||||
const parentId = orgUnitIdByKey.get(`${parentLevel}:${orgUnit.parentName}`);
|
||||
if (!parentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await db.orgUnit.upsert({
|
||||
where: {
|
||||
parentId_name: {
|
||||
parentId,
|
||||
name: orgUnit.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
sortOrder: orgUnit.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
parentId,
|
||||
name: orgUnit.name,
|
||||
level: orgUnit.level,
|
||||
sortOrder: orgUnit.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
orgUnitIdByKey.set(`${orgUnit.level}:${orgUnit.name}`, created.id);
|
||||
}
|
||||
|
||||
let managementLevelCount = 0;
|
||||
for (const group of parsed.managementLevelGroups) {
|
||||
const createdGroup = await db.managementLevelGroup.upsert({
|
||||
where: { name: group.name },
|
||||
update: {
|
||||
targetPercentage: group.targetPercentage,
|
||||
sortOrder: group.sortOrder,
|
||||
},
|
||||
create: {
|
||||
name: group.name,
|
||||
targetPercentage: group.targetPercentage,
|
||||
sortOrder: group.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
for (const levelName of group.levels) {
|
||||
managementLevelCount += 1;
|
||||
await db.managementLevel.upsert({
|
||||
where: { name: levelName },
|
||||
update: { groupId: createdGroup.id },
|
||||
create: { name: levelName, groupId: createdGroup.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootClientIdByName = new Map<string, string>();
|
||||
for (const rootClient of parsed.clients.filter((entry) => entry.parentName === null)) {
|
||||
const created = await upsertRootClient(db, {
|
||||
clientCode: rootClient.clientCode,
|
||||
name: rootClient.name,
|
||||
sortOrder: rootClient.sortOrder,
|
||||
});
|
||||
rootClientIdByName.set(rootClient.name, created.id);
|
||||
}
|
||||
|
||||
for (const client of parsed.clients.filter((entry) => entry.parentName !== null)) {
|
||||
const parentId = rootClientIdByName.get(client.parentName ?? "");
|
||||
if (!parentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await db.client.findFirst({
|
||||
where: { name: client.name, parentId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.client.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
sortOrder: client.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.client.create({
|
||||
data: {
|
||||
name: client.name,
|
||||
parentId,
|
||||
sortOrder: client.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.stagedClient.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.REFERENCE,
|
||||
},
|
||||
});
|
||||
|
||||
if (parsed.clients.length > 0) {
|
||||
await db.stagedClient.createMany({
|
||||
data: parsed.clients.map((client) => ({
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.NORMALIZED,
|
||||
sourceKind: DispoImportSourceKind.REFERENCE,
|
||||
sourceWorkbook: getWorkbookFileName(input.referenceWorkbookPath),
|
||||
sourceSheet: "Project-Attr",
|
||||
sourceRow: client.sourceRow,
|
||||
sourceColumn: client.sourceColumn,
|
||||
clientCode: client.clientCode,
|
||||
parentClientCode: client.parentClientCode,
|
||||
name: client.name,
|
||||
sortOrder: client.sortOrder,
|
||||
isActive: true,
|
||||
warnings: [],
|
||||
rawPayload: {
|
||||
name: client.name,
|
||||
parentName: client.parentName,
|
||||
} as Prisma.InputJsonValue,
|
||||
normalizedData: {
|
||||
clientCode: client.clientCode,
|
||||
name: client.name,
|
||||
parentClientCode: client.parentClientCode,
|
||||
parentName: client.parentName,
|
||||
} as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await db.stagedUnresolvedRecord.deleteMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
sourceKind: DispoImportSourceKind.REFERENCE,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = {
|
||||
countries: parsed.countries.length,
|
||||
managementLevelGroups: parsed.managementLevelGroups.length,
|
||||
managementLevels: managementLevelCount,
|
||||
metroCities: parsed.countries.reduce((count, country) => count + country.metroCities.length, 0),
|
||||
orgUnits: parsed.orgUnits.length,
|
||||
stagedClients: parsed.clients.length,
|
||||
unresolved: 0,
|
||||
warnings: parsed.warnings.length,
|
||||
};
|
||||
|
||||
await finalizeImportBatchStage(db, {
|
||||
batchId: batch.id,
|
||||
existingSummary: batch.summary,
|
||||
key: "reference",
|
||||
summary,
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { EstimateStatus, EstimateVersionStatus } from "@planarchy/shared";
|
||||
import {
|
||||
buildProjectSnapshot,
|
||||
ESTIMATE_DETAIL_INCLUDE,
|
||||
type EstimateDbClient,
|
||||
type EstimateWithDetails,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface CloneEstimateInput {
|
||||
sourceEstimateId: string;
|
||||
/** Override name for the clone (defaults to "Copy of <original>") */
|
||||
name?: string | undefined;
|
||||
/** Link to a different project */
|
||||
projectId?: string | undefined;
|
||||
}
|
||||
|
||||
export async function cloneEstimate(
|
||||
db: EstimateDbClient,
|
||||
input: CloneEstimateInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const source = await db.estimate.findUnique({
|
||||
where: { id: input.sourceEstimateId },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" as const },
|
||||
take: 1,
|
||||
include: {
|
||||
assumptions: { orderBy: { sortOrder: "asc" as const } },
|
||||
scopeItems: { orderBy: { sortOrder: "asc" as const } },
|
||||
demandLines: { orderBy: { createdAt: "asc" as const } },
|
||||
resourceSnapshots: { orderBy: { displayName: "asc" as const } },
|
||||
metrics: { orderBy: { createdAt: "asc" as const } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new Error("Source estimate not found");
|
||||
}
|
||||
|
||||
const sourceVersion = source.versions[0];
|
||||
if (!sourceVersion) {
|
||||
throw new Error("Source estimate has no versions");
|
||||
}
|
||||
|
||||
const cloneName = input.name ?? `Copy of ${source.name}`;
|
||||
const cloneProjectId = input.projectId ?? source.projectId;
|
||||
const projectSnapshot = await buildProjectSnapshot(db, cloneProjectId);
|
||||
|
||||
const estimate = await db.estimate.create({
|
||||
data: {
|
||||
...(cloneProjectId ? { projectId: cloneProjectId } : {}),
|
||||
name: cloneName,
|
||||
...(source.opportunityId ? { opportunityId: source.opportunityId } : {}),
|
||||
baseCurrency: source.baseCurrency,
|
||||
status: EstimateStatus.DRAFT,
|
||||
latestVersionNumber: 1,
|
||||
versions: {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
label: "Cloned from v" + sourceVersion.versionNumber,
|
||||
status: EstimateVersionStatus.WORKING,
|
||||
projectSnapshot,
|
||||
assumptions: {
|
||||
create: sourceVersion.assumptions.map((a) => ({
|
||||
category: a.category,
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
valueType: a.valueType,
|
||||
value: a.value as Prisma.InputJsonValue,
|
||||
sortOrder: a.sortOrder,
|
||||
...(a.notes ? { notes: a.notes } : {}),
|
||||
})),
|
||||
},
|
||||
scopeItems: {
|
||||
create: sourceVersion.scopeItems.map((s) => ({
|
||||
sequenceNo: s.sequenceNo,
|
||||
scopeType: s.scopeType,
|
||||
...(s.packageCode ? { packageCode: s.packageCode } : {}),
|
||||
name: s.name,
|
||||
...(s.description ? { description: s.description } : {}),
|
||||
...(s.scene ? { scene: s.scene } : {}),
|
||||
...(s.page ? { page: s.page } : {}),
|
||||
...(s.location ? { location: s.location } : {}),
|
||||
...(s.assumptionCategory ? { assumptionCategory: s.assumptionCategory } : {}),
|
||||
technicalSpec: s.technicalSpec as Prisma.InputJsonValue,
|
||||
...(s.frameCount !== null ? { frameCount: s.frameCount } : {}),
|
||||
...(s.itemCount !== null ? { itemCount: s.itemCount } : {}),
|
||||
...(s.unitMode ? { unitMode: s.unitMode } : {}),
|
||||
...(s.internalComments ? { internalComments: s.internalComments } : {}),
|
||||
...(s.externalComments ? { externalComments: s.externalComments } : {}),
|
||||
sortOrder: s.sortOrder,
|
||||
metadata: s.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
demandLines: {
|
||||
create: sourceVersion.demandLines.map((l) => ({
|
||||
...(l.roleId ? { roleId: l.roleId } : {}),
|
||||
...(l.resourceId ? { resourceId: l.resourceId } : {}),
|
||||
lineType: l.lineType,
|
||||
name: l.name,
|
||||
...(l.chapter ? { chapter: l.chapter } : {}),
|
||||
hours: l.hours,
|
||||
...(l.days !== null ? { days: l.days } : {}),
|
||||
...(l.fte !== null ? { fte: l.fte } : {}),
|
||||
...(l.rateSource ? { rateSource: l.rateSource } : {}),
|
||||
costRateCents: l.costRateCents,
|
||||
billRateCents: l.billRateCents,
|
||||
currency: l.currency,
|
||||
costTotalCents: l.costTotalCents,
|
||||
priceTotalCents: l.priceTotalCents,
|
||||
monthlySpread: l.monthlySpread as Prisma.InputJsonValue,
|
||||
staffingAttributes: l.staffingAttributes as Prisma.InputJsonValue,
|
||||
metadata: l.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
resourceSnapshots: {
|
||||
create: sourceVersion.resourceSnapshots.map((r) => ({
|
||||
...(r.resourceId ? { resourceId: r.resourceId } : {}),
|
||||
...(r.sourceEid ? { sourceEid: r.sourceEid } : {}),
|
||||
displayName: r.displayName,
|
||||
...(r.chapter ? { chapter: r.chapter } : {}),
|
||||
...(r.roleId ? { roleId: r.roleId } : {}),
|
||||
currency: r.currency,
|
||||
lcrCents: r.lcrCents,
|
||||
ucrCents: r.ucrCents,
|
||||
...(r.fte !== null ? { fte: r.fte } : {}),
|
||||
...(r.location ? { location: r.location } : {}),
|
||||
...(r.country ? { country: r.country } : {}),
|
||||
...(r.level ? { level: r.level } : {}),
|
||||
...(r.workType ? { workType: r.workType } : {}),
|
||||
attributes: r.attributes as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
metrics: {
|
||||
create: sourceVersion.metrics.map((m) => ({
|
||||
key: m.key,
|
||||
label: m.label,
|
||||
...(m.metricGroup ? { metricGroup: m.metricGroup } : {}),
|
||||
valueDecimal: Number(m.valueDecimal),
|
||||
...(m.valueCents !== null ? { valueCents: m.valueCents } : {}),
|
||||
...(m.currency ? { currency: m.currency } : {}),
|
||||
metadata: m.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: ESTIMATE_DETAIL_INCLUDE,
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { EstimateVersionStatus, type CreateEstimateInput } from "@planarchy/shared";
|
||||
import {
|
||||
buildProjectSnapshot,
|
||||
ESTIMATE_DETAIL_INCLUDE,
|
||||
type EstimateDbClient,
|
||||
type EstimateWithDetails,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function createEstimate(
|
||||
db: EstimateDbClient,
|
||||
input: CreateEstimateInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const projectSnapshot = await buildProjectSnapshot(db, input.projectId);
|
||||
|
||||
const estimate = await db.estimate.create({
|
||||
data: {
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
name: input.name,
|
||||
...(input.opportunityId !== undefined
|
||||
? { opportunityId: input.opportunityId }
|
||||
: {}),
|
||||
baseCurrency: input.baseCurrency,
|
||||
status: input.status,
|
||||
latestVersionNumber: 1,
|
||||
versions: {
|
||||
create: {
|
||||
versionNumber: 1,
|
||||
...(input.versionLabel !== undefined ? { label: input.versionLabel } : {}),
|
||||
status: EstimateVersionStatus.WORKING,
|
||||
...(input.versionNotes !== undefined
|
||||
? { notes: input.versionNotes }
|
||||
: {}),
|
||||
projectSnapshot,
|
||||
assumptions: {
|
||||
create: input.assumptions.map((assumption) => ({
|
||||
category: assumption.category,
|
||||
key: assumption.key,
|
||||
label: assumption.label,
|
||||
valueType: assumption.valueType,
|
||||
value: assumption.value as Prisma.InputJsonValue,
|
||||
sortOrder: assumption.sortOrder,
|
||||
...(assumption.notes !== undefined ? { notes: assumption.notes } : {}),
|
||||
})),
|
||||
},
|
||||
scopeItems: {
|
||||
create: input.scopeItems.map((scopeItem) => ({
|
||||
sequenceNo: scopeItem.sequenceNo,
|
||||
scopeType: scopeItem.scopeType,
|
||||
...(scopeItem.packageCode !== undefined
|
||||
? { packageCode: scopeItem.packageCode }
|
||||
: {}),
|
||||
name: scopeItem.name,
|
||||
...(scopeItem.description !== undefined
|
||||
? { description: scopeItem.description }
|
||||
: {}),
|
||||
...(scopeItem.scene !== undefined ? { scene: scopeItem.scene } : {}),
|
||||
...(scopeItem.page !== undefined ? { page: scopeItem.page } : {}),
|
||||
...(scopeItem.location !== undefined
|
||||
? { location: scopeItem.location }
|
||||
: {}),
|
||||
...(scopeItem.assumptionCategory !== undefined
|
||||
? { assumptionCategory: scopeItem.assumptionCategory }
|
||||
: {}),
|
||||
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
|
||||
...(scopeItem.frameCount !== undefined
|
||||
? { frameCount: scopeItem.frameCount }
|
||||
: {}),
|
||||
...(scopeItem.itemCount !== undefined
|
||||
? { itemCount: scopeItem.itemCount }
|
||||
: {}),
|
||||
...(scopeItem.unitMode !== undefined
|
||||
? { unitMode: scopeItem.unitMode }
|
||||
: {}),
|
||||
...(scopeItem.internalComments !== undefined
|
||||
? { internalComments: scopeItem.internalComments }
|
||||
: {}),
|
||||
...(scopeItem.externalComments !== undefined
|
||||
? { externalComments: scopeItem.externalComments }
|
||||
: {}),
|
||||
sortOrder: scopeItem.sortOrder,
|
||||
metadata: scopeItem.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
demandLines: {
|
||||
create: input.demandLines.map((line) => ({
|
||||
...(line.scopeItemId !== undefined ? { scopeItemId: line.scopeItemId } : {}),
|
||||
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
|
||||
...(line.resourceId !== undefined
|
||||
? { resourceId: line.resourceId }
|
||||
: {}),
|
||||
lineType: line.lineType,
|
||||
name: line.name,
|
||||
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
|
||||
hours: line.hours,
|
||||
...(line.days !== undefined ? { days: line.days } : {}),
|
||||
...(line.fte !== undefined ? { fte: line.fte } : {}),
|
||||
...(line.rateSource !== undefined
|
||||
? { rateSource: line.rateSource }
|
||||
: {}),
|
||||
costRateCents: line.costRateCents,
|
||||
billRateCents: line.billRateCents,
|
||||
currency: line.currency,
|
||||
costTotalCents: line.costTotalCents,
|
||||
priceTotalCents: line.priceTotalCents,
|
||||
monthlySpread: line.monthlySpread as Prisma.InputJsonValue,
|
||||
staffingAttributes: line.staffingAttributes as Prisma.InputJsonValue,
|
||||
metadata: line.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
resourceSnapshots: {
|
||||
create: input.resourceSnapshots.map((snapshot) => ({
|
||||
...(snapshot.resourceId !== undefined
|
||||
? { resourceId: snapshot.resourceId }
|
||||
: {}),
|
||||
...(snapshot.sourceEid !== undefined
|
||||
? { sourceEid: snapshot.sourceEid }
|
||||
: {}),
|
||||
displayName: snapshot.displayName,
|
||||
...(snapshot.chapter !== undefined ? { chapter: snapshot.chapter } : {}),
|
||||
...(snapshot.roleId !== undefined ? { roleId: snapshot.roleId } : {}),
|
||||
currency: snapshot.currency,
|
||||
lcrCents: snapshot.lcrCents,
|
||||
ucrCents: snapshot.ucrCents,
|
||||
...(snapshot.fte !== undefined ? { fte: snapshot.fte } : {}),
|
||||
...(snapshot.location !== undefined
|
||||
? { location: snapshot.location }
|
||||
: {}),
|
||||
...(snapshot.country !== undefined ? { country: snapshot.country } : {}),
|
||||
...(snapshot.level !== undefined ? { level: snapshot.level } : {}),
|
||||
...(snapshot.workType !== undefined
|
||||
? { workType: snapshot.workType }
|
||||
: {}),
|
||||
attributes: snapshot.attributes as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
metrics: {
|
||||
create: input.metrics.map((metric) => ({
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
...(metric.metricGroup !== undefined
|
||||
? { metricGroup: metric.metricGroup }
|
||||
: {}),
|
||||
valueDecimal: metric.valueDecimal,
|
||||
...(metric.valueCents !== undefined
|
||||
? { valueCents: metric.valueCents }
|
||||
: {}),
|
||||
...(metric.currency !== undefined ? { currency: metric.currency } : {}),
|
||||
metadata: metric.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: ESTIMATE_DETAIL_INCLUDE,
|
||||
});
|
||||
|
||||
return estimate;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ESTIMATE_DETAIL_INCLUDE, type EstimateDbClient } from "./shared.js";
|
||||
|
||||
export async function getEstimateById(db: EstimateDbClient, estimateId: string) {
|
||||
return db.estimate.findUnique({
|
||||
where: { id: estimateId },
|
||||
include: ESTIMATE_DETAIL_INCLUDE,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
createEstimate,
|
||||
} from "./create-estimate.js";
|
||||
export { cloneEstimate } from "./clone-estimate.js";
|
||||
export type { CloneEstimateInput } from "./clone-estimate.js";
|
||||
export { listEstimates } from "./list-estimates.js";
|
||||
export { getEstimateById } from "./get-estimate.js";
|
||||
export { updateEstimateDraft } from "./update-estimate-draft.js";
|
||||
export {
|
||||
approveEstimateVersion,
|
||||
createEstimateExport,
|
||||
createEstimateRevision,
|
||||
submitEstimateVersion,
|
||||
} from "./version-actions.js";
|
||||
export { createEstimatePlanningHandoff } from "./create-planning-handoff.js";
|
||||
export type { EstimateWithDetails, EstimateListItem } from "./shared.js";
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { EstimateListFilters } from "@planarchy/shared";
|
||||
import type { EstimateDbClient } from "./shared.js";
|
||||
|
||||
export async function listEstimates(
|
||||
db: EstimateDbClient,
|
||||
filters: EstimateListFilters = {},
|
||||
) {
|
||||
return db.estimate.findMany({
|
||||
where: {
|
||||
...(filters.projectId !== undefined ? { projectId: filters.projectId } : {}),
|
||||
...(filters.status !== undefined ? { status: filters.status } : {}),
|
||||
...(filters.query
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: filters.query, mode: "insensitive" } },
|
||||
{
|
||||
opportunityId: {
|
||||
contains: filters.query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
shortCode: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
label: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
Estimate,
|
||||
EstimateAssumption,
|
||||
EstimateDemandLine,
|
||||
EstimateExport,
|
||||
EstimateMetric,
|
||||
EstimateVersion,
|
||||
PrismaClient,
|
||||
Project,
|
||||
ResourceCostSnapshot,
|
||||
ScopeItem,
|
||||
} from "@planarchy/db";
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
|
||||
export const PROJECT_SNAPSHOT_SELECT = {
|
||||
id: true,
|
||||
shortCode: true,
|
||||
name: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
orderType: true,
|
||||
allocationType: true,
|
||||
winProbability: true,
|
||||
budgetCents: true,
|
||||
responsiblePerson: true,
|
||||
} as const;
|
||||
|
||||
export const ESTIMATE_DETAIL_INCLUDE = {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
shortCode: true,
|
||||
name: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
include: {
|
||||
assumptions: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
},
|
||||
scopeItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
},
|
||||
demandLines: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
resourceSnapshots: {
|
||||
orderBy: { displayName: "asc" },
|
||||
},
|
||||
metrics: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
exports: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface EstimateVersionDetails extends EstimateVersion {
|
||||
assumptions: EstimateAssumption[];
|
||||
scopeItems: ScopeItem[];
|
||||
demandLines: EstimateDemandLine[];
|
||||
resourceSnapshots: ResourceCostSnapshot[];
|
||||
metrics: EstimateMetric[];
|
||||
exports: EstimateExport[];
|
||||
}
|
||||
|
||||
export interface EstimateWithDetails extends Estimate {
|
||||
project?: Pick<Project, "id" | "shortCode" | "name" | "status" | "startDate" | "endDate"> | null;
|
||||
versions: EstimateVersionDetails[];
|
||||
}
|
||||
|
||||
export interface EstimateListItem extends Estimate {
|
||||
project?: Pick<Project, "id" | "shortCode" | "name" | "status"> | null;
|
||||
versions: Array<
|
||||
Pick<
|
||||
EstimateVersion,
|
||||
"id" | "versionNumber" | "label" | "status" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export type EstimateDbClient = PrismaClient;
|
||||
|
||||
export async function buildProjectSnapshot(
|
||||
db: Pick<PrismaClient, "project">,
|
||||
projectId: string | null | undefined,
|
||||
): Promise<Prisma.InputJsonValue> {
|
||||
if (!projectId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: PROJECT_SNAPSHOT_SELECT,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
startDate: project.startDate.toISOString(),
|
||||
endDate: project.endDate.toISOString(),
|
||||
} satisfies Prisma.InputJsonValue;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { EstimateVersionStatus, type UpdateEstimateDraftInput } from "@planarchy/shared";
|
||||
import {
|
||||
buildProjectSnapshot,
|
||||
ESTIMATE_DETAIL_INCLUDE,
|
||||
type EstimateDbClient,
|
||||
type EstimateWithDetails,
|
||||
} from "./shared.js";
|
||||
|
||||
function toAssumptionCreateInput(
|
||||
assumption: UpdateEstimateDraftInput["assumptions"][number],
|
||||
) {
|
||||
return {
|
||||
...(assumption.id !== undefined ? { id: assumption.id } : {}),
|
||||
category: assumption.category,
|
||||
key: assumption.key,
|
||||
label: assumption.label,
|
||||
valueType: assumption.valueType,
|
||||
value: assumption.value as Prisma.InputJsonValue,
|
||||
sortOrder: assumption.sortOrder,
|
||||
...(assumption.notes !== undefined ? { notes: assumption.notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toScopeItemCreateInput(
|
||||
scopeItem: UpdateEstimateDraftInput["scopeItems"][number],
|
||||
) {
|
||||
return {
|
||||
...(scopeItem.id !== undefined ? { id: scopeItem.id } : {}),
|
||||
sequenceNo: scopeItem.sequenceNo,
|
||||
scopeType: scopeItem.scopeType,
|
||||
...(scopeItem.packageCode !== undefined
|
||||
? { packageCode: scopeItem.packageCode }
|
||||
: {}),
|
||||
name: scopeItem.name,
|
||||
...(scopeItem.description !== undefined
|
||||
? { description: scopeItem.description }
|
||||
: {}),
|
||||
...(scopeItem.scene !== undefined ? { scene: scopeItem.scene } : {}),
|
||||
...(scopeItem.page !== undefined ? { page: scopeItem.page } : {}),
|
||||
...(scopeItem.location !== undefined ? { location: scopeItem.location } : {}),
|
||||
...(scopeItem.assumptionCategory !== undefined
|
||||
? { assumptionCategory: scopeItem.assumptionCategory }
|
||||
: {}),
|
||||
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
|
||||
...(scopeItem.frameCount !== undefined
|
||||
? { frameCount: scopeItem.frameCount }
|
||||
: {}),
|
||||
...(scopeItem.itemCount !== undefined ? { itemCount: scopeItem.itemCount } : {}),
|
||||
...(scopeItem.unitMode !== undefined ? { unitMode: scopeItem.unitMode } : {}),
|
||||
...(scopeItem.internalComments !== undefined
|
||||
? { internalComments: scopeItem.internalComments }
|
||||
: {}),
|
||||
...(scopeItem.externalComments !== undefined
|
||||
? { externalComments: scopeItem.externalComments }
|
||||
: {}),
|
||||
sortOrder: scopeItem.sortOrder,
|
||||
metadata: scopeItem.metadata as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
function toDemandLineCreateInput(
|
||||
line: UpdateEstimateDraftInput["demandLines"][number],
|
||||
) {
|
||||
return {
|
||||
...(line.id !== undefined ? { id: line.id } : {}),
|
||||
...(line.scopeItemId !== undefined ? { scopeItemId: line.scopeItemId } : {}),
|
||||
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
|
||||
...(line.resourceId !== undefined ? { resourceId: line.resourceId } : {}),
|
||||
lineType: line.lineType,
|
||||
name: line.name,
|
||||
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
|
||||
hours: line.hours,
|
||||
...(line.days !== undefined ? { days: line.days } : {}),
|
||||
...(line.fte !== undefined ? { fte: line.fte } : {}),
|
||||
...(line.rateSource !== undefined ? { rateSource: line.rateSource } : {}),
|
||||
costRateCents: line.costRateCents,
|
||||
billRateCents: line.billRateCents,
|
||||
currency: line.currency,
|
||||
costTotalCents: line.costTotalCents,
|
||||
priceTotalCents: line.priceTotalCents,
|
||||
monthlySpread: line.monthlySpread as Prisma.InputJsonValue,
|
||||
staffingAttributes: line.staffingAttributes as Prisma.InputJsonValue,
|
||||
metadata: line.metadata as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
function toResourceSnapshotCreateInput(
|
||||
snapshot: UpdateEstimateDraftInput["resourceSnapshots"][number],
|
||||
) {
|
||||
return {
|
||||
...(snapshot.id !== undefined ? { id: snapshot.id } : {}),
|
||||
...(snapshot.resourceId !== undefined ? { resourceId: snapshot.resourceId } : {}),
|
||||
...(snapshot.sourceEid !== undefined ? { sourceEid: snapshot.sourceEid } : {}),
|
||||
displayName: snapshot.displayName,
|
||||
...(snapshot.chapter !== undefined ? { chapter: snapshot.chapter } : {}),
|
||||
...(snapshot.roleId !== undefined ? { roleId: snapshot.roleId } : {}),
|
||||
currency: snapshot.currency,
|
||||
lcrCents: snapshot.lcrCents,
|
||||
ucrCents: snapshot.ucrCents,
|
||||
...(snapshot.fte !== undefined ? { fte: snapshot.fte } : {}),
|
||||
...(snapshot.location !== undefined ? { location: snapshot.location } : {}),
|
||||
...(snapshot.country !== undefined ? { country: snapshot.country } : {}),
|
||||
...(snapshot.level !== undefined ? { level: snapshot.level } : {}),
|
||||
...(snapshot.workType !== undefined ? { workType: snapshot.workType } : {}),
|
||||
attributes: snapshot.attributes as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
function toMetricCreateInput(metric: UpdateEstimateDraftInput["metrics"][number]) {
|
||||
return {
|
||||
...(metric.id !== undefined ? { id: metric.id } : {}),
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
...(metric.metricGroup !== undefined ? { metricGroup: metric.metricGroup } : {}),
|
||||
valueDecimal: metric.valueDecimal,
|
||||
...(metric.valueCents !== undefined ? { valueCents: metric.valueCents } : {}),
|
||||
...(metric.currency !== undefined ? { currency: metric.currency } : {}),
|
||||
metadata: metric.metadata as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateEstimateDraft(
|
||||
db: EstimateDbClient,
|
||||
input: UpdateEstimateDraftInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const existing = await db.estimate.findUnique({
|
||||
where: { id: input.id },
|
||||
include: ESTIMATE_DETAIL_INCLUDE,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error("Estimate not found");
|
||||
}
|
||||
|
||||
const workingVersion = existing.versions.find(
|
||||
(version) => version.status === EstimateVersionStatus.WORKING,
|
||||
);
|
||||
|
||||
if (!workingVersion) {
|
||||
throw new Error("Estimate has no working version");
|
||||
}
|
||||
|
||||
const effectiveProjectId = input.projectId ?? existing.projectId;
|
||||
const projectSnapshot = await buildProjectSnapshot(db, effectiveProjectId);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.estimate.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.opportunityId !== undefined
|
||||
? { opportunityId: input.opportunityId }
|
||||
: {}),
|
||||
...(input.baseCurrency !== undefined
|
||||
? { baseCurrency: input.baseCurrency }
|
||||
: {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.estimateVersion.update({
|
||||
where: { id: workingVersion.id },
|
||||
data: {
|
||||
...(input.versionLabel !== undefined ? { label: input.versionLabel } : {}),
|
||||
...(input.versionNotes !== undefined ? { notes: input.versionNotes } : {}),
|
||||
projectSnapshot,
|
||||
assumptions: {
|
||||
deleteMany: {},
|
||||
create: input.assumptions.map(toAssumptionCreateInput),
|
||||
},
|
||||
scopeItems: {
|
||||
deleteMany: {},
|
||||
create: input.scopeItems.map(toScopeItemCreateInput),
|
||||
},
|
||||
demandLines: {
|
||||
deleteMany: {},
|
||||
create: input.demandLines.map(toDemandLineCreateInput),
|
||||
},
|
||||
resourceSnapshots: {
|
||||
deleteMany: {},
|
||||
create: input.resourceSnapshots.map(toResourceSnapshotCreateInput),
|
||||
},
|
||||
metrics: {
|
||||
deleteMany: {},
|
||||
create: input.metrics.map(toMetricCreateInput),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updated = await db.estimate.findUnique({
|
||||
where: { id: input.id },
|
||||
include: ESTIMATE_DETAIL_INCLUDE,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Estimate not found after update");
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { Prisma } from "@planarchy/db";
|
||||
import { serializeEstimateExport } from "@planarchy/engine";
|
||||
import {
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
type ApproveEstimateVersionInput,
|
||||
type CreateEstimateExportInput,
|
||||
type CreateEstimateRevisionInput,
|
||||
type SubmitEstimateVersionInput,
|
||||
} from "@planarchy/shared";
|
||||
import {
|
||||
ESTIMATE_DETAIL_INCLUDE,
|
||||
type EstimateDbClient,
|
||||
type EstimateVersionDetails,
|
||||
type EstimateWithDetails,
|
||||
} from "./shared.js";
|
||||
|
||||
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 resolveVersion(
|
||||
estimate: EstimateWithDetails,
|
||||
versionId: string | undefined,
|
||||
matcher: (version: EstimateVersionDetails) => boolean,
|
||||
errorMessage: string,
|
||||
) {
|
||||
const version = versionId
|
||||
? estimate.versions.find((candidate) => candidate.id === versionId)
|
||||
: estimate.versions.find(matcher);
|
||||
|
||||
if (!version) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
function sanitizeFileNameSegment(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized.length > 0 ? normalized : "estimate";
|
||||
}
|
||||
|
||||
function buildExportFileName(
|
||||
estimateName: string,
|
||||
versionNumber: number,
|
||||
format: EstimateExportFormat,
|
||||
) {
|
||||
return `${sanitizeFileNameSegment(estimateName)}-v${versionNumber}.${format.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export async function submitEstimateVersion(
|
||||
db: EstimateDbClient,
|
||||
input: SubmitEstimateVersionInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
||||
const targetVersion = resolveVersion(
|
||||
estimate,
|
||||
input.versionId,
|
||||
(version) => version.status === EstimateVersionStatus.WORKING,
|
||||
input.versionId
|
||||
? "Estimate version not found"
|
||||
: "Estimate has no working version",
|
||||
);
|
||||
|
||||
if (targetVersion.status !== EstimateVersionStatus.WORKING) {
|
||||
throw new Error("Only working versions can be submitted");
|
||||
}
|
||||
|
||||
const supersededIds = estimate.versions
|
||||
.filter(
|
||||
(version) =>
|
||||
version.id !== targetVersion.id &&
|
||||
version.status === EstimateVersionStatus.SUBMITTED,
|
||||
)
|
||||
.map((version) => version.id);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
if (supersededIds.length > 0) {
|
||||
await tx.estimateVersion.updateMany({
|
||||
where: { id: { in: supersededIds } },
|
||||
data: { status: EstimateVersionStatus.SUPERSEDED },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.estimateVersion.update({
|
||||
where: { id: targetVersion.id },
|
||||
data: {
|
||||
status: EstimateVersionStatus.SUBMITTED,
|
||||
lockedAt: targetVersion.lockedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.estimate.update({
|
||||
where: { id: estimate.id },
|
||||
data: { status: EstimateStatus.IN_REVIEW },
|
||||
});
|
||||
});
|
||||
|
||||
return getEstimateOrThrow(db, estimate.id);
|
||||
}
|
||||
|
||||
export async function approveEstimateVersion(
|
||||
db: EstimateDbClient,
|
||||
input: ApproveEstimateVersionInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
||||
const targetVersion = resolveVersion(
|
||||
estimate,
|
||||
input.versionId,
|
||||
(version) => version.status === EstimateVersionStatus.SUBMITTED,
|
||||
input.versionId
|
||||
? "Estimate version not found"
|
||||
: "Estimate has no submitted version",
|
||||
);
|
||||
|
||||
if (targetVersion.status !== EstimateVersionStatus.SUBMITTED) {
|
||||
throw new Error("Only submitted versions can be approved");
|
||||
}
|
||||
|
||||
const supersededIds = estimate.versions
|
||||
.filter(
|
||||
(version) =>
|
||||
version.id !== targetVersion.id &&
|
||||
(version.status === EstimateVersionStatus.SUBMITTED ||
|
||||
version.status === EstimateVersionStatus.APPROVED),
|
||||
)
|
||||
.map((version) => version.id);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
if (supersededIds.length > 0) {
|
||||
await tx.estimateVersion.updateMany({
|
||||
where: { id: { in: supersededIds } },
|
||||
data: { status: EstimateVersionStatus.SUPERSEDED },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.estimateVersion.update({
|
||||
where: { id: targetVersion.id },
|
||||
data: {
|
||||
status: EstimateVersionStatus.APPROVED,
|
||||
lockedAt: targetVersion.lockedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.estimate.update({
|
||||
where: { id: estimate.id },
|
||||
data: { status: EstimateStatus.APPROVED },
|
||||
});
|
||||
});
|
||||
|
||||
return getEstimateOrThrow(db, estimate.id);
|
||||
}
|
||||
|
||||
export async function createEstimateRevision(
|
||||
db: EstimateDbClient,
|
||||
input: CreateEstimateRevisionInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
||||
const existingWorkingVersion = estimate.versions.find(
|
||||
(version) => version.status === EstimateVersionStatus.WORKING,
|
||||
);
|
||||
|
||||
if (existingWorkingVersion) {
|
||||
throw new Error("Estimate already has a working version");
|
||||
}
|
||||
|
||||
const sourceVersion = resolveVersion(
|
||||
estimate,
|
||||
input.sourceVersionId,
|
||||
(version) =>
|
||||
version.status !== EstimateVersionStatus.WORKING || version.lockedAt != null,
|
||||
input.sourceVersionId
|
||||
? "Estimate version not found"
|
||||
: "Estimate has no locked version to revise",
|
||||
);
|
||||
|
||||
if (
|
||||
sourceVersion.status === EstimateVersionStatus.WORKING &&
|
||||
sourceVersion.lockedAt == null
|
||||
) {
|
||||
throw new Error("Source version must be locked before creating a revision");
|
||||
}
|
||||
|
||||
const nextVersionNumber = estimate.latestVersionNumber + 1;
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
const newVersion = await tx.estimateVersion.create({
|
||||
data: {
|
||||
estimateId: estimate.id,
|
||||
versionNumber: nextVersionNumber,
|
||||
label: input.label ?? `Revision ${nextVersionNumber}`,
|
||||
status: EstimateVersionStatus.WORKING,
|
||||
notes:
|
||||
input.notes ?? `Revision created from v${sourceVersion.versionNumber}`,
|
||||
projectSnapshot: sourceVersion.projectSnapshot as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (sourceVersion.assumptions.length > 0) {
|
||||
await tx.estimateAssumption.createMany({
|
||||
data: sourceVersion.assumptions.map((assumption) => ({
|
||||
estimateVersionId: newVersion.id,
|
||||
category: assumption.category,
|
||||
key: assumption.key,
|
||||
label: assumption.label,
|
||||
valueType: assumption.valueType,
|
||||
value: assumption.value as Prisma.InputJsonValue,
|
||||
sortOrder: assumption.sortOrder,
|
||||
...(assumption.notes != null ? { notes: assumption.notes } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const scopeItemIdMap = new Map<string, string>();
|
||||
|
||||
for (const scopeItem of sourceVersion.scopeItems) {
|
||||
const createdScopeItem = await tx.scopeItem.create({
|
||||
data: {
|
||||
estimateVersionId: newVersion.id,
|
||||
sequenceNo: scopeItem.sequenceNo,
|
||||
scopeType: scopeItem.scopeType,
|
||||
...(scopeItem.packageCode != null
|
||||
? { packageCode: scopeItem.packageCode }
|
||||
: {}),
|
||||
name: scopeItem.name,
|
||||
...(scopeItem.description != null
|
||||
? { description: scopeItem.description }
|
||||
: {}),
|
||||
...(scopeItem.scene != null ? { scene: scopeItem.scene } : {}),
|
||||
...(scopeItem.page != null ? { page: scopeItem.page } : {}),
|
||||
...(scopeItem.location != null ? { location: scopeItem.location } : {}),
|
||||
...(scopeItem.assumptionCategory != null
|
||||
? { assumptionCategory: scopeItem.assumptionCategory }
|
||||
: {}),
|
||||
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
|
||||
...(scopeItem.frameCount != null
|
||||
? { frameCount: scopeItem.frameCount }
|
||||
: {}),
|
||||
...(scopeItem.itemCount != null ? { itemCount: scopeItem.itemCount } : {}),
|
||||
...(scopeItem.unitMode != null ? { unitMode: scopeItem.unitMode } : {}),
|
||||
...(scopeItem.internalComments != null
|
||||
? { internalComments: scopeItem.internalComments }
|
||||
: {}),
|
||||
...(scopeItem.externalComments != null
|
||||
? { externalComments: scopeItem.externalComments }
|
||||
: {}),
|
||||
sortOrder: scopeItem.sortOrder,
|
||||
metadata: scopeItem.metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
scopeItemIdMap.set(scopeItem.id, createdScopeItem.id);
|
||||
}
|
||||
|
||||
if (sourceVersion.demandLines.length > 0) {
|
||||
await tx.estimateDemandLine.createMany({
|
||||
data: sourceVersion.demandLines.map((line) => ({
|
||||
estimateVersionId: newVersion.id,
|
||||
scopeItemId:
|
||||
line.scopeItemId != null
|
||||
? (scopeItemIdMap.get(line.scopeItemId) ?? null)
|
||||
: null,
|
||||
roleId: line.roleId ?? null,
|
||||
resourceId: line.resourceId ?? null,
|
||||
lineType: line.lineType,
|
||||
name: line.name,
|
||||
chapter: line.chapter ?? null,
|
||||
hours: line.hours,
|
||||
days: line.days ?? null,
|
||||
fte: line.fte ?? null,
|
||||
rateSource: line.rateSource ?? null,
|
||||
costRateCents: line.costRateCents,
|
||||
billRateCents: line.billRateCents,
|
||||
currency: line.currency,
|
||||
costTotalCents: line.costTotalCents,
|
||||
priceTotalCents: line.priceTotalCents,
|
||||
monthlySpread: line.monthlySpread as Prisma.InputJsonValue,
|
||||
staffingAttributes: line.staffingAttributes as Prisma.InputJsonValue,
|
||||
metadata: line.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceVersion.resourceSnapshots.length > 0) {
|
||||
await tx.resourceCostSnapshot.createMany({
|
||||
data: sourceVersion.resourceSnapshots.map((snapshot) => ({
|
||||
estimateVersionId: newVersion.id,
|
||||
...(snapshot.resourceId != null ? { resourceId: snapshot.resourceId } : {}),
|
||||
...(snapshot.sourceEid != null ? { sourceEid: snapshot.sourceEid } : {}),
|
||||
displayName: snapshot.displayName,
|
||||
...(snapshot.chapter != null ? { chapter: snapshot.chapter } : {}),
|
||||
...(snapshot.roleId != null ? { roleId: snapshot.roleId } : {}),
|
||||
currency: snapshot.currency,
|
||||
lcrCents: snapshot.lcrCents,
|
||||
ucrCents: snapshot.ucrCents,
|
||||
...(snapshot.fte != null ? { fte: snapshot.fte } : {}),
|
||||
...(snapshot.location != null ? { location: snapshot.location } : {}),
|
||||
...(snapshot.country != null ? { country: snapshot.country } : {}),
|
||||
...(snapshot.level != null ? { level: snapshot.level } : {}),
|
||||
...(snapshot.workType != null ? { workType: snapshot.workType } : {}),
|
||||
attributes: snapshot.attributes as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceVersion.metrics.length > 0) {
|
||||
await tx.estimateMetric.createMany({
|
||||
data: sourceVersion.metrics.map((metric) => ({
|
||||
estimateVersionId: newVersion.id,
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
...(metric.metricGroup != null
|
||||
? { metricGroup: metric.metricGroup }
|
||||
: {}),
|
||||
valueDecimal: metric.valueDecimal,
|
||||
...(metric.valueCents != null ? { valueCents: metric.valueCents } : {}),
|
||||
...(metric.currency != null ? { currency: metric.currency } : {}),
|
||||
metadata: metric.metadata as Prisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.estimate.update({
|
||||
where: { id: estimate.id },
|
||||
data: {
|
||||
latestVersionNumber: nextVersionNumber,
|
||||
status: EstimateStatus.DRAFT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return getEstimateOrThrow(db, estimate.id);
|
||||
}
|
||||
|
||||
export async function createEstimateExport(
|
||||
db: EstimateDbClient,
|
||||
input: CreateEstimateExportInput,
|
||||
): Promise<EstimateWithDetails> {
|
||||
const estimate = await getEstimateOrThrow(db, input.estimateId);
|
||||
const targetVersion = resolveVersion(
|
||||
estimate,
|
||||
input.versionId,
|
||||
() => true,
|
||||
input.versionId
|
||||
? "Estimate version not found"
|
||||
: "Estimate has no version to export",
|
||||
);
|
||||
const projectSnapshot =
|
||||
typeof targetVersion.projectSnapshot === "object" &&
|
||||
targetVersion.projectSnapshot !== null &&
|
||||
!Array.isArray(targetVersion.projectSnapshot)
|
||||
? (targetVersion.projectSnapshot as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const payload = serializeEstimateExport(
|
||||
{
|
||||
estimate,
|
||||
version: targetVersion,
|
||||
project: estimate.project
|
||||
? {
|
||||
...estimate.project,
|
||||
startDate:
|
||||
typeof projectSnapshot?.startDate === "string"
|
||||
? projectSnapshot.startDate
|
||||
: null,
|
||||
endDate:
|
||||
typeof projectSnapshot?.endDate === "string"
|
||||
? projectSnapshot.endDate
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
input.format,
|
||||
);
|
||||
|
||||
await db.estimateExport.create({
|
||||
data: {
|
||||
estimateVersionId: targetVersion.id,
|
||||
format: input.format,
|
||||
fileName: buildExportFileName(
|
||||
estimate.name,
|
||||
targetVersion.versionNumber,
|
||||
input.format,
|
||||
),
|
||||
payload: payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return getEstimateOrThrow(db, estimate.id);
|
||||
}
|
||||
Reference in New Issue
Block a user