From 4dabb9d4ced266623073037aae5d269e482c7109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 14 Mar 2026 15:14:55 +0100 Subject: [PATCH] feat(application): complete dispo import operator flow --- package.json | 2 + .../commit-dispo-import-batch.test.ts | 197 ++++++++ .../dispo-import-role-derivation.test.ts | 13 + .../dispo-import/commit-dispo-import-batch.ts | 111 ++++- .../dispo-import/parse-dispo-matrix.ts | 193 +++++++- .../parse-dispo-roster-workbook.ts | 11 +- .../use-cases/dispo-import/read-workbook.ts | 9 +- .../src/use-cases/dispo-import/shared.ts | 24 +- packages/db/package.json | 1 + packages/db/src/import-dispo-batch.test.ts | 48 ++ packages/db/src/import-dispo-batch.ts | 457 ++++++++++++++++++ 11 files changed, 1034 insertions(+), 32 deletions(-) create mode 100644 packages/application/src/__tests__/dispo-import-role-derivation.test.ts create mode 100644 packages/db/src/import-dispo-batch.test.ts create mode 100644 packages/db/src/import-dispo-batch.ts diff --git a/package.json b/package.json index 0630418..178c49e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "db:migrate": "pnpm --filter @planarchy/db db:migrate", "db:seed": "pnpm --filter @planarchy/db db:seed", "db:studio": "pnpm --filter @planarchy/db db:studio", + "db:reset:dispo": "pnpm --filter @planarchy/db db:reset:dispo", + "db:import:dispo": "pnpm --filter @planarchy/db db:import:dispo", "db:readiness:demand-assignment": "pnpm --filter @planarchy/db db:readiness:demand-assignment", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "typecheck": "turbo typecheck" diff --git a/packages/application/src/__tests__/commit-dispo-import-batch.test.ts b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts index 20a1e24..91a02df 100644 --- a/packages/application/src/__tests__/commit-dispo-import-batch.test.ts +++ b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts @@ -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", + }), + }), + ); + }); }); diff --git a/packages/application/src/__tests__/dispo-import-role-derivation.test.ts b/packages/application/src/__tests__/dispo-import-role-derivation.test.ts new file mode 100644 index 0000000..886bebf --- /dev/null +++ b/packages/application/src/__tests__/dispo-import-role-derivation.test.ts @@ -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"]); + }); +}); 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 index f6989a7..f4cf5c2 100644 --- 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 @@ -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, projectIdByShortCode: ReadonlyMap, roleIdByName: ReadonlyMap, + resourceRoleNameByKey: ReadonlyMap, ): 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(); + 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(); + const resourceRoleNameByKey = new Map(); 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; diff --git a/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts b/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts index 24d0adb..094fdc9 100644 --- a/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts +++ b/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts @@ -101,6 +101,18 @@ interface AvailabilityAccumulator { warnings: Set; } +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(); + 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(), + 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; } diff --git a/packages/application/src/use-cases/dispo-import/parse-dispo-roster-workbook.ts b/packages/application/src/use-cases/dispo-import/parse-dispo-roster-workbook.ts index 4b5d041..be9d965 100644 --- a/packages/application/src/use-cases/dispo-import/parse-dispo-roster-workbook.ts +++ b/packages/application/src/use-cases/dispo-import/parse-dispo-roster-workbook.ts @@ -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; } diff --git a/packages/application/src/use-cases/dispo-import/read-workbook.ts b/packages/application/src/use-cases/dispo-import/read-workbook.ts index e485aa8..426fb7f 100644 --- a/packages/application/src/use-cases/dispo-import/read-workbook.ts +++ b/packages/application/src/use-cases/dispo-import/read-workbook.ts @@ -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[][]; diff --git a/packages/application/src/use-cases/dispo-import/shared.ts b/packages/application/src/use-cases/dispo-import/shared.ts index 4e706af..a4b64ad 100644 --- a/packages/application/src/use-cases/dispo-import/shared.ts +++ b/packages/application/src/use-cases/dispo-import/shared.ts @@ -419,16 +419,32 @@ export function deriveRoleTokens(...values: Array): 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"); } diff --git a/packages/db/package.json b/packages/db/package.json index 26471c5..6a566c5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -15,6 +15,7 @@ "db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts", "db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts", "db:reset:dispo": "tsx src/reset-dispo-import.ts", + "db:import:dispo": "tsx src/import-dispo-batch.ts", "db:excel": "tsx src/generate-excel.ts", "db:studio": "prisma studio --schema ./prisma/schema.prisma", "db:generate": "prisma generate --schema ./prisma/schema.prisma", diff --git a/packages/db/src/import-dispo-batch.test.ts b/packages/db/src/import-dispo-batch.test.ts new file mode 100644 index 0000000..f18a6fa --- /dev/null +++ b/packages/db/src/import-dispo-batch.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseImportDispoBatchArgs } from "./import-dispo-batch.js"; + +test("parseImportDispoBatchArgs uses the agreed workbook defaults", () => { + const options = parseImportDispoBatchArgs([]); + + assert.equal(options.allowTbdUnresolved, true); + assert.equal(options.skipCommit, false); + assert.equal(options.strictSourceData, false); + assert.equal(options.previewUnresolvedLimit, 10); + assert.match(options.referenceWorkbookPath, /MandatoryDispoCategories_V3\.xlsx$/u); + assert.match(options.planningWorkbookPath, /DISPO_2026\.xlsx$/u); + assert.match( + options.chargeabilityWorkbookPath, + /20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0\.943_4Hartmut\.xlsx$/u, + ); + assert.match(options.rosterWorkbookPath ?? "", /MV_DispoRoster\.xlsx$/u); + assert.match( + options.costWorkbookPath ?? "", + /Resource Roster_MASTER_FY26_CJ_20251201\.xlsx$/u, + ); +}); + +test("parseImportDispoBatchArgs applies operator overrides", () => { + const options = parseImportDispoBatchArgs([ + "--planning-workbook", + "./custom-planning.xlsx", + "--notes", + "live test", + "--preview-unresolved", + "25", + "--skip-commit", + "--strict-source-data", + "--disallow-tbd", + "--no-roster", + "--no-cost", + ]); + + assert.match(options.planningWorkbookPath, /custom-planning\.xlsx$/u); + assert.equal(options.notes, "live test"); + assert.equal(options.previewUnresolvedLimit, 25); + assert.equal(options.skipCommit, true); + assert.equal(options.strictSourceData, true); + assert.equal(options.allowTbdUnresolved, false); + assert.equal(options.rosterWorkbookPath, undefined); + assert.equal(options.costWorkbookPath, undefined); +}); diff --git a/packages/db/src/import-dispo-batch.ts b/packages/db/src/import-dispo-batch.ts new file mode 100644 index 0000000..d5e7a9d --- /dev/null +++ b/packages/db/src/import-dispo-batch.ts @@ -0,0 +1,457 @@ +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { PrismaClient, StagedRecordStatus } from "@prisma/client"; +import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js"; + +loadWorkspaceEnv(); + +const prisma = new PrismaClient(); + +const DEFAULT_REFERENCE_WORKBOOK = resolveWorkspacePath( + "samples", + "Dispov2", + "MandatoryDispoCategories_V3.xlsx", +); +const DEFAULT_PLANNING_WORKBOOK = resolveWorkspacePath( + "samples", + "Dispov2", + "DISPO_2026.xlsx", +); +const DEFAULT_CHARGEABILITY_WORKBOOK = resolveWorkspacePath( + "samples", + "Dispov2", + "20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0.943_4Hartmut.xlsx", +); +const DEFAULT_ROSTER_WORKBOOK = resolveWorkspacePath( + "samples", + "Dispov2", + "MV_DispoRoster.xlsx", +); +const DEFAULT_COST_WORKBOOK = resolveWorkspacePath( + "samples", + "Dispov2", + "Resource Roster_MASTER_FY26_CJ_20251201.xlsx", +); + +export interface ImportDispoBatchOptions { + allowTbdUnresolved: boolean; + chargeabilityWorkbookPath: string; + costWorkbookPath: string | undefined; + notes: string | undefined; + planningWorkbookPath: string; + previewUnresolvedLimit: number; + referenceWorkbookPath: string; + rosterWorkbookPath: string | undefined; + skipCommit: boolean; + strictSourceData: boolean; +} + +interface DispoImportReadinessIssue { + code: string; + count: number; + message: string; + resolution: string; + severity: "blocker" | "warning"; +} + +interface DispoImportReadinessReport { + assignmentCount: number; + availabilityRuleCount: number; + canCommitWithFallbacks: boolean; + canCommitWithStrictSourceData: boolean; + fallbackAssumptions: string[]; + issues: DispoImportReadinessIssue[]; + projectCount: number; + resourceCount: number; + unresolvedCount: number; + vacationCount: number; +} + +interface StageDispoImportBatchInput { + chargeabilityWorkbookPath: string; + costWorkbookPath?: string; + notes?: string | null; + planningWorkbookPath: string; + referenceWorkbookPath: string; + rosterWorkbookPath?: string; +} + +interface StageDispoImportBatchResult { + batchId: string; + counts: { + stagedAssignments: number; + stagedAvailabilityRules: number; + stagedClients: number; + stagedProjects: number; + stagedResources: number; + stagedRosterResources: number; + stagedVacations: number; + unresolved: number; + }; + readiness: DispoImportReadinessReport; +} + +interface CommitDispoImportBatchInput { + allowTbdUnresolved?: boolean; + importBatchId: string; +} + +interface CommitDispoImportBatchResult { + batchId: string; + counts: { + committedAssignments: number; + committedProjects: number; + committedResources: number; + committedVacations: number; + updatedEntitlements: number; + updatedResourceAvailabilities: number; + upsertedResourceRoles: number; + }; + unresolved: { + blocked: number; + skippedTbd: number; + }; +} + +interface DispoImportModule { + stageDispoImportBatch( + db: PrismaClient, + input: StageDispoImportBatchInput, + ): Promise; + commitDispoImportBatch( + db: PrismaClient, + input: CommitDispoImportBatchInput, + ): Promise; +} + +interface UnresolvedPreviewRecord { + message: string; + projectKey: string | null; + recordType: string; + resolutionHint: string | null; + resourceExternalId: string | null; + sourceRow: number; + sourceSheet: string; +} + +function requireValue(argv: string[], index: number, flag: string): string { + const value = argv[index + 1]; + + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${flag}`); + } + + return value; +} + +export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptions { + const options: ImportDispoBatchOptions = { + allowTbdUnresolved: true, + chargeabilityWorkbookPath: DEFAULT_CHARGEABILITY_WORKBOOK, + costWorkbookPath: DEFAULT_COST_WORKBOOK, + notes: undefined, + planningWorkbookPath: DEFAULT_PLANNING_WORKBOOK, + previewUnresolvedLimit: 10, + referenceWorkbookPath: DEFAULT_REFERENCE_WORKBOOK, + rosterWorkbookPath: DEFAULT_ROSTER_WORKBOOK, + skipCommit: false, + strictSourceData: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--reference-workbook") { + options.referenceWorkbookPath = resolve(requireValue(argv, index, argument)); + index += 1; + continue; + } + + if (argument === "--planning-workbook") { + options.planningWorkbookPath = resolve(requireValue(argv, index, argument)); + index += 1; + continue; + } + + if (argument === "--chargeability-workbook") { + options.chargeabilityWorkbookPath = resolve(requireValue(argv, index, argument)); + index += 1; + continue; + } + + if (argument === "--roster-workbook") { + options.rosterWorkbookPath = resolve(requireValue(argv, index, argument)); + index += 1; + continue; + } + + if (argument === "--cost-workbook") { + options.costWorkbookPath = resolve(requireValue(argv, index, argument)); + index += 1; + continue; + } + + if (argument === "--notes") { + options.notes = requireValue(argv, index, argument); + index += 1; + continue; + } + + if (argument === "--preview-unresolved") { + const value = Number.parseInt(requireValue(argv, index, argument), 10); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Invalid value for ${argument}: expected a non-negative integer`); + } + options.previewUnresolvedLimit = value; + index += 1; + continue; + } + + if (argument === "--skip-commit") { + options.skipCommit = true; + continue; + } + + if (argument === "--strict-source-data") { + options.strictSourceData = true; + continue; + } + + if (argument === "--disallow-tbd") { + options.allowTbdUnresolved = false; + continue; + } + + if (argument === "--no-roster") { + options.rosterWorkbookPath = undefined; + continue; + } + + if (argument === "--no-cost") { + options.costWorkbookPath = undefined; + continue; + } + + if (argument === "--help" || argument === "-h") { + throw new Error(buildHelpText()); + } + + throw new Error(`Unknown argument: ${argument}`); + } + + return options; +} + +function buildHelpText() { + return [ + "Usage: pnpm --filter @planarchy/db db:import:dispo [options]", + "", + "Options:", + " --reference-workbook Override MandatoryDispoCategories workbook", + " --planning-workbook Override DISPO planning workbook", + " --chargeability-workbook Override chargeability workbook", + " --roster-workbook Override dispo roster workbook", + " --cost-workbook Override cost-rate workbook", + " --no-roster Stage without roster workbook", + " --no-cost Stage without cost-rate workbook", + " --notes Attach operator notes to the import batch", + " --preview-unresolved Print up to N unresolved rows (default 10)", + " --skip-commit Stage and assess readiness only", + " --strict-source-data Require readiness without fallback assumptions", + " --disallow-tbd Fail commit if [tbd] unresolved rows remain", + ].join("\n"); +} + +async function loadDispoImportModule(): Promise { + const modulePath = resolveWorkspacePath("packages", "application", "src", "index.ts"); + return import(pathToFileURL(modulePath).href) as Promise; +} + +function printWorkbookSources(options: ImportDispoBatchOptions) { + console.log("Dispo import sources:"); + console.log(` reference: ${options.referenceWorkbookPath}`); + console.log(` planning: ${options.planningWorkbookPath}`); + console.log(` chargeability: ${options.chargeabilityWorkbookPath}`); + console.log(` roster: ${options.rosterWorkbookPath ?? "(disabled)"}`); + console.log(` cost rates: ${options.costWorkbookPath ?? "(disabled)"}`); +} + +function printReadiness(report: DispoImportReadinessReport) { + console.log("Readiness:"); + console.log(` resources: ${report.resourceCount}`); + console.log(` assignment rows: ${report.assignmentCount}`); + console.log(` project rows: ${report.projectCount}`); + console.log(` vacations: ${report.vacationCount}`); + console.log(` availability rules: ${report.availabilityRuleCount}`); + console.log(` unresolved rows: ${report.unresolvedCount}`); + console.log(` strict commit ready: ${report.canCommitWithStrictSourceData ? "yes" : "no"}`); + console.log(` fallback commit ready:${report.canCommitWithFallbacks ? " yes" : " no"}`); + + if (report.issues.length === 0) { + console.log(" issues: none"); + return; + } + + console.log(" issues:"); + for (const issue of report.issues) { + console.log(` - [${issue.severity}] ${issue.code} (${issue.count})`); + console.log(` ${issue.message}`); + console.log(` resolution: ${issue.resolution}`); + } + + if (report.fallbackAssumptions.length > 0) { + console.log(" approved fallback assumptions required:"); + for (const assumption of report.fallbackAssumptions) { + console.log(` - ${assumption}`); + } + } +} + +async function loadUnresolvedPreview( + batchId: string, + limit: number, +): Promise { + if (limit <= 0) { + return []; + } + + return prisma.stagedUnresolvedRecord.findMany({ + where: { + importBatchId: batchId, + status: StagedRecordStatus.UNRESOLVED, + }, + orderBy: [ + { recordType: "asc" }, + { sourceSheet: "asc" }, + { sourceRow: "asc" }, + ], + take: limit, + select: { + message: true, + projectKey: true, + recordType: true, + resolutionHint: true, + resourceExternalId: true, + sourceRow: true, + sourceSheet: true, + }, + }); +} + +function printUnresolvedPreview(records: ReadonlyArray, total: number) { + if (records.length === 0) { + return; + } + + console.log(`Unresolved preview (${records.length}/${total}):`); + for (const record of records) { + const location = `${record.sourceSheet}:${record.sourceRow}`; + const identity = [record.resourceExternalId, record.projectKey].filter(Boolean).join(" | "); + console.log(` - ${record.recordType} @ ${location}${identity ? ` | ${identity}` : ""}`); + console.log(` ${record.message}`); + if (record.resolutionHint) { + console.log(` resolution: ${record.resolutionHint}`); + } + } +} + +function ensureCommitAllowed(options: ImportDispoBatchOptions, readiness: DispoImportReadinessReport) { + if (options.strictSourceData) { + if (!readiness.canCommitWithStrictSourceData) { + throw new Error("Readiness is not strict-source-data clean. Re-run without --strict-source-data or fix blockers."); + } + return; + } + + if (!readiness.canCommitWithFallbacks) { + throw new Error("Readiness has unresolved blocker issues that are not covered by the agreed fallback rules."); + } +} + +export async function runImportDispoBatch(options: ImportDispoBatchOptions) { + const dispoImport = await loadDispoImportModule(); + + printWorkbookSources(options); + console.log(""); + console.log("Staging workbook data..."); + + const stageResult = await dispoImport.stageDispoImportBatch(prisma, { + chargeabilityWorkbookPath: options.chargeabilityWorkbookPath, + ...(options.costWorkbookPath ? { costWorkbookPath: options.costWorkbookPath } : {}), + ...(options.notes ? { notes: options.notes } : {}), + planningWorkbookPath: options.planningWorkbookPath, + referenceWorkbookPath: options.referenceWorkbookPath, + ...(options.rosterWorkbookPath ? { rosterWorkbookPath: options.rosterWorkbookPath } : {}), + }); + + console.log(`Staged import batch: ${stageResult.batchId}`); + console.log("Stage counts:"); + console.log(` clients: ${stageResult.counts.stagedClients}`); + console.log(` resources: ${stageResult.counts.stagedResources}`); + console.log(` roster resources: ${stageResult.counts.stagedRosterResources}`); + console.log(` projects: ${stageResult.counts.stagedProjects}`); + console.log(` assignments: ${stageResult.counts.stagedAssignments}`); + console.log(` vacations: ${stageResult.counts.stagedVacations}`); + console.log(` availability rules: ${stageResult.counts.stagedAvailabilityRules}`); + console.log(` unresolved: ${stageResult.counts.unresolved}`); + printReadiness(stageResult.readiness); + + const unresolvedPreview = await loadUnresolvedPreview( + stageResult.batchId, + options.previewUnresolvedLimit, + ); + printUnresolvedPreview(unresolvedPreview, stageResult.readiness.unresolvedCount); + + if (options.skipCommit) { + console.log(""); + console.log("Commit skipped by operator flag."); + return { stageResult, commitResult: null }; + } + + ensureCommitAllowed(options, stageResult.readiness); + + console.log(""); + console.log("Committing staged rows into live Planarchy tables..."); + + const commitResult = await dispoImport.commitDispoImportBatch(prisma, { + allowTbdUnresolved: options.allowTbdUnresolved, + importBatchId: stageResult.batchId, + }); + + console.log(`Committed import batch: ${commitResult.batchId}`); + console.log("Commit counts:"); + console.log(` resources: ${commitResult.counts.committedResources}`); + console.log(` resource roles: ${commitResult.counts.upsertedResourceRoles}`); + console.log(` projects: ${commitResult.counts.committedProjects}`); + console.log(` assignments: ${commitResult.counts.committedAssignments}`); + console.log(` vacations/public holidays: ${commitResult.counts.committedVacations}`); + console.log(` vacation entitlements: ${commitResult.counts.updatedEntitlements}`); + console.log(` availability overlays: ${commitResult.counts.updatedResourceAvailabilities}`); + console.log(` blocked unresolved: ${commitResult.unresolved.blocked}`); + console.log(` skipped [tbd] unresolved: ${commitResult.unresolved.skippedTbd}`); + + return { stageResult, commitResult }; +} + +async function main() { + try { + const options = parseImportDispoBatchArgs(process.argv.slice(2)); + await runImportDispoBatch(options); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error(error); + } + process.exitCode = 1; + } finally { + await prisma.$disconnect(); + } +} + +const currentFile = fileURLToPath(import.meta.url); +const entryFile = process.argv[1] ? resolve(process.argv[1]) : null; + +if (entryFile === currentFile) { + void main(); +}