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
@@ -351,4 +351,201 @@ describe("commitDispoImportBatch", () => {
expect(db.$transaction).not.toHaveBeenCalled();
});
it("falls back to the staged resource role when an assignment row has no role", async () => {
const { db, tx } = createCommitDb({
role: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([
{ id: "role_ad", name: "Art Director" },
{ id: "role_pm", name: "Project Manager" },
]),
},
});
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "catharina.voelkle",
displayName: "Catharina Voelkle",
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: {
department: "Digital Designer",
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "generic",
projectKey: "generic",
name: "Generic Work",
clientCode: "BMW",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: false,
startDate: new Date("2026-03-12T00:00:00.000Z"),
endDate: new Date("2026-03-12T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_missing_role",
resourceExternalId: "catharina.voelkle",
projectKey: "generic",
assignmentDate: new Date("2026-03-12T00:00:00.000Z"),
startDate: new Date("2026-03-12T00:00:00.000Z"),
endDate: new Date("2026-03-12T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
roleToken: null,
roleName: null,
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result.counts.committedAssignments).toBe(1);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
role: "Art Director",
}),
}),
);
});
it("creates a role from the roster department when no canonical dispo role exists", async () => {
const { db, tx } = createCommitDb({
role: {
upsert: vi.fn(async ({ where }: { where: { name: string } }) => ({
id: `role_${where.name.toLowerCase().replace(/\s+/g, "_")}`,
name: where.name,
})),
findMany: vi.fn().mockResolvedValue([{ id: "role_ad", name: "Art Director" }]),
},
});
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "dennis.steinschulte",
displayName: "Dennis Steinschulte",
email: null,
chapter: "Product Data Management",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: [],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {
department: "Vis Logic",
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "INT-MO",
projectKey: "generic",
name: "Management & Operations",
clientCode: "BMW",
utilizationCategoryCode: "M&O",
allocationType: "INT",
orderType: "NONCHARGEABLE",
winProbability: 100,
isInternal: true,
isTbd: false,
startDate: new Date("2026-01-12T00:00:00.000Z"),
endDate: new Date("2026-01-12T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_missing_role",
resourceExternalId: "dennis.steinschulte",
projectKey: "generic",
assignmentDate: new Date("2026-01-12T00:00:00.000Z"),
startDate: new Date("2026-01-12T00:00:00.000Z"),
endDate: new Date("2026-01-12T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
roleToken: null,
roleName: null,
utilizationCategoryCode: "M&O",
isInternal: true,
isUnassigned: false,
isTbd: false,
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result.counts.committedAssignments).toBe(1);
expect(tx.role.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { name: "Vis Logic" },
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
role: "Vis Logic",
}),
}),
);
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { deriveRoleTokens } from "../use-cases/dispo-import/shared.js";
describe("deriveRoleTokens", () => {
it("recognizes common roster department and title variants", () => {
expect(deriveRoleTokens("2D Nuke")).toEqual(["2D"]);
expect(deriveRoleTokens("Motion Design")).toEqual(["2D"]);
expect(deriveRoleTokens("3D Modeling")).toEqual(["3D"]);
expect(deriveRoleTokens("Digital Producer")).toEqual(["PM"]);
expect(deriveRoleTokens("Head of Delivery")).toEqual(["PM"]);
expect(deriveRoleTokens("Digital Designer")).toEqual(["AD"]);
});
});
@@ -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");
}