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;
@@ -101,6 +101,18 @@ interface AvailabilityAccumulator {
warnings: Set<string>;
}
interface NaTokenHandlingResult {
availableHours?: number | null;
isPublicHoliday: boolean;
kind: "availability" | "assignment" | "skip" | "vacation";
note?: string | null;
percentage?: number | null;
projectKey?: string | null;
ruleType?: string;
vacationType?: VacationType;
warning?: string;
}
interface ParsedAssignmentToken {
chapterToken: string | null;
isInternal: boolean;
@@ -241,6 +253,14 @@ function extractProjectKey(token: string): string | null {
return lastToken && lastToken.toLowerCase() !== "tbd" ? lastToken : null;
}
function slugifyProjectKeyFragment(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
function extractLabel(token: string): string | null {
const stripped = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
@@ -269,6 +289,84 @@ function parsePercentage(value: string): number | null {
return null;
}
function classifyNaPlanningToken(rawToken: string): NaTokenHandlingResult {
const normalized = rawToken.toLowerCase();
const label = extractLabel(rawToken);
const { utilizationToken } = extractUtilizationToken(rawToken);
if (!rawToken.toUpperCase().startsWith("[_NA]")) {
return {
isPublicHoliday: false,
kind: "assignment",
};
}
if (normalized.includes("public holiday")) {
return {
isPublicHoliday: true,
kind: "vacation",
note: label,
vacationType: VacationType.PUBLIC_HOLIDAY,
};
}
if (normalized.includes("part-time")) {
return {
availableHours: null,
isPublicHoliday: false,
kind: "availability",
percentage: parsePercentage(rawToken),
ruleType: "PART_TIME",
};
}
if (normalized.includes("not bookable")) {
return {
availableHours: 0,
isPublicHoliday: false,
kind: "availability",
percentage: 0,
ruleType: "NOT_BOOKABLE",
};
}
if (normalized.includes("reduced spanish working hours")) {
return {
availableHours: 7,
isPublicHoliday: false,
kind: "availability",
percentage: 87.5,
ruleType: "REDUCED_SPANISH_WORKING_HOURS",
warning: "Assumed 7h/day for reduced Spanish working hours",
};
}
if (normalized.includes("parental leave")) {
return {
isPublicHoliday: false,
kind: "vacation",
note: label,
vacationType: VacationType.OTHER,
};
}
if (utilizationToken && utilizationToken !== "NA" && utilizationToken !== "UN") {
const projectKey = label ? `NA-${slugifyProjectKeyFragment(label)}` : null;
return {
isPublicHoliday: false,
kind: projectKey ? "assignment" : "skip",
note: label,
projectKey,
};
}
return {
isPublicHoliday: false,
kind: "skip",
note: label,
};
}
function buildAssignmentAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
@@ -278,7 +376,8 @@ function buildAssignmentAccumulator(
const roleName = normalizeDispoRoleToken(roleToken);
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken);
const projectKey = extractProjectKey(rawToken);
const naHandling = classifyNaPlanningToken(rawToken);
const projectKey = extractProjectKey(rawToken) ?? naHandling.projectKey ?? null;
const isTbd = /\[tbd\]/i.test(rawToken);
const isUnassigned = utilizationToken === "UN";
const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? "");
@@ -340,11 +439,21 @@ function buildAvailabilityAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
rawToken: string,
input: {
availableHours?: number | null;
percentage?: number | null;
ruleType?: string;
warning?: string;
} = {},
): AvailabilityAccumulator {
const percentage = parsePercentage(rawToken);
const percentage = input.percentage ?? parsePercentage(rawToken);
const availableHours = percentage !== null
? Math.round((percentage / 100) * 8 * 100) / 100
: 8 - SLOT_HOURS;
: (input.availableHours ?? (8 - SLOT_HOURS));
const warnings = new Set<string>();
if (input.warning) {
warnings.add(input.warning);
}
return {
availableHours,
@@ -355,9 +464,9 @@ function buildAvailabilityAccumulator(
percentage,
rawToken,
resourceExternalId: metadata.eid,
ruleType: "PART_TIME",
ruleType: input.ruleType ?? "PART_TIME",
sourceRow: 0,
warnings: new Set<string>(),
warnings,
};
}
@@ -388,7 +497,18 @@ export async function parseDispoPlanningWorkbook(
};
for (const column of planningColumns) {
const rawCellValue = normalizeNullableWorkbookValue(row[column.columnNumber - 1]);
const worksheetValue = row[column.columnNumber - 1];
if (
worksheetValue === null ||
worksheetValue === undefined ||
typeof worksheetValue === "number" ||
typeof worksheetValue === "boolean" ||
worksheetValue instanceof Date
) {
continue;
}
const rawCellValue = normalizeNullableWorkbookValue(worksheetValue);
if (!rawCellValue) {
continue;
}
@@ -396,6 +516,10 @@ export async function parseDispoPlanningWorkbook(
const rawToken = normalizePlanningToken(rawCellValue);
const normalizedToken = rawToken.toUpperCase();
if (normalizedToken === "IN DAYS") {
continue;
}
if (normalizedToken === "[_NA] WEEKEND {NA}") {
continue;
}
@@ -442,20 +566,69 @@ export async function parseDispoPlanningWorkbook(
continue;
}
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PART-TIME")) {
const naHandling = classifyNaPlanningToken(rawToken);
if (naHandling.kind === "availability") {
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`;
const existing = availabilityRules.get(key);
if (existing) {
existing.availableHours = buildAvailabilityAccumulator(column, metadata, rawToken).availableHours;
existing.percentage = buildAvailabilityAccumulator(column, metadata, rawToken).percentage;
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
});
existing.availableHours = nextAvailability.availableHours;
existing.percentage = nextAvailability.percentage;
if (naHandling.warning) {
existing.warnings.add(naHandling.warning);
}
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken);
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
});
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
}
continue;
}
if (naHandling.kind === "vacation" && naHandling.vacationType) {
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|VAC|${rawToken}`;
const existing = vacations.get(key);
if (existing) {
existing.endDate = column.assignmentDate;
if (column.halfDayPart) {
existing.halfDayParts.add(column.halfDayPart);
}
} else {
const vacation = buildVacationAccumulator(
column,
metadata,
rawToken,
naHandling.vacationType,
{
holidayName: naHandling.isPublicHoliday ? extractLabel(rawToken) : null,
isPublicHoliday: naHandling.isPublicHoliday,
note: naHandling.note ?? extractLabel(rawToken),
},
);
vacation.sourceRow = rowNumber;
if (naHandling.warning) {
vacation.warnings.add(naHandling.warning);
}
vacations.set(key, vacation);
}
continue;
}
if (naHandling.kind === "skip") {
continue;
}
if (normalizedToken.startsWith("[_UN]")) {
continue;
}
@@ -294,16 +294,7 @@ export async function parseDispoRosterWorkbook(
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
if (!eidValue) {
if (row.some((value) => normalizeText(value) !== null)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: null,
message: "Missing EID in DispoRoster row",
resolutionHint: "Populate EID before staging roster resource data",
warnings: [],
normalizedData: {},
});
warnings.push(`Ignoring DispoRoster row ${rowNumber} because EID is missing`);
}
continue;
}
@@ -1,4 +1,11 @@
import * as XLSX from "xlsx";
import XLSXModule from "xlsx";
const XLSX =
(
XLSXModule as typeof import("xlsx") & {
default?: typeof import("xlsx");
}
).default ?? (XLSXModule as typeof import("xlsx"));
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
@@ -419,16 +419,32 @@ export function deriveRoleTokens(...values: Array<string | null | undefined>): s
.join(" ")
.toUpperCase();
if (combinedValue.includes("2D")) {
if (
combinedValue.includes("2D") ||
combinedValue.includes("NUKE") ||
combinedValue.includes("PHOTOSHOP") ||
combinedValue.includes("RETOUCH") ||
combinedValue.includes("ARTWORK") ||
combinedValue.includes("MOTION DESIGN")
) {
tokenSet.add("2D");
}
if (combinedValue.includes("3D")) {
if (combinedValue.includes("3D") || combinedValue.includes("MODELING")) {
tokenSet.add("3D");
}
if (combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT")) {
if (
combinedValue.includes("PROGRAM/DELIVERY MGMT") ||
combinedValue.includes("PROJECT MANAGEMENT") ||
combinedValue.includes("PRODUCER") ||
combinedValue.includes("HEAD")
) {
tokenSet.add("PM");
}
if (combinedValue.includes("ART DIRECTION")) {
if (
combinedValue.includes("ART DIRECTION") ||
combinedValue.includes("ART DIRECTOR") ||
combinedValue.includes("DIGITAL DESIGNER")
) {
tokenSet.add("AD");
}