import { DISPO_REQUIRED_ROLE_SEEDS, DISPO_UTILIZATION_CATEGORIES, normalizeDispoRoleToken, } from "@capakraken/shared"; import type { Prisma } from "@capakraken/db"; import { AllocationStatus, ImportBatchStatus, ProjectStatus, StagedRecordStatus, VacationStatus, } from "@capakraken/db"; import { buildBatchSummaryEntry, buildFallbackAccentureEmail, toJsonObject, } from "./shared.js"; import { recomputeResourceValueScores } from "../resource/recompute-resource-value-scores.js"; import { classifyDispoProject } from "./tbd-projects.js"; import type { CommitDbClient, MergedStagedResource, TxClient } from "./commit-dispo-batch-types.js"; import { validateDispoBatch } from "./validate-dispo-batch.js"; import type { ReferenceDataMaps } from "./build-dispo-maps.js"; import { buildReferenceDataMaps, inferRoleNameFromResource, mergeStagedResources, parseWeekdayAvailability, } from "./build-dispo-maps.js"; import { aggregateAssignments, deriveOverlayAvailability, } from "./determine-placement.js"; export interface CommitDispoImportBatchInput { allowTbdUnresolved?: boolean; importTbdProjects?: 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; }; } function asObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function normalizeDate(date: Date): Date { return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`); } function roundToOneDecimal(value: number): number { return Math.round(value * 10) / 10; } 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, }); } } async function ensureInferredRolesExist( tx: TxClient, mergedResources: Map, roleIdByName: Map, ) { const inferredRoleNames = new Set(); for (const resource of mergedResources.values()) { const name = inferRoleNameFromResource(resource); if (name) inferredRoleNames.add(name); } for (const roleName of inferredRoleNames) { if (roleIdByName.has(roleName)) continue; const role = await tx.role.upsert({ where: { name: roleName }, update: { description: "Imported Dispo resource role", isActive: true }, create: { name: roleName, description: "Imported Dispo resource role", isActive: true }, select: { id: true, name: true }, }); roleIdByName.set(role.name, role.id); } } function buildResourceData( resource: MergedStagedResource, maps: ReferenceDataMaps, batchId: string, ) { const managementGroup = resource.managementLevelGroupName ? maps.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" ? maps.orgUnitIdByLevelAndName.get(`7:${rawPayload.sapOrgUnitLevelSeven.toLowerCase()}`) : null) ?? (typeof rawPayload.sapOrgUnitLevelSix === "string" ? maps.orgUnitIdByLevelAndName.get(`6:${rawPayload.sapOrgUnitLevelSix.toLowerCase()}`) : null) ?? (typeof rawPayload.sapOrgUnitLevelFive === "string" ? maps.orgUnitIdByLevelAndName.get(`5:${rawPayload.sapOrgUnitLevelFive.toLowerCase()}`) : null) ?? null; const shared = { availability: availability as unknown as Prisma.InputJsonValue, chapter: resource.chapter, chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget, clientUnitId: resource.clientUnitName ? (maps.clientIdByCode.get(resource.clientUnitName) ?? maps.clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null) : null, countryId: resource.countryCode ? (maps.countryIdByCode.get(resource.countryCode) ?? null) : null, displayName: resource.displayName ?? resource.canonicalExternalId, email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId), fte: resource.fte ?? 1, lcrCents: resource.lcrCents ?? 0, managementLevelGroupId: resource.managementLevelGroupName ? (managementGroup?.id ?? null) : null, managementLevelId: resource.managementLevelName ? (maps.managementLevelIdByName.get(resource.managementLevelName) ?? null) : null, metroCityId: resource.metroCityName ? (maps.metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null) : null, orgUnitId, resourceType: resource.resourceType ?? "EMPLOYEE", ucrCents: resource.ucrCents ?? 0, } as const; const dynamicFields = { dispoImport: { importBatchId: batchId, sourceKinds: resource.sourceKinds, warnings: resource.warnings, }, } as Prisma.InputJsonValue; return { shared, dynamicFields, managementGroup }; } interface CommitResourcesResult { resourceIdByKey: Map; resourceRoleNameByKey: Map; updatedEntitlements: number; updatedResourceAvailabilities: number; upsertedResourceRoles: number; } async function commitResources( tx: TxClient, mergedResources: Map, maps: ReferenceDataMaps, batchId: string, stagedVacations: Awaited>, stagedAvailabilityRules: Awaited>, ): Promise { const resourceIdByKey = new Map(); const resourceRoleNameByKey = new Map(); let upsertedResourceRoles = 0; let updatedResourceAvailabilities = 0; let updatedEntitlements = 0; for (const resource of mergedResources.values()) { const inferredRoleName = inferRoleNameFromResource(resource); if (inferredRoleName) { resourceRoleNameByKey.set(resource.canonicalExternalId, inferredRoleName); } const { shared, dynamicFields } = buildResourceData(resource, maps, batchId); const committed = await tx.resource.upsert({ where: { eid: resource.canonicalExternalId }, update: { ...shared, enterpriseId: resource.canonicalExternalId, dynamicFields }, create: { ...shared, dynamicFields, eid: resource.canonicalExternalId, enterpriseId: resource.canonicalExternalId, }, select: { id: true, availability: true }, }); resourceIdByKey.set(resource.canonicalExternalId, committed.id); // Upsert resource roles const resourceRoleNames = new Set( Array.from(resource.roleTokens) .map((roleToken) => normalizeDispoRoleToken(roleToken)) .filter((roleName): roleName is string => Boolean(roleName)), ); if (inferredRoleName) resourceRoleNames.add(inferredRoleName); for (const roleName of resourceRoleNames) { const roleId = maps.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; } // Upsert vacation entitlements if (resource.vacationDaysPerYear !== null && resource.vacationDaysPerYear !== undefined) { const entitlementYears = new Set( stagedVacations .filter((v) => v.resourceExternalId === resource.canonicalExternalId) .map((v) => v.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; } } // Apply availability overlay rules 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: batchId, sourceKinds: resource.sourceKinds, warnings: resource.warnings, }, } as Prisma.InputJsonValue, }, }); updatedResourceAvailabilities += 1; } } return { resourceIdByKey, resourceRoleNameByKey, updatedEntitlements, updatedResourceAvailabilities, upsertedResourceRoles }; } async function commitProjects( tx: TxClient, stagedProjects: Awaited>, maps: ReferenceDataMaps, batchId: string, importTbdProjects: boolean, ): Promise> { const projectIdByShortCode = new Map(); for (const stagedProject of stagedProjects) { if (stagedProject.isTbd && !importTbdProjects) continue; const shortCode = stagedProject.shortCode ?? stagedProject.projectKey; const classification = classifyDispoProject(stagedProject.utilizationCategoryCode ?? null); const projectStatus = stagedProject.isTbd ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE; const dynamicFields = { dispoImport: { importBatchId: batchId, isInternal: stagedProject.isInternal, isTbd: stagedProject.isTbd, projectKey: stagedProject.projectKey, rawTokens: Array.isArray(asObject(stagedProject.rawPayload).rawTokens) ? asObject(stagedProject.rawPayload).rawTokens : [], warnings: stagedProject.warnings, }, } as Prisma.InputJsonValue; const project = await tx.project.upsert({ where: { shortCode }, update: { allocationType: stagedProject.allocationType ?? classification.allocationType, budgetCents: 0, clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null, dynamicFields, endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()), name: stagedProject.name ?? shortCode, orderType: stagedProject.orderType ?? classification.orderType, startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()), status: projectStatus, utilizationCategoryId: stagedProject.utilizationCategoryCode ? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null) : null, winProbability: stagedProject.winProbability ?? 100, }, create: { shortCode, name: stagedProject.name ?? shortCode, orderType: stagedProject.orderType ?? classification.orderType, allocationType: stagedProject.allocationType ?? classification.allocationType, budgetCents: 0, startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()), endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()), status: projectStatus, winProbability: stagedProject.winProbability ?? 100, utilizationCategoryId: stagedProject.utilizationCategoryCode ? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null) : null, clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null, dynamicFields, }, select: { id: true }, }); projectIdByShortCode.set(shortCode, project.id); } return projectIdByShortCode; } export async function commitDispoImportBatch( db: CommitDbClient, input: CommitDispoImportBatchInput, ): Promise { const validation = await validateDispoBatch(db, input); const result = await db.$transaction(async (tx) => { await tx.importBatch.update({ where: { id: validation.batchId }, 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: validation.batchId } }), tx.stagedProject.findMany({ where: { importBatchId: validation.batchId } }), tx.stagedAssignment.findMany({ where: { importBatchId: validation.batchId } }), tx.stagedVacation.findMany({ where: { importBatchId: validation.batchId } }), tx.stagedAvailabilityRule.findMany({ where: { importBatchId: validation.batchId } }), 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 maps = buildReferenceDataMaps({ clients, countries, managementLevelGroups, managementLevels, metroCities, orgUnits, roles, utilizationCategories, }); const mergedResources = mergeStagedResources(stagedResources); await ensureInferredRolesExist(tx, mergedResources, maps.roleIdByName); const resourceResult = await commitResources( tx, mergedResources, maps, validation.batchId, stagedVacations, stagedAvailabilityRules, ); const projectIdByShortCode = await commitProjects( tx, stagedProjects, maps, validation.batchId, input.importTbdProjects ?? false, ); // Commit assignments const aggregatedAssignments = aggregateAssignments( stagedAssignments, resourceResult.resourceIdByKey, projectIdByShortCode, maps.roleIdByName, resourceResult.resourceRoleNameByKey, input.importTbdProjects ?? false, ); for (const assignment of aggregatedAssignments) { const dailyCostCents = Math.round( assignment.hoursPerDay * (mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0), ); const metadata = { dispoImport: { importBatchId: validation.batchId, sourceDates: assignment.sourceDates, utilizationCategoryCode: assignment.utilizationCategoryCode, }, } as Prisma.InputJsonValue; await tx.assignment.upsert({ where: { unique_assignment: { resourceId: assignment.resourceId, projectId: assignment.projectId, startDate: assignment.startDate, endDate: assignment.endDate, }, }, update: { dailyCostCents, metadata, percentage: assignment.percentage, role: assignment.roleName, roleId: assignment.roleId, status: AllocationStatus.PROPOSED, }, create: { dailyCostCents, metadata, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, 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 }, }); resourceResult.upsertedResourceRoles += 1; } // Commit vacations for (const stagedVacation of stagedVacations) { const resourceId = resourceResult.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, }, }); } } // Mark staged records as committed await Promise.all([ tx.stagedResource.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }), tx.stagedProject.updateMany({ where: input.importTbdProjects ? { importBatchId: validation.batchId } : { importBatchId: validation.batchId, isTbd: false }, data: { status: StagedRecordStatus.COMMITTED }, }), tx.stagedAssignment.updateMany({ where: input.importTbdProjects ? { importBatchId: validation.batchId, isUnassigned: false } : { importBatchId: validation.batchId, isTbd: false, isUnassigned: false }, data: { status: StagedRecordStatus.COMMITTED }, }), tx.stagedVacation.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }), tx.stagedAvailabilityRule.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }), ]); await tx.importBatch.update({ where: { id: validation.batchId }, data: { committedAt: new Date(), status: ImportBatchStatus.COMMITTED, summary: buildBatchSummaryEntry({ ...toJsonObject(validation.batchSummary), commit: { committedAssignments: aggregatedAssignments.length, committedProjects: projectIdByShortCode.size, committedResources: mergedResources.size, committedVacations: stagedVacations.length, skippedTbdUnresolved: validation.skippedTbdUnresolved, updatedEntitlements: resourceResult.updatedEntitlements, updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities, upsertedResourceRoles: resourceResult.upsertedResourceRoles, }, }), }, }); return { batchId: validation.batchId, counts: { committedAssignments: aggregatedAssignments.length, committedProjects: projectIdByShortCode.size, committedResources: mergedResources.size, committedVacations: stagedVacations.length, updatedEntitlements: resourceResult.updatedEntitlements, updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities, upsertedResourceRoles: resourceResult.upsertedResourceRoles, }, unresolved: { blocked: 0, skippedTbd: validation.skippedTbdUnresolved }, } satisfies CommitDispoImportBatchResult; }, { maxWait: 30_000, timeout: 600_000 }); if ("resource" in db && db.resource) { await recomputeResourceValueScores(db); } return result; }