feat(application): complete dispo import operator flow
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user