feat(application): complete dispo import operator flow

This commit is contained in:
2026-03-14 15:14:55 +01:00
parent 6a2f552ccb
commit 4dabb9d4ce
11 changed files with 1034 additions and 32 deletions
@@ -14,7 +14,12 @@ import {
StagedRecordStatus,
VacationStatus,
} from "@planarchy/db";
import { buildBatchSummaryEntry, buildFallbackAccentureEmail, toJsonObject } from "./shared.js";
import {
buildBatchSummaryEntry,
buildFallbackAccentureEmail,
deriveRoleTokens,
toJsonObject,
} from "./shared.js";
type CommitDbClient = Pick<
PrismaClient,
@@ -83,6 +88,53 @@ interface AggregatedAssignment {
utilizationCategoryCode: string | null;
}
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeFallbackRoleName(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function inferRoleNameFromResource(resource: MergedStagedResource): string | null {
const explicitRoleName = Array.from(resource.roleTokens)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (explicitRoleName) {
return explicitRoleName;
}
const derivedRoleName = deriveRoleTokens(
resource.chapter,
asNullableString(resource.rawPayload.department),
asNullableString(resource.rawPayload.mainSkillset),
asNullableString(resource.rawPayload.sapOrgUnitLevelSix),
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven),
asNullableString(resource.rawPayload.sapEmployeeName),
)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (derivedRoleName) {
return derivedRoleName;
}
if (resource.chapter === "Art Direction") {
return "Art Director";
}
if (resource.chapter === "Project Management") {
return "Project Manager";
}
const fallbackRoleLabel =
asNullableString(resource.rawPayload.department) ??
asNullableString(resource.rawPayload.mainSkillset) ??
asNullableString(resource.chapter) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSix);
return fallbackRoleLabel ? normalizeFallbackRoleName(fallbackRoleLabel) : null;
}
export interface CommitDispoImportBatchInput {
allowTbdUnresolved?: boolean;
importBatchId: string;
@@ -268,6 +320,7 @@ function aggregateAssignments(
resourceIdByKey: ReadonlyMap<string, string>,
projectIdByShortCode: ReadonlyMap<string, string>,
roleIdByName: ReadonlyMap<string, string>,
resourceRoleNameByKey: ReadonlyMap<string, string>,
): AggregatedAssignment[] {
const resolvedRows = rows
.filter((row) => !row.isUnassigned && !row.isTbd)
@@ -275,7 +328,11 @@ function aggregateAssignments(
const projectShortCode = row.isInternal
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
: (row.projectKey ?? null);
const roleName = row.roleName ?? normalizeDispoRoleToken(row.roleToken);
const roleName =
row.roleName ??
normalizeDispoRoleToken(row.roleToken) ??
resourceRoleNameByKey.get(row.resourceExternalId) ??
null;
const resourceId = resourceIdByKey.get(row.resourceExternalId);
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
const roleId = roleName ? roleIdByName.get(roleName) : null;
@@ -541,12 +598,43 @@ export async function commitDispoImportBatch(
);
const mergedResources = mergeStagedResources(stagedResources);
const inferredRoleNames = new Set<string>();
for (const resource of mergedResources.values()) {
const inferredRoleName = inferRoleNameFromResource(resource);
if (inferredRoleName) {
inferredRoleNames.add(inferredRoleName);
}
}
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);
}
const resourceIdByKey = new Map<string, string>();
const resourceRoleNameByKey = new Map<string, string>();
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 managementGroup = resource.managementLevelGroupName
? managementLevelGroupByName.get(resource.managementLevelGroupName)
: null;
@@ -641,11 +729,16 @@ export async function commitDispoImportBatch(
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
for (const roleToken of resource.roleTokens) {
const roleName = normalizeDispoRoleToken(roleToken);
if (!roleName) {
continue;
}
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 = roleIdByName.get(roleName);
if (!roleId) {
continue;
@@ -796,6 +889,7 @@ export async function commitDispoImportBatch(
resourceIdByKey,
projectIdByShortCode,
roleIdByName,
resourceRoleNameByKey,
);
for (const assignment of aggregatedAssignments) {
@@ -974,6 +1068,9 @@ export async function commitDispoImportBatch(
skippedTbd: skippedTbdUnresolved,
},
} satisfies CommitDispoImportBatchResult;
}, {
maxWait: 30_000,
timeout: 600_000,
});
return result;