diff --git a/packages/application/src/__tests__/commit-dispo-import-batch.test.ts b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts new file mode 100644 index 0000000..20a1e24 --- /dev/null +++ b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from "vitest"; +import { commitDispoImportBatch } from "../index.js"; + +function createCommitDb(overrides: Record = {}) { + const tx = { + importBatch: { + update: vi.fn().mockResolvedValue({}), + }, + utilizationCategory: { + upsert: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([{ id: "util_chg", code: "Chg" }]), + }, + role: { + upsert: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([{ id: "role_ad", name: "Art Director" }]), + }, + stagedResource: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + stagedProject: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + stagedAssignment: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + stagedVacation: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + stagedAvailabilityRule: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + user: { + findFirst: vi.fn().mockResolvedValue({ id: "admin_1" }), + }, + country: { + findMany: vi.fn().mockResolvedValue([{ id: "country_de", code: "DE" }]), + }, + metroCity: { + findMany: vi.fn().mockResolvedValue([]), + }, + managementLevelGroup: { + findMany: vi.fn().mockResolvedValue([]), + }, + managementLevel: { + findMany: vi.fn().mockResolvedValue([]), + }, + client: { + findMany: vi.fn().mockResolvedValue([{ id: "client_bmw", code: "BMW", name: "BMW" }]), + }, + orgUnit: { + findMany: vi.fn().mockResolvedValue([{ id: "org_ad", level: 7, name: "Art Direction" }]), + }, + resource: { + upsert: vi.fn().mockResolvedValue({ + id: "res_1", + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + }), + update: vi.fn().mockResolvedValue({}), + }, + resourceRole: { + upsert: vi.fn().mockResolvedValue({}), + }, + vacationEntitlement: { + upsert: vi.fn().mockResolvedValue({}), + }, + project: { + upsert: vi.fn().mockResolvedValue({ id: "proj_1" }), + }, + assignment: { + upsert: vi.fn().mockResolvedValue({}), + }, + vacation: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + }, + ...overrides, + }; + + const db = { + importBatch: { + findUnique: vi.fn().mockResolvedValue({ id: "batch_1", status: "STAGED", summary: {} }), + update: vi.fn().mockResolvedValue({}), + }, + stagedUnresolvedRecord: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn(async (callback: (client: typeof tx) => Promise) => callback(tx)), + ...overrides, + }; + + return { db, tx }; +} + +describe("commitDispoImportBatch", () => { + it("commits resolved staged data and keeps [tbd] unresolved rows staged", async () => { + const { db, tx } = createCommitDb(); + + db.stagedUnresolvedRecord.findMany.mockResolvedValue([ + { + id: "unresolved_1", + recordType: "PROJECT", + message: 'Planning token "[tbd]" references [tbd] and requires project resolution', + resolutionHint: "Resolve [tbd] rows before final project creation", + normalizedData: { rawToken: "[tbd]" }, + status: "UNRESOLVED", + }, + ]); + + tx.stagedResource.findMany.mockResolvedValue([ + { + id: "sr_charge", + sourceKind: "CHARGEABILITY", + canonicalExternalId: "ada.director", + displayName: "Ada Director", + email: null, + chapter: "Art Direction", + chargeabilityTarget: 77.5, + clientUnitName: null, + countryCode: "DE", + fte: 1, + lcrCents: null, + managementLevelGroupName: null, + managementLevelName: null, + metroCityName: null, + resourceType: "EMPLOYEE", + roleTokens: ["AD"], + ucrCents: null, + availability: null, + rawPayload: {}, + warnings: [], + }, + { + id: "sr_roster", + sourceKind: "ROSTER", + canonicalExternalId: "ada.director", + displayName: "Ada Director", + email: null, + chapter: "Art Direction", + chargeabilityTarget: null, + clientUnitName: null, + countryCode: "DE", + fte: 1, + lcrCents: 1000, + managementLevelGroupName: null, + managementLevelName: null, + metroCityName: null, + resourceType: "EMPLOYEE", + roleTokens: ["AD"], + ucrCents: 1500, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + rawPayload: { + sapOrgUnitLevelSeven: "Art Direction", + vacationDaysPerYear: 30, + }, + warnings: [], + }, + ]); + + tx.stagedProject.findMany.mockResolvedValue([ + { + id: "sp_1", + shortCode: "11035763", + projectKey: "11035763", + name: "BMW Launch", + clientCode: "BMW", + utilizationCategoryCode: "Chg", + allocationType: "EXT", + orderType: "CHARGEABLE", + winProbability: 100, + isInternal: false, + isTbd: false, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + warnings: [], + }, + ]); + + tx.stagedAssignment.findMany.mockResolvedValue([ + { + id: "sa_1", + resourceExternalId: "ada.director", + projectKey: "11035763", + assignmentDate: new Date("2026-01-05T00:00:00.000Z"), + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-05T00:00:00.000Z"), + hoursPerDay: 4, + percentage: 50, + roleToken: "AD", + roleName: "Art Director", + utilizationCategoryCode: "Chg", + isInternal: false, + isUnassigned: false, + isTbd: false, + }, + { + id: "sa_2", + resourceExternalId: "ada.director", + projectKey: "11035763", + assignmentDate: new Date("2026-01-06T00:00:00.000Z"), + startDate: new Date("2026-01-06T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + hoursPerDay: 4, + percentage: 50, + roleToken: "AD", + roleName: "Art Director", + utilizationCategoryCode: "Chg", + isInternal: false, + isUnassigned: false, + isTbd: false, + }, + ]); + + tx.stagedVacation.findMany.mockResolvedValue([ + { + id: "sv_1", + resourceExternalId: "ada.director", + vacationType: "PUBLIC_HOLIDAY", + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-01-01T00:00:00.000Z"), + note: "New Year", + holidayName: "New Year", + isHalfDay: false, + halfDayPart: null, + }, + ]); + + tx.stagedAvailabilityRule.findMany.mockResolvedValue([ + { + id: "sar_1", + resourceExternalId: "ada.director", + effectiveStartDate: new Date("2026-01-05T00:00:00.000Z"), + effectiveEndDate: new Date("2026-01-05T00:00:00.000Z"), + availableHours: 4, + percentage: 50, + ruleType: "PART_TIME", + }, + { + id: "sar_2", + resourceExternalId: "ada.director", + effectiveStartDate: new Date("2026-01-12T00:00:00.000Z"), + effectiveEndDate: new Date("2026-01-12T00:00:00.000Z"), + availableHours: 4, + percentage: 50, + ruleType: "PART_TIME", + }, + ]); + + const result = await commitDispoImportBatch(db as never, { + importBatchId: "batch_1", + }); + + expect(result).toEqual({ + batchId: "batch_1", + counts: { + committedAssignments: 1, + committedProjects: 1, + committedResources: 1, + committedVacations: 1, + updatedEntitlements: 1, + updatedResourceAvailabilities: 1, + upsertedResourceRoles: 2, + }, + unresolved: { + blocked: 0, + skippedTbd: 1, + }, + }); + + expect(tx.resource.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + eid: "ada.director", + email: "ada.director@accenture.com", + enterpriseId: "ada.director", + lcrCents: 1000, + ucrCents: 1500, + }), + }), + ); + expect(tx.resource.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + availability: expect.objectContaining({ + monday: 4, + tuesday: 8, + }), + }), + }), + ); + expect(tx.assignment.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + dailyCostCents: 4000, + endDate: new Date("2026-01-06T00:00:00.000Z"), + startDate: new Date("2026-01-05T00:00:00.000Z"), + }), + }), + ); + expect(tx.vacationEntitlement.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + entitledDays: 30, + year: 2026, + }), + }), + ); + expect(tx.importBatch.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "COMMITTED", + }), + }), + ); + }); + + it("blocks commit when non-[tbd] unresolved rows remain", async () => { + const { db } = createCommitDb(); + + db.stagedUnresolvedRecord.findMany.mockResolvedValue([ + { + id: "unresolved_blocker", + recordType: "ASSIGNMENT", + message: "Unable to resolve project key from planning token", + resolutionHint: "Add a WBS token", + normalizedData: { rawToken: "[BMW] Launch" }, + status: "UNRESOLVED", + }, + ]); + + await expect( + commitDispoImportBatch(db as never, { importBatchId: "batch_1" }), + ).rejects.toThrow(/blocking unresolved staged record/); + + expect(db.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index b6651d3..c42500b 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -107,7 +107,10 @@ export { stageDispoPlanningData, stageDispoProjects, stageDispoImportBatch, + commitDispoImportBatch, type AssessDispoImportReadinessInput, + type CommitDispoImportBatchInput, + type CommitDispoImportBatchResult, type DispoImportReadinessIssue, type DispoImportReadinessReport, type StageDispoReferenceDataResult, diff --git a/packages/application/src/use-cases/dispo-import/commit-dispo-import-batch.ts b/packages/application/src/use-cases/dispo-import/commit-dispo-import-batch.ts new file mode 100644 index 0000000..f6989a7 --- /dev/null +++ b/packages/application/src/use-cases/dispo-import/commit-dispo-import-batch.ts @@ -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[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; + resourceType: NonNullable>[number]["resourceType"]> | null; + roleTokens: Set; + 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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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>[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( + current: T | null, + incoming: T | null | undefined, +): T | null { + return incoming ?? current; +} + +function mergeStagedResources( + rows: Awaited>, +): Map { + 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(); + + 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>, + resourceIdByKey: ReadonlyMap, + projectIdByShortCode: ReadonlyMap, + roleIdByName: ReadonlyMap, +): 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>, +): WeekdayAvailability { + const next = { ...baseAvailability }; + const weekdayVotes = new Map>(); + + 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(); + 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 { + 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(); + 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( + 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(); + + 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; +} diff --git a/packages/application/src/use-cases/dispo-import/index.ts b/packages/application/src/use-cases/dispo-import/index.ts index 67fc0f7..3dc9678 100644 --- a/packages/application/src/use-cases/dispo-import/index.ts +++ b/packages/application/src/use-cases/dispo-import/index.ts @@ -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";