feat(application): add dispo import commit flow
This commit is contained in:
@@ -0,0 +1,980 @@
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import {
|
||||
createWeekdayAvailabilityFromFte,
|
||||
DISPO_INTERNAL_PROJECT_BUCKETS,
|
||||
DISPO_REQUIRED_ROLE_SEEDS,
|
||||
DISPO_UTILIZATION_CATEGORIES,
|
||||
normalizeDispoRoleToken,
|
||||
} from "@planarchy/shared";
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import {
|
||||
AllocationStatus,
|
||||
ImportBatchStatus,
|
||||
ProjectStatus,
|
||||
StagedRecordStatus,
|
||||
VacationStatus,
|
||||
} from "@planarchy/db";
|
||||
import { buildBatchSummaryEntry, buildFallbackAccentureEmail, toJsonObject } from "./shared.js";
|
||||
|
||||
type CommitDbClient = Pick<
|
||||
PrismaClient,
|
||||
| "$transaction"
|
||||
| "assignment"
|
||||
| "client"
|
||||
| "country"
|
||||
| "importBatch"
|
||||
| "managementLevel"
|
||||
| "managementLevelGroup"
|
||||
| "metroCity"
|
||||
| "orgUnit"
|
||||
| "project"
|
||||
| "resource"
|
||||
| "resourceRole"
|
||||
| "role"
|
||||
| "stagedAssignment"
|
||||
| "stagedAvailabilityRule"
|
||||
| "stagedProject"
|
||||
| "stagedResource"
|
||||
| "stagedUnresolvedRecord"
|
||||
| "stagedVacation"
|
||||
| "utilizationCategory"
|
||||
| "user"
|
||||
| "vacation"
|
||||
| "vacationEntitlement"
|
||||
>;
|
||||
|
||||
type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
|
||||
|
||||
interface MergedStagedResource {
|
||||
availability: Prisma.InputJsonValue | null;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
fte: number | null;
|
||||
lcrCents: number | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawPayload: Record<string, unknown>;
|
||||
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
|
||||
roleTokens: Set<string>;
|
||||
sourceKinds: string[];
|
||||
ucrCents: number | null;
|
||||
vacationDaysPerYear: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface AggregatedAssignment {
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
projectId: string;
|
||||
projectShortCode: string;
|
||||
resourceId: string;
|
||||
resourceKey: string;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
sourceDates: string[];
|
||||
startDate: Date;
|
||||
utilizationCategoryCode: string | null;
|
||||
}
|
||||
|
||||
export interface CommitDispoImportBatchInput {
|
||||
allowTbdUnresolved?: boolean;
|
||||
importBatchId: string;
|
||||
}
|
||||
|
||||
export interface CommitDispoImportBatchResult {
|
||||
batchId: string;
|
||||
counts: {
|
||||
committedAssignments: number;
|
||||
committedProjects: number;
|
||||
committedResources: number;
|
||||
committedVacations: number;
|
||||
updatedEntitlements: number;
|
||||
updatedResourceAvailabilities: number;
|
||||
upsertedResourceRoles: number;
|
||||
};
|
||||
unresolved: {
|
||||
blocked: number;
|
||||
skippedTbd: number;
|
||||
};
|
||||
}
|
||||
|
||||
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
|
||||
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
|
||||
|
||||
function normalizeDate(date: Date): Date {
|
||||
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function getDateKey(date: Date): string {
|
||||
return normalizeDate(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = normalizeDate(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function roundToOneDecimal(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function parseWeekdayAvailability(
|
||||
value: unknown,
|
||||
fallbackFte: number | null,
|
||||
): WeekdayAvailability {
|
||||
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
|
||||
const source = asObject(value);
|
||||
|
||||
return {
|
||||
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
|
||||
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
|
||||
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
|
||||
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
|
||||
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedUnresolvedRecord(
|
||||
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
|
||||
): boolean {
|
||||
const message = record.message.toLowerCase();
|
||||
const hint = (record.resolutionHint ?? "").toLowerCase();
|
||||
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
|
||||
|
||||
return record.recordType === "PROJECT" && (
|
||||
message.includes("[tbd]") ||
|
||||
hint.includes("[tbd]") ||
|
||||
rawToken.includes("[tbd]")
|
||||
);
|
||||
}
|
||||
|
||||
function mergeScalar<T>(
|
||||
current: T | null,
|
||||
incoming: T | null | undefined,
|
||||
): T | null {
|
||||
return incoming ?? current;
|
||||
}
|
||||
|
||||
function mergeStagedResources(
|
||||
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
|
||||
): Map<string, MergedStagedResource> {
|
||||
const sourcePriority = new Map([
|
||||
["CHARGEABILITY", 1],
|
||||
["ROSTER", 2],
|
||||
]);
|
||||
const ordered = [...rows].sort(
|
||||
(left, right) =>
|
||||
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
|
||||
);
|
||||
const merged = new Map<string, MergedStagedResource>();
|
||||
|
||||
for (const row of ordered) {
|
||||
const existing = merged.get(row.canonicalExternalId);
|
||||
const rawPayload = asObject(row.rawPayload);
|
||||
|
||||
if (!existing) {
|
||||
merged.set(row.canonicalExternalId, {
|
||||
availability: row.availability ?? null,
|
||||
canonicalExternalId: row.canonicalExternalId,
|
||||
chapter: row.chapter ?? null,
|
||||
chargeabilityTarget: row.chargeabilityTarget ?? null,
|
||||
clientUnitName: row.clientUnitName ?? null,
|
||||
countryCode: row.countryCode ?? null,
|
||||
displayName: row.displayName ?? null,
|
||||
email: row.email ?? null,
|
||||
fte: row.fte ?? null,
|
||||
lcrCents: row.lcrCents ?? null,
|
||||
managementLevelGroupName: row.managementLevelGroupName ?? null,
|
||||
managementLevelName: row.managementLevelName ?? null,
|
||||
metroCityName: row.metroCityName ?? null,
|
||||
rawPayload,
|
||||
resourceType: row.resourceType ?? null,
|
||||
roleTokens: new Set(row.roleTokens),
|
||||
sourceKinds: [row.sourceKind],
|
||||
ucrCents: row.ucrCents ?? null,
|
||||
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
|
||||
? rawPayload.vacationDaysPerYear
|
||||
: null,
|
||||
warnings: [...row.warnings],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.availability = mergeScalar(existing.availability, row.availability);
|
||||
existing.chapter = mergeScalar(existing.chapter, row.chapter);
|
||||
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
|
||||
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
|
||||
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
|
||||
existing.displayName = mergeScalar(existing.displayName, row.displayName);
|
||||
existing.email = mergeScalar(existing.email, row.email);
|
||||
existing.fte = mergeScalar(existing.fte, row.fte);
|
||||
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
|
||||
existing.managementLevelGroupName = mergeScalar(
|
||||
existing.managementLevelGroupName,
|
||||
row.managementLevelGroupName,
|
||||
);
|
||||
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
|
||||
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
|
||||
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
|
||||
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
|
||||
if (existing.availability === null && row.availability !== null) {
|
||||
existing.availability = row.availability;
|
||||
}
|
||||
for (const roleToken of row.roleTokens) {
|
||||
existing.roleTokens.add(roleToken);
|
||||
}
|
||||
existing.sourceKinds.push(row.sourceKind);
|
||||
existing.warnings.push(...row.warnings);
|
||||
existing.rawPayload = {
|
||||
...existing.rawPayload,
|
||||
...rawPayload,
|
||||
};
|
||||
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
|
||||
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
|
||||
return (
|
||||
DISPO_INTERNAL_PROJECT_BUCKETS.find(
|
||||
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
|
||||
)?.shortCode ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateAssignments(
|
||||
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
|
||||
resourceIdByKey: ReadonlyMap<string, string>,
|
||||
projectIdByShortCode: ReadonlyMap<string, string>,
|
||||
roleIdByName: ReadonlyMap<string, string>,
|
||||
): AggregatedAssignment[] {
|
||||
const resolvedRows = rows
|
||||
.filter((row) => !row.isUnassigned && !row.isTbd)
|
||||
.map((row) => {
|
||||
const projectShortCode = row.isInternal
|
||||
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
|
||||
: (row.projectKey ?? null);
|
||||
const roleName = row.roleName ?? normalizeDispoRoleToken(row.roleToken);
|
||||
const resourceId = resourceIdByKey.get(row.resourceExternalId);
|
||||
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
|
||||
const roleId = roleName ? roleIdByName.get(roleName) : null;
|
||||
|
||||
if (!resourceId) {
|
||||
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
|
||||
}
|
||||
if (!projectShortCode || !projectId) {
|
||||
throw new Error(
|
||||
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||
);
|
||||
}
|
||||
if (!roleName || !roleId) {
|
||||
throw new Error(
|
||||
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||
);
|
||||
}
|
||||
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
|
||||
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
|
||||
}
|
||||
|
||||
return {
|
||||
assignmentDate: normalizeDate(row.assignmentDate),
|
||||
hoursPerDay: row.hoursPerDay,
|
||||
percentage: row.percentage,
|
||||
projectId,
|
||||
projectShortCode,
|
||||
resourceId,
|
||||
resourceKey: row.resourceExternalId,
|
||||
roleId,
|
||||
roleName,
|
||||
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) =>
|
||||
left.resourceKey.localeCompare(right.resourceKey) ||
|
||||
left.projectShortCode.localeCompare(right.projectShortCode) ||
|
||||
left.roleName.localeCompare(right.roleName) ||
|
||||
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
|
||||
);
|
||||
|
||||
const aggregated: AggregatedAssignment[] = [];
|
||||
|
||||
for (const row of resolvedRows) {
|
||||
const previous = aggregated.at(-1);
|
||||
const canMerge = previous &&
|
||||
previous.resourceId === row.resourceId &&
|
||||
previous.projectId === row.projectId &&
|
||||
previous.roleId === row.roleId &&
|
||||
previous.hoursPerDay === row.hoursPerDay &&
|
||||
previous.percentage === row.percentage &&
|
||||
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
|
||||
|
||||
if (canMerge) {
|
||||
previous.endDate = row.assignmentDate;
|
||||
previous.sourceDates.push(getDateKey(row.assignmentDate));
|
||||
continue;
|
||||
}
|
||||
|
||||
aggregated.push({
|
||||
endDate: row.assignmentDate,
|
||||
hoursPerDay: row.hoursPerDay,
|
||||
percentage: row.percentage,
|
||||
projectId: row.projectId,
|
||||
projectShortCode: row.projectShortCode,
|
||||
resourceId: row.resourceId,
|
||||
resourceKey: row.resourceKey,
|
||||
roleId: row.roleId,
|
||||
roleName: row.roleName,
|
||||
sourceDates: [getDateKey(row.assignmentDate)],
|
||||
startDate: row.assignmentDate,
|
||||
utilizationCategoryCode: row.utilizationCategoryCode,
|
||||
});
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
async function upsertRoleSeeds(tx: TxClient) {
|
||||
for (const role of DISPO_REQUIRED_ROLE_SEEDS) {
|
||||
await tx.role.upsert({
|
||||
where: { name: role.name },
|
||||
update: {
|
||||
description: role.description,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
...role,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertUtilizationCategories(tx: TxClient) {
|
||||
for (const category of DISPO_UTILIZATION_CATEGORIES) {
|
||||
await tx.utilizationCategory.upsert({
|
||||
where: { code: category.code },
|
||||
update: {
|
||||
description: category.description,
|
||||
isActive: true,
|
||||
isDefault: category.isDefault,
|
||||
name: category.name,
|
||||
sortOrder: category.sortOrder,
|
||||
},
|
||||
create: category,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deriveOverlayAvailability(
|
||||
baseAvailability: WeekdayAvailability,
|
||||
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
|
||||
): WeekdayAvailability {
|
||||
const next = { ...baseAvailability };
|
||||
const weekdayVotes = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
|
||||
if (!date) {
|
||||
continue;
|
||||
}
|
||||
const weekdayIndex = normalizeDate(date).getUTCDay();
|
||||
if (weekdayIndex === 0 || weekdayIndex === 6) {
|
||||
continue;
|
||||
}
|
||||
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
|
||||
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
|
||||
continue;
|
||||
}
|
||||
const availableHours = rule.availableHours ?? (
|
||||
rule.percentage !== null && rule.percentage !== undefined
|
||||
? roundToOneDecimal((rule.percentage / 100) * 8)
|
||||
: null
|
||||
);
|
||||
if (availableHours === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
|
||||
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
|
||||
weekdayVotes.set(weekdayKey, hoursMap);
|
||||
}
|
||||
|
||||
for (const weekdayKey of WORKDAY_KEYS) {
|
||||
const hoursMap = weekdayVotes.get(weekdayKey);
|
||||
if (!hoursMap || hoursMap.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstEntry = [...hoursMap.entries()].sort(
|
||||
(left, right) => right[1] - left[1] || left[0] - right[0],
|
||||
)[0];
|
||||
if (!firstEntry) {
|
||||
continue;
|
||||
}
|
||||
const [resolvedHours] = firstEntry;
|
||||
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function commitDispoImportBatch(
|
||||
db: CommitDbClient,
|
||||
input: CommitDispoImportBatchInput,
|
||||
): Promise<CommitDispoImportBatchResult> {
|
||||
const batch = await db.importBatch.findUnique({
|
||||
where: { id: input.importBatchId },
|
||||
select: { id: true, status: true, summary: true },
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||
}
|
||||
|
||||
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
|
||||
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
|
||||
}
|
||||
|
||||
const unresolved = await db.stagedUnresolvedRecord.findMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.UNRESOLVED,
|
||||
},
|
||||
});
|
||||
const blockingUnresolved = unresolved.filter(
|
||||
(record) => !(input.allowTbdUnresolved ?? true) || !isAllowedUnresolvedRecord(record),
|
||||
);
|
||||
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
|
||||
|
||||
if (blockingUnresolved.length > 0) {
|
||||
throw new Error(
|
||||
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
await tx.importBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
status: ImportBatchStatus.COMMITTING,
|
||||
},
|
||||
});
|
||||
|
||||
await upsertUtilizationCategories(tx);
|
||||
await upsertRoleSeeds(tx);
|
||||
|
||||
const [
|
||||
stagedResources,
|
||||
stagedProjects,
|
||||
stagedAssignments,
|
||||
stagedVacations,
|
||||
stagedAvailabilityRules,
|
||||
adminUser,
|
||||
countries,
|
||||
metroCities,
|
||||
managementLevelGroups,
|
||||
managementLevels,
|
||||
clients,
|
||||
orgUnits,
|
||||
roles,
|
||||
utilizationCategories,
|
||||
] = await Promise.all([
|
||||
tx.stagedResource.findMany({ where: { importBatchId: batch.id } }),
|
||||
tx.stagedProject.findMany({ where: { importBatchId: batch.id } }),
|
||||
tx.stagedAssignment.findMany({ where: { importBatchId: batch.id } }),
|
||||
tx.stagedVacation.findMany({ where: { importBatchId: batch.id } }),
|
||||
tx.stagedAvailabilityRule.findMany({ where: { importBatchId: batch.id } }),
|
||||
tx.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } }),
|
||||
tx.country.findMany({ select: { id: true, code: true } }),
|
||||
tx.metroCity.findMany({ select: { id: true, name: true } }),
|
||||
tx.managementLevelGroup.findMany({ select: { id: true, name: true, targetPercentage: true } }),
|
||||
tx.managementLevel.findMany({ select: { id: true, name: true } }),
|
||||
tx.client.findMany({ select: { id: true, code: true, name: true } }),
|
||||
tx.orgUnit.findMany({ select: { id: true, level: true, name: true } }),
|
||||
tx.role.findMany({ select: { id: true, name: true } }),
|
||||
tx.utilizationCategory.findMany({ select: { id: true, code: true } }),
|
||||
]);
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error("Cannot commit Dispo import without an ADMIN user in the database");
|
||||
}
|
||||
|
||||
const countryIdByCode = new Map(countries.map((country) => [country.code, country.id]));
|
||||
const metroCityIdByName = new Map(
|
||||
metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
|
||||
);
|
||||
const managementLevelGroupByName = new Map(
|
||||
managementLevelGroups.map((group) => [group.name, group]),
|
||||
);
|
||||
const managementLevelIdByName = new Map(
|
||||
managementLevels.map((level) => [level.name, level.id]),
|
||||
);
|
||||
const clientIdByCode = new Map(clients.filter((client) => client.code).map((client) => [client.code!, client.id]));
|
||||
const clientIdByName = new Map(clients.map((client) => [client.name.toLowerCase(), client.id]));
|
||||
const orgUnitIdByLevelAndName = new Map(
|
||||
orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
|
||||
);
|
||||
const roleIdByName = new Map(roles.map((role) => [role.name, role.id]));
|
||||
const utilizationCategoryIdByCode = new Map(
|
||||
utilizationCategories.map((category) => [category.code, category.id]),
|
||||
);
|
||||
|
||||
const mergedResources = mergeStagedResources(stagedResources);
|
||||
const resourceIdByKey = new Map<string, string>();
|
||||
let upsertedResourceRoles = 0;
|
||||
let updatedResourceAvailabilities = 0;
|
||||
let updatedEntitlements = 0;
|
||||
|
||||
for (const resource of mergedResources.values()) {
|
||||
const managementGroup = resource.managementLevelGroupName
|
||||
? managementLevelGroupByName.get(resource.managementLevelGroupName)
|
||||
: null;
|
||||
const defaultChargeabilityTarget = managementGroup
|
||||
? roundToOneDecimal(managementGroup.targetPercentage * 100)
|
||||
: 80;
|
||||
const availability = parseWeekdayAvailability(resource.availability, resource.fte);
|
||||
const rawPayload = resource.rawPayload;
|
||||
const orgUnitId =
|
||||
(typeof rawPayload.sapOrgUnitLevelSeven === "string"
|
||||
? orgUnitIdByLevelAndName.get(`7:${rawPayload.sapOrgUnitLevelSeven.toLowerCase()}`)
|
||||
: null) ??
|
||||
(typeof rawPayload.sapOrgUnitLevelSix === "string"
|
||||
? orgUnitIdByLevelAndName.get(`6:${rawPayload.sapOrgUnitLevelSix.toLowerCase()}`)
|
||||
: null) ??
|
||||
(typeof rawPayload.sapOrgUnitLevelFive === "string"
|
||||
? orgUnitIdByLevelAndName.get(`5:${rawPayload.sapOrgUnitLevelFive.toLowerCase()}`)
|
||||
: null) ??
|
||||
null;
|
||||
|
||||
const committed = await tx.resource.upsert({
|
||||
where: { eid: resource.canonicalExternalId },
|
||||
update: {
|
||||
availability: availability as unknown as Prisma.InputJsonValue,
|
||||
chapter: resource.chapter,
|
||||
chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
|
||||
clientUnitId: resource.clientUnitName
|
||||
? (clientIdByCode.get(resource.clientUnitName) ?? clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
|
||||
: null,
|
||||
countryId: resource.countryCode ? (countryIdByCode.get(resource.countryCode) ?? null) : null,
|
||||
displayName: resource.displayName ?? resource.canonicalExternalId,
|
||||
email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
|
||||
enterpriseId: resource.canonicalExternalId,
|
||||
fte: resource.fte ?? 1,
|
||||
lcrCents: resource.lcrCents ?? 0,
|
||||
managementLevelGroupId: resource.managementLevelGroupName
|
||||
? (managementGroup?.id ?? null)
|
||||
: null,
|
||||
managementLevelId: resource.managementLevelName
|
||||
? (managementLevelIdByName.get(resource.managementLevelName) ?? null)
|
||||
: null,
|
||||
metroCityId: resource.metroCityName
|
||||
? (metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
|
||||
: null,
|
||||
orgUnitId,
|
||||
resourceType: resource.resourceType ?? "EMPLOYEE",
|
||||
ucrCents: resource.ucrCents ?? 0,
|
||||
dynamicFields: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
sourceKinds: resource.sourceKinds,
|
||||
warnings: resource.warnings,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
availability: availability as unknown as Prisma.InputJsonValue,
|
||||
chapter: resource.chapter,
|
||||
chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
|
||||
clientUnitId: resource.clientUnitName
|
||||
? (clientIdByCode.get(resource.clientUnitName) ?? clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
|
||||
: null,
|
||||
countryId: resource.countryCode ? (countryIdByCode.get(resource.countryCode) ?? null) : null,
|
||||
displayName: resource.displayName ?? resource.canonicalExternalId,
|
||||
dynamicFields: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
sourceKinds: resource.sourceKinds,
|
||||
warnings: resource.warnings,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
eid: resource.canonicalExternalId,
|
||||
email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
|
||||
enterpriseId: resource.canonicalExternalId,
|
||||
fte: resource.fte ?? 1,
|
||||
lcrCents: resource.lcrCents ?? 0,
|
||||
ucrCents: resource.ucrCents ?? 0,
|
||||
resourceType: resource.resourceType ?? "EMPLOYEE",
|
||||
managementLevelGroupId: resource.managementLevelGroupName
|
||||
? (managementGroup?.id ?? null)
|
||||
: null,
|
||||
managementLevelId: resource.managementLevelName
|
||||
? (managementLevelIdByName.get(resource.managementLevelName) ?? null)
|
||||
: null,
|
||||
metroCityId: resource.metroCityName
|
||||
? (metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
|
||||
: null,
|
||||
orgUnitId,
|
||||
},
|
||||
select: { id: true, availability: true },
|
||||
});
|
||||
|
||||
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
|
||||
|
||||
for (const roleToken of resource.roleTokens) {
|
||||
const roleName = normalizeDispoRoleToken(roleToken);
|
||||
if (!roleName) {
|
||||
continue;
|
||||
}
|
||||
const roleId = roleIdByName.get(roleName);
|
||||
if (!roleId) {
|
||||
continue;
|
||||
}
|
||||
await tx.resourceRole.upsert({
|
||||
where: {
|
||||
resourceId_roleId: {
|
||||
resourceId: committed.id,
|
||||
roleId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
resourceId: committed.id,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
upsertedResourceRoles += 1;
|
||||
}
|
||||
|
||||
if (resource.vacationDaysPerYear !== null && resource.vacationDaysPerYear !== undefined) {
|
||||
const entitlementYears = new Set<number>(
|
||||
stagedVacations
|
||||
.filter((vacation) => vacation.resourceExternalId === resource.canonicalExternalId)
|
||||
.map((vacation) => vacation.startDate.getUTCFullYear()),
|
||||
);
|
||||
if (entitlementYears.size === 0) {
|
||||
entitlementYears.add(new Date().getUTCFullYear());
|
||||
}
|
||||
for (const year of entitlementYears) {
|
||||
await tx.vacationEntitlement.upsert({
|
||||
where: {
|
||||
resourceId_year: {
|
||||
resourceId: committed.id,
|
||||
year,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
entitledDays: resource.vacationDaysPerYear,
|
||||
},
|
||||
create: {
|
||||
resourceId: committed.id,
|
||||
year,
|
||||
entitledDays: resource.vacationDaysPerYear,
|
||||
},
|
||||
});
|
||||
updatedEntitlements += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const resourceRules = stagedAvailabilityRules.filter(
|
||||
(rule) => rule.resourceExternalId === resource.canonicalExternalId,
|
||||
);
|
||||
if (resourceRules.length > 0) {
|
||||
const overlaidAvailability = deriveOverlayAvailability(
|
||||
parseWeekdayAvailability(committed.availability, resource.fte),
|
||||
resourceRules,
|
||||
);
|
||||
await tx.resource.update({
|
||||
where: { id: committed.id },
|
||||
data: {
|
||||
availability: overlaidAvailability as unknown as Prisma.InputJsonValue,
|
||||
dynamicFields: {
|
||||
dispoImport: {
|
||||
availabilityRules: resourceRules.map((rule) => ({
|
||||
availableHours: rule.availableHours,
|
||||
effectiveEndDate: rule.effectiveEndDate?.toISOString().slice(0, 10) ?? null,
|
||||
effectiveStartDate: rule.effectiveStartDate?.toISOString().slice(0, 10) ?? null,
|
||||
percentage: rule.percentage,
|
||||
ruleType: rule.ruleType,
|
||||
})),
|
||||
importBatchId: batch.id,
|
||||
sourceKinds: resource.sourceKinds,
|
||||
warnings: resource.warnings,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
updatedResourceAvailabilities += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const projectIdByShortCode = new Map<string, string>();
|
||||
|
||||
for (const stagedProject of stagedProjects) {
|
||||
if (stagedProject.isTbd) {
|
||||
continue;
|
||||
}
|
||||
const shortCode = stagedProject.shortCode ?? stagedProject.projectKey;
|
||||
const project = await tx.project.upsert({
|
||||
where: { shortCode },
|
||||
update: {
|
||||
allocationType: stagedProject.allocationType ?? "EXT",
|
||||
budgetCents: 0,
|
||||
clientId: stagedProject.clientCode
|
||||
? (clientIdByCode.get(stagedProject.clientCode) ?? null)
|
||||
: null,
|
||||
dynamicFields: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
isInternal: stagedProject.isInternal,
|
||||
projectKey: stagedProject.projectKey,
|
||||
warnings: stagedProject.warnings,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
|
||||
name: stagedProject.name ?? shortCode,
|
||||
orderType: stagedProject.orderType ?? "CHARGEABLE",
|
||||
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
|
||||
status: ProjectStatus.ACTIVE,
|
||||
utilizationCategoryId: stagedProject.utilizationCategoryCode
|
||||
? (utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
|
||||
: null,
|
||||
winProbability: stagedProject.winProbability ?? 100,
|
||||
},
|
||||
create: {
|
||||
shortCode,
|
||||
name: stagedProject.name ?? shortCode,
|
||||
orderType: stagedProject.orderType ?? "CHARGEABLE",
|
||||
allocationType: stagedProject.allocationType ?? "EXT",
|
||||
budgetCents: 0,
|
||||
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
|
||||
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
|
||||
status: ProjectStatus.ACTIVE,
|
||||
winProbability: stagedProject.winProbability ?? 100,
|
||||
utilizationCategoryId: stagedProject.utilizationCategoryCode
|
||||
? (utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
|
||||
: null,
|
||||
clientId: stagedProject.clientCode
|
||||
? (clientIdByCode.get(stagedProject.clientCode) ?? null)
|
||||
: null,
|
||||
dynamicFields: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
isInternal: stagedProject.isInternal,
|
||||
projectKey: stagedProject.projectKey,
|
||||
warnings: stagedProject.warnings,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
projectIdByShortCode.set(shortCode, project.id);
|
||||
}
|
||||
|
||||
const aggregatedAssignments = aggregateAssignments(
|
||||
stagedAssignments,
|
||||
resourceIdByKey,
|
||||
projectIdByShortCode,
|
||||
roleIdByName,
|
||||
);
|
||||
|
||||
for (const assignment of aggregatedAssignments) {
|
||||
await tx.assignment.upsert({
|
||||
where: {
|
||||
unique_assignment: {
|
||||
resourceId: assignment.resourceId,
|
||||
projectId: assignment.projectId,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
dailyCostCents: Math.round(assignment.hoursPerDay * (
|
||||
mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0
|
||||
)),
|
||||
metadata: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
sourceDates: assignment.sourceDates,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
percentage: assignment.percentage,
|
||||
role: assignment.roleName,
|
||||
roleId: assignment.roleId,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
},
|
||||
create: {
|
||||
dailyCostCents: Math.round(assignment.hoursPerDay * (
|
||||
mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0
|
||||
)),
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
metadata: {
|
||||
dispoImport: {
|
||||
importBatchId: batch.id,
|
||||
sourceDates: assignment.sourceDates,
|
||||
utilizationCategoryCode: assignment.utilizationCategoryCode,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
percentage: assignment.percentage,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
role: assignment.roleName,
|
||||
roleId: assignment.roleId,
|
||||
startDate: assignment.startDate,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.resourceRole.upsert({
|
||||
where: {
|
||||
resourceId_roleId: {
|
||||
resourceId: assignment.resourceId,
|
||||
roleId: assignment.roleId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
resourceId: assignment.resourceId,
|
||||
roleId: assignment.roleId,
|
||||
},
|
||||
});
|
||||
upsertedResourceRoles += 1;
|
||||
}
|
||||
|
||||
for (const stagedVacation of stagedVacations) {
|
||||
const resourceId = resourceIdByKey.get(stagedVacation.resourceExternalId);
|
||||
if (!resourceId) {
|
||||
throw new Error(`Unable to resolve resource "${stagedVacation.resourceExternalId}" during vacation commit`);
|
||||
}
|
||||
|
||||
const existing = await tx.vacation.findFirst({
|
||||
where: {
|
||||
endDate: stagedVacation.endDate,
|
||||
halfDayPart: stagedVacation.halfDayPart,
|
||||
isHalfDay: stagedVacation.isHalfDay,
|
||||
note: stagedVacation.note,
|
||||
resourceId,
|
||||
startDate: stagedVacation.startDate,
|
||||
status: VacationStatus.APPROVED,
|
||||
type: stagedVacation.vacationType,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await tx.vacation.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
approvedAt: new Date(),
|
||||
approvedById: adminUser.id,
|
||||
note: stagedVacation.note,
|
||||
requestedById: adminUser.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.vacation.create({
|
||||
data: {
|
||||
approvedAt: new Date(),
|
||||
approvedById: adminUser.id,
|
||||
endDate: stagedVacation.endDate,
|
||||
halfDayPart: stagedVacation.halfDayPart,
|
||||
isHalfDay: stagedVacation.isHalfDay,
|
||||
note: stagedVacation.note,
|
||||
requestedById: adminUser.id,
|
||||
resourceId,
|
||||
startDate: stagedVacation.startDate,
|
||||
status: VacationStatus.APPROVED,
|
||||
type: stagedVacation.vacationType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
tx.stagedResource.updateMany({
|
||||
where: { importBatchId: batch.id },
|
||||
data: { status: StagedRecordStatus.COMMITTED },
|
||||
}),
|
||||
tx.stagedProject.updateMany({
|
||||
where: { importBatchId: batch.id, isTbd: false },
|
||||
data: { status: StagedRecordStatus.COMMITTED },
|
||||
}),
|
||||
tx.stagedAssignment.updateMany({
|
||||
where: { importBatchId: batch.id, isTbd: false, isUnassigned: false },
|
||||
data: { status: StagedRecordStatus.COMMITTED },
|
||||
}),
|
||||
tx.stagedVacation.updateMany({
|
||||
where: { importBatchId: batch.id },
|
||||
data: { status: StagedRecordStatus.COMMITTED },
|
||||
}),
|
||||
tx.stagedAvailabilityRule.updateMany({
|
||||
where: { importBatchId: batch.id },
|
||||
data: { status: StagedRecordStatus.COMMITTED },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nextSummary = {
|
||||
...toJsonObject(batch.summary),
|
||||
commit: {
|
||||
committedAssignments: aggregatedAssignments.length,
|
||||
committedProjects: projectIdByShortCode.size,
|
||||
committedResources: mergedResources.size,
|
||||
committedVacations: stagedVacations.length,
|
||||
skippedTbdUnresolved,
|
||||
updatedEntitlements,
|
||||
updatedResourceAvailabilities,
|
||||
upsertedResourceRoles,
|
||||
},
|
||||
};
|
||||
|
||||
await tx.importBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
committedAt: new Date(),
|
||||
status: ImportBatchStatus.COMMITTED,
|
||||
summary: buildBatchSummaryEntry(nextSummary),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
counts: {
|
||||
committedAssignments: aggregatedAssignments.length,
|
||||
committedProjects: projectIdByShortCode.size,
|
||||
committedResources: mergedResources.size,
|
||||
committedVacations: stagedVacations.length,
|
||||
updatedEntitlements,
|
||||
updatedResourceAvailabilities,
|
||||
upsertedResourceRoles,
|
||||
},
|
||||
unresolved: {
|
||||
blocked: 0,
|
||||
skippedTbd: skippedTbdUnresolved,
|
||||
},
|
||||
} satisfies CommitDispoImportBatchResult;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -29,3 +29,8 @@ export {
|
||||
type StageDispoImportBatchInput,
|
||||
type StageDispoImportBatchResult,
|
||||
} from "./stage-dispo-import-batch.js";
|
||||
export {
|
||||
commitDispoImportBatch,
|
||||
type CommitDispoImportBatchInput,
|
||||
type CommitDispoImportBatchResult,
|
||||
} from "./commit-dispo-import-batch.js";
|
||||
|
||||
Reference in New Issue
Block a user