feat(application): complete dispo import operator flow
This commit is contained in:
@@ -13,6 +13,8 @@
|
|||||||
"db:migrate": "pnpm --filter @planarchy/db db:migrate",
|
"db:migrate": "pnpm --filter @planarchy/db db:migrate",
|
||||||
"db:seed": "pnpm --filter @planarchy/db db:seed",
|
"db:seed": "pnpm --filter @planarchy/db db:seed",
|
||||||
"db:studio": "pnpm --filter @planarchy/db db:studio",
|
"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",
|
"db:readiness:demand-assignment": "pnpm --filter @planarchy/db db:readiness:demand-assignment",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
|
||||||
"typecheck": "turbo typecheck"
|
"typecheck": "turbo typecheck"
|
||||||
|
|||||||
@@ -351,4 +351,201 @@ describe("commitDispoImportBatch", () => {
|
|||||||
|
|
||||||
expect(db.$transaction).not.toHaveBeenCalled();
|
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,
|
StagedRecordStatus,
|
||||||
VacationStatus,
|
VacationStatus,
|
||||||
} from "@planarchy/db";
|
} from "@planarchy/db";
|
||||||
import { buildBatchSummaryEntry, buildFallbackAccentureEmail, toJsonObject } from "./shared.js";
|
import {
|
||||||
|
buildBatchSummaryEntry,
|
||||||
|
buildFallbackAccentureEmail,
|
||||||
|
deriveRoleTokens,
|
||||||
|
toJsonObject,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
type CommitDbClient = Pick<
|
type CommitDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
@@ -83,6 +88,53 @@ interface AggregatedAssignment {
|
|||||||
utilizationCategoryCode: string | null;
|
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 {
|
export interface CommitDispoImportBatchInput {
|
||||||
allowTbdUnresolved?: boolean;
|
allowTbdUnresolved?: boolean;
|
||||||
importBatchId: string;
|
importBatchId: string;
|
||||||
@@ -268,6 +320,7 @@ function aggregateAssignments(
|
|||||||
resourceIdByKey: ReadonlyMap<string, string>,
|
resourceIdByKey: ReadonlyMap<string, string>,
|
||||||
projectIdByShortCode: ReadonlyMap<string, string>,
|
projectIdByShortCode: ReadonlyMap<string, string>,
|
||||||
roleIdByName: ReadonlyMap<string, string>,
|
roleIdByName: ReadonlyMap<string, string>,
|
||||||
|
resourceRoleNameByKey: ReadonlyMap<string, string>,
|
||||||
): AggregatedAssignment[] {
|
): AggregatedAssignment[] {
|
||||||
const resolvedRows = rows
|
const resolvedRows = rows
|
||||||
.filter((row) => !row.isUnassigned && !row.isTbd)
|
.filter((row) => !row.isUnassigned && !row.isTbd)
|
||||||
@@ -275,7 +328,11 @@ function aggregateAssignments(
|
|||||||
const projectShortCode = row.isInternal
|
const projectShortCode = row.isInternal
|
||||||
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
|
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
|
||||||
: (row.projectKey ?? null);
|
: (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 resourceId = resourceIdByKey.get(row.resourceExternalId);
|
||||||
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
|
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
|
||||||
const roleId = roleName ? roleIdByName.get(roleName) : null;
|
const roleId = roleName ? roleIdByName.get(roleName) : null;
|
||||||
@@ -541,12 +598,43 @@ export async function commitDispoImportBatch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mergedResources = mergeStagedResources(stagedResources);
|
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 resourceIdByKey = new Map<string, string>();
|
||||||
|
const resourceRoleNameByKey = new Map<string, string>();
|
||||||
let upsertedResourceRoles = 0;
|
let upsertedResourceRoles = 0;
|
||||||
let updatedResourceAvailabilities = 0;
|
let updatedResourceAvailabilities = 0;
|
||||||
let updatedEntitlements = 0;
|
let updatedEntitlements = 0;
|
||||||
|
|
||||||
for (const resource of mergedResources.values()) {
|
for (const resource of mergedResources.values()) {
|
||||||
|
const inferredRoleName = inferRoleNameFromResource(resource);
|
||||||
|
if (inferredRoleName) {
|
||||||
|
resourceRoleNameByKey.set(resource.canonicalExternalId, inferredRoleName);
|
||||||
|
}
|
||||||
const managementGroup = resource.managementLevelGroupName
|
const managementGroup = resource.managementLevelGroupName
|
||||||
? managementLevelGroupByName.get(resource.managementLevelGroupName)
|
? managementLevelGroupByName.get(resource.managementLevelGroupName)
|
||||||
: null;
|
: null;
|
||||||
@@ -641,11 +729,16 @@ export async function commitDispoImportBatch(
|
|||||||
|
|
||||||
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
|
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
|
||||||
|
|
||||||
for (const roleToken of resource.roleTokens) {
|
const resourceRoleNames = new Set(
|
||||||
const roleName = normalizeDispoRoleToken(roleToken);
|
Array.from(resource.roleTokens)
|
||||||
if (!roleName) {
|
.map((roleToken) => normalizeDispoRoleToken(roleToken))
|
||||||
continue;
|
.filter((roleName): roleName is string => Boolean(roleName)),
|
||||||
}
|
);
|
||||||
|
if (inferredRoleName) {
|
||||||
|
resourceRoleNames.add(inferredRoleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const roleName of resourceRoleNames) {
|
||||||
const roleId = roleIdByName.get(roleName);
|
const roleId = roleIdByName.get(roleName);
|
||||||
if (!roleId) {
|
if (!roleId) {
|
||||||
continue;
|
continue;
|
||||||
@@ -796,6 +889,7 @@ export async function commitDispoImportBatch(
|
|||||||
resourceIdByKey,
|
resourceIdByKey,
|
||||||
projectIdByShortCode,
|
projectIdByShortCode,
|
||||||
roleIdByName,
|
roleIdByName,
|
||||||
|
resourceRoleNameByKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const assignment of aggregatedAssignments) {
|
for (const assignment of aggregatedAssignments) {
|
||||||
@@ -974,6 +1068,9 @@ export async function commitDispoImportBatch(
|
|||||||
skippedTbd: skippedTbdUnresolved,
|
skippedTbd: skippedTbdUnresolved,
|
||||||
},
|
},
|
||||||
} satisfies CommitDispoImportBatchResult;
|
} satisfies CommitDispoImportBatchResult;
|
||||||
|
}, {
|
||||||
|
maxWait: 30_000,
|
||||||
|
timeout: 600_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ interface AvailabilityAccumulator {
|
|||||||
warnings: Set<string>;
|
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 {
|
interface ParsedAssignmentToken {
|
||||||
chapterToken: string | null;
|
chapterToken: string | null;
|
||||||
isInternal: boolean;
|
isInternal: boolean;
|
||||||
@@ -241,6 +253,14 @@ function extractProjectKey(token: string): string | null {
|
|||||||
return lastToken && lastToken.toLowerCase() !== "tbd" ? lastToken : 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 {
|
function extractLabel(token: string): string | null {
|
||||||
const stripped = token
|
const stripped = token
|
||||||
.replace(/^(2D|3D|PM|AD)\s+/i, "")
|
.replace(/^(2D|3D|PM|AD)\s+/i, "")
|
||||||
@@ -269,6 +289,84 @@ function parsePercentage(value: string): number | null {
|
|||||||
return 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(
|
function buildAssignmentAccumulator(
|
||||||
column: PlanningColumn,
|
column: PlanningColumn,
|
||||||
metadata: PlanningRowMetadata,
|
metadata: PlanningRowMetadata,
|
||||||
@@ -278,7 +376,8 @@ function buildAssignmentAccumulator(
|
|||||||
const roleName = normalizeDispoRoleToken(roleToken);
|
const roleName = normalizeDispoRoleToken(roleToken);
|
||||||
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
|
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
|
||||||
const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken);
|
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 isTbd = /\[tbd\]/i.test(rawToken);
|
||||||
const isUnassigned = utilizationToken === "UN";
|
const isUnassigned = utilizationToken === "UN";
|
||||||
const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? "");
|
const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? "");
|
||||||
@@ -340,11 +439,21 @@ function buildAvailabilityAccumulator(
|
|||||||
column: PlanningColumn,
|
column: PlanningColumn,
|
||||||
metadata: PlanningRowMetadata,
|
metadata: PlanningRowMetadata,
|
||||||
rawToken: string,
|
rawToken: string,
|
||||||
|
input: {
|
||||||
|
availableHours?: number | null;
|
||||||
|
percentage?: number | null;
|
||||||
|
ruleType?: string;
|
||||||
|
warning?: string;
|
||||||
|
} = {},
|
||||||
): AvailabilityAccumulator {
|
): AvailabilityAccumulator {
|
||||||
const percentage = parsePercentage(rawToken);
|
const percentage = input.percentage ?? parsePercentage(rawToken);
|
||||||
const availableHours = percentage !== null
|
const availableHours = percentage !== null
|
||||||
? Math.round((percentage / 100) * 8 * 100) / 100
|
? 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 {
|
return {
|
||||||
availableHours,
|
availableHours,
|
||||||
@@ -355,9 +464,9 @@ function buildAvailabilityAccumulator(
|
|||||||
percentage,
|
percentage,
|
||||||
rawToken,
|
rawToken,
|
||||||
resourceExternalId: metadata.eid,
|
resourceExternalId: metadata.eid,
|
||||||
ruleType: "PART_TIME",
|
ruleType: input.ruleType ?? "PART_TIME",
|
||||||
sourceRow: 0,
|
sourceRow: 0,
|
||||||
warnings: new Set<string>(),
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +497,18 @@ export async function parseDispoPlanningWorkbook(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const column of planningColumns) {
|
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) {
|
if (!rawCellValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -396,6 +516,10 @@ export async function parseDispoPlanningWorkbook(
|
|||||||
const rawToken = normalizePlanningToken(rawCellValue);
|
const rawToken = normalizePlanningToken(rawCellValue);
|
||||||
const normalizedToken = rawToken.toUpperCase();
|
const normalizedToken = rawToken.toUpperCase();
|
||||||
|
|
||||||
|
if (normalizedToken === "IN DAYS") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizedToken === "[_NA] WEEKEND {NA}") {
|
if (normalizedToken === "[_NA] WEEKEND {NA}") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -442,20 +566,69 @@ export async function parseDispoPlanningWorkbook(
|
|||||||
continue;
|
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 key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`;
|
||||||
const existing = availabilityRules.get(key);
|
const existing = availabilityRules.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.availableHours = buildAvailabilityAccumulator(column, metadata, rawToken).availableHours;
|
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
|
||||||
existing.percentage = buildAvailabilityAccumulator(column, metadata, rawToken).percentage;
|
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 {
|
} 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;
|
availabilityRule.sourceRow = rowNumber;
|
||||||
availabilityRules.set(key, availabilityRule);
|
availabilityRules.set(key, availabilityRule);
|
||||||
}
|
}
|
||||||
continue;
|
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]")) {
|
if (normalizedToken.startsWith("[_UN]")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,16 +294,7 @@ export async function parseDispoRosterWorkbook(
|
|||||||
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
|
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
|
||||||
if (!eidValue) {
|
if (!eidValue) {
|
||||||
if (row.some((value) => normalizeText(value) !== null)) {
|
if (row.some((value) => normalizeText(value) !== null)) {
|
||||||
unresolved.push({
|
warnings.push(`Ignoring DispoRoster row ${rowNumber} because EID is missing`);
|
||||||
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: {},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
continue;
|
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 WorksheetCellValue = boolean | Date | number | string | null;
|
||||||
export type WorksheetMatrix = WorksheetCellValue[][];
|
export type WorksheetMatrix = WorksheetCellValue[][];
|
||||||
|
|||||||
@@ -419,16 +419,32 @@ export function deriveRoleTokens(...values: Array<string | null | undefined>): s
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.toUpperCase();
|
.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");
|
tokenSet.add("2D");
|
||||||
}
|
}
|
||||||
if (combinedValue.includes("3D")) {
|
if (combinedValue.includes("3D") || combinedValue.includes("MODELING")) {
|
||||||
tokenSet.add("3D");
|
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");
|
tokenSet.add("PM");
|
||||||
}
|
}
|
||||||
if (combinedValue.includes("ART DIRECTION")) {
|
if (
|
||||||
|
combinedValue.includes("ART DIRECTION") ||
|
||||||
|
combinedValue.includes("ART DIRECTOR") ||
|
||||||
|
combinedValue.includes("DIGITAL DESIGNER")
|
||||||
|
) {
|
||||||
tokenSet.add("AD");
|
tokenSet.add("AD");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts",
|
"db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts",
|
||||||
"db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts",
|
"db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts",
|
||||||
"db:reset:dispo": "tsx src/reset-dispo-import.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:excel": "tsx src/generate-excel.ts",
|
||||||
"db:studio": "prisma studio --schema ./prisma/schema.prisma",
|
"db:studio": "prisma studio --schema ./prisma/schema.prisma",
|
||||||
"db:generate": "prisma generate --schema ./prisma/schema.prisma",
|
"db:generate": "prisma generate --schema ./prisma/schema.prisma",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<StageDispoImportBatchResult>;
|
||||||
|
commitDispoImportBatch(
|
||||||
|
db: PrismaClient,
|
||||||
|
input: CommitDispoImportBatchInput,
|
||||||
|
): Promise<CommitDispoImportBatchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <path> Override MandatoryDispoCategories workbook",
|
||||||
|
" --planning-workbook <path> Override DISPO planning workbook",
|
||||||
|
" --chargeability-workbook <path> Override chargeability workbook",
|
||||||
|
" --roster-workbook <path> Override dispo roster workbook",
|
||||||
|
" --cost-workbook <path> Override cost-rate workbook",
|
||||||
|
" --no-roster Stage without roster workbook",
|
||||||
|
" --no-cost Stage without cost-rate workbook",
|
||||||
|
" --notes <text> Attach operator notes to the import batch",
|
||||||
|
" --preview-unresolved <count> 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<DispoImportModule> {
|
||||||
|
const modulePath = resolveWorkspacePath("packages", "application", "src", "index.ts");
|
||||||
|
return import(pathToFileURL(modulePath).href) as Promise<DispoImportModule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<UnresolvedPreviewRecord[]> {
|
||||||
|
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<UnresolvedPreviewRecord>, 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user