feat(application): add dispo import commit flow

This commit is contained in:
2026-03-14 14:45:30 +01:00
parent dd55d0e78b
commit 6a2f552ccb
4 changed files with 1342 additions and 0 deletions
@@ -0,0 +1,354 @@
import { describe, expect, it, vi } from "vitest";
import { commitDispoImportBatch } from "../index.js";
function createCommitDb(overrides: Record<string, unknown> = {}) {
const tx = {
importBatch: {
update: vi.fn().mockResolvedValue({}),
},
utilizationCategory: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([{ id: "util_chg", code: "Chg" }]),
},
role: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([{ id: "role_ad", name: "Art Director" }]),
},
stagedResource: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedProject: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedAssignment: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedVacation: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedAvailabilityRule: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findFirst: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
country: {
findMany: vi.fn().mockResolvedValue([{ id: "country_de", code: "DE" }]),
},
metroCity: {
findMany: vi.fn().mockResolvedValue([]),
},
managementLevelGroup: {
findMany: vi.fn().mockResolvedValue([]),
},
managementLevel: {
findMany: vi.fn().mockResolvedValue([]),
},
client: {
findMany: vi.fn().mockResolvedValue([{ id: "client_bmw", code: "BMW", name: "BMW" }]),
},
orgUnit: {
findMany: vi.fn().mockResolvedValue([{ id: "org_ad", level: 7, name: "Art Direction" }]),
},
resource: {
upsert: vi.fn().mockResolvedValue({
id: "res_1",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
}),
update: vi.fn().mockResolvedValue({}),
},
resourceRole: {
upsert: vi.fn().mockResolvedValue({}),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue({}),
},
project: {
upsert: vi.fn().mockResolvedValue({ id: "proj_1" }),
},
assignment: {
upsert: vi.fn().mockResolvedValue({}),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
...overrides,
};
const db = {
importBatch: {
findUnique: vi.fn().mockResolvedValue({ id: "batch_1", status: "STAGED", summary: {} }),
update: vi.fn().mockResolvedValue({}),
},
stagedUnresolvedRecord: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(async (callback: (client: typeof tx) => Promise<unknown>) => callback(tx)),
...overrides,
};
return { db, tx };
}
describe("commitDispoImportBatch", () => {
it("commits resolved staged data and keeps [tbd] unresolved rows staged", async () => {
const { db, tx } = createCommitDb();
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: 'Planning token "[tbd]" references [tbd] and requires project resolution',
resolutionHint: "Resolve [tbd] rows before final project creation",
normalizedData: { rawToken: "[tbd]" },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_charge",
sourceKind: "CHARGEABILITY",
canonicalExternalId: "ada.director",
displayName: "Ada Director",
email: null,
chapter: "Art Direction",
chargeabilityTarget: 77.5,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: null,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: null,
availability: null,
rawPayload: {},
warnings: [],
},
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "ada.director",
displayName: "Ada Director",
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: {
sapOrgUnitLevelSeven: "Art Direction",
vacationDaysPerYear: 30,
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "11035763",
projectKey: "11035763",
name: "BMW Launch",
clientCode: "BMW",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: false,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_1",
resourceExternalId: "ada.director",
projectKey: "11035763",
assignmentDate: new Date("2026-01-05T00:00:00.000Z"),
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-05T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
{
id: "sa_2",
resourceExternalId: "ada.director",
projectKey: "11035763",
assignmentDate: new Date("2026-01-06T00:00:00.000Z"),
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
]);
tx.stagedVacation.findMany.mockResolvedValue([
{
id: "sv_1",
resourceExternalId: "ada.director",
vacationType: "PUBLIC_HOLIDAY",
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-01T00:00:00.000Z"),
note: "New Year",
holidayName: "New Year",
isHalfDay: false,
halfDayPart: null,
},
]);
tx.stagedAvailabilityRule.findMany.mockResolvedValue([
{
id: "sar_1",
resourceExternalId: "ada.director",
effectiveStartDate: new Date("2026-01-05T00:00:00.000Z"),
effectiveEndDate: new Date("2026-01-05T00:00:00.000Z"),
availableHours: 4,
percentage: 50,
ruleType: "PART_TIME",
},
{
id: "sar_2",
resourceExternalId: "ada.director",
effectiveStartDate: new Date("2026-01-12T00:00:00.000Z"),
effectiveEndDate: new Date("2026-01-12T00:00:00.000Z"),
availableHours: 4,
percentage: 50,
ruleType: "PART_TIME",
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 1,
updatedEntitlements: 1,
updatedResourceAvailabilities: 1,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.resource.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
eid: "ada.director",
email: "ada.director@accenture.com",
enterpriseId: "ada.director",
lcrCents: 1000,
ucrCents: 1500,
}),
}),
);
expect(tx.resource.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
availability: expect.objectContaining({
monday: 4,
tuesday: 8,
}),
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
dailyCostCents: 4000,
endDate: new Date("2026-01-06T00:00:00.000Z"),
startDate: new Date("2026-01-05T00:00:00.000Z"),
}),
}),
);
expect(tx.vacationEntitlement.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
entitledDays: 30,
year: 2026,
}),
}),
);
expect(tx.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "COMMITTED",
}),
}),
);
});
it("blocks commit when non-[tbd] unresolved rows remain", async () => {
const { db } = createCommitDb();
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_blocker",
recordType: "ASSIGNMENT",
message: "Unable to resolve project key from planning token",
resolutionHint: "Add a WBS token",
normalizedData: { rawToken: "[BMW] Launch" },
status: "UNRESOLVED",
},
]);
await expect(
commitDispoImportBatch(db as never, { importBatchId: "batch_1" }),
).rejects.toThrow(/blocking unresolved staged record/);
expect(db.$transaction).not.toHaveBeenCalled();
});
});
+3
View File
@@ -107,7 +107,10 @@ export {
stageDispoPlanningData, stageDispoPlanningData,
stageDispoProjects, stageDispoProjects,
stageDispoImportBatch, stageDispoImportBatch,
commitDispoImportBatch,
type AssessDispoImportReadinessInput, type AssessDispoImportReadinessInput,
type CommitDispoImportBatchInput,
type CommitDispoImportBatchResult,
type DispoImportReadinessIssue, type DispoImportReadinessIssue,
type DispoImportReadinessReport, type DispoImportReadinessReport,
type StageDispoReferenceDataResult, type StageDispoReferenceDataResult,
@@ -0,0 +1,980 @@
import type { WeekdayAvailability } from "@planarchy/shared";
import {
createWeekdayAvailabilityFromFte,
DISPO_INTERNAL_PROJECT_BUCKETS,
DISPO_REQUIRED_ROLE_SEEDS,
DISPO_UTILIZATION_CATEGORIES,
normalizeDispoRoleToken,
} from "@planarchy/shared";
import type { Prisma, PrismaClient } from "@planarchy/db";
import {
AllocationStatus,
ImportBatchStatus,
ProjectStatus,
StagedRecordStatus,
VacationStatus,
} from "@planarchy/db";
import { buildBatchSummaryEntry, buildFallbackAccentureEmail, toJsonObject } from "./shared.js";
type CommitDbClient = Pick<
PrismaClient,
| "$transaction"
| "assignment"
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "project"
| "resource"
| "resourceRole"
| "role"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedProject"
| "stagedResource"
| "stagedUnresolvedRecord"
| "stagedVacation"
| "utilizationCategory"
| "user"
| "vacation"
| "vacationEntitlement"
>;
type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
interface MergedStagedResource {
availability: Prisma.InputJsonValue | null;
canonicalExternalId: string;
chapter: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string | null;
email: string | null;
fte: number | null;
lcrCents: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawPayload: Record<string, unknown>;
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
roleTokens: Set<string>;
sourceKinds: string[];
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
interface AggregatedAssignment {
endDate: Date;
hoursPerDay: number;
percentage: number;
projectId: string;
projectShortCode: string;
resourceId: string;
resourceKey: string;
roleId: string;
roleName: string;
sourceDates: string[];
startDate: Date;
utilizationCategoryCode: string | null;
}
export interface CommitDispoImportBatchInput {
allowTbdUnresolved?: boolean;
importBatchId: string;
}
export interface CommitDispoImportBatchResult {
batchId: string;
counts: {
committedAssignments: number;
committedProjects: number;
committedResources: number;
committedVacations: number;
updatedEntitlements: number;
updatedResourceAvailabilities: number;
upsertedResourceRoles: number;
};
unresolved: {
blocked: number;
skippedTbd: number;
};
}
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
function normalizeDate(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function getDateKey(date: Date): string {
return normalizeDate(date).toISOString().slice(0, 10);
}
function addDays(date: Date, days: number): Date {
const next = normalizeDate(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function roundToOneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function parseWeekdayAvailability(
value: unknown,
fallbackFte: number | null,
): WeekdayAvailability {
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
const source = asObject(value);
return {
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
};
}
function isAllowedUnresolvedRecord(
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
): boolean {
const message = record.message.toLowerCase();
const hint = (record.resolutionHint ?? "").toLowerCase();
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
return record.recordType === "PROJECT" && (
message.includes("[tbd]") ||
hint.includes("[tbd]") ||
rawToken.includes("[tbd]")
);
}
function mergeScalar<T>(
current: T | null,
incoming: T | null | undefined,
): T | null {
return incoming ?? current;
}
function mergeStagedResources(
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
): Map<string, MergedStagedResource> {
const sourcePriority = new Map([
["CHARGEABILITY", 1],
["ROSTER", 2],
]);
const ordered = [...rows].sort(
(left, right) =>
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
);
const merged = new Map<string, MergedStagedResource>();
for (const row of ordered) {
const existing = merged.get(row.canonicalExternalId);
const rawPayload = asObject(row.rawPayload);
if (!existing) {
merged.set(row.canonicalExternalId, {
availability: row.availability ?? null,
canonicalExternalId: row.canonicalExternalId,
chapter: row.chapter ?? null,
chargeabilityTarget: row.chargeabilityTarget ?? null,
clientUnitName: row.clientUnitName ?? null,
countryCode: row.countryCode ?? null,
displayName: row.displayName ?? null,
email: row.email ?? null,
fte: row.fte ?? null,
lcrCents: row.lcrCents ?? null,
managementLevelGroupName: row.managementLevelGroupName ?? null,
managementLevelName: row.managementLevelName ?? null,
metroCityName: row.metroCityName ?? null,
rawPayload,
resourceType: row.resourceType ?? null,
roleTokens: new Set(row.roleTokens),
sourceKinds: [row.sourceKind],
ucrCents: row.ucrCents ?? null,
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
? rawPayload.vacationDaysPerYear
: null,
warnings: [...row.warnings],
});
continue;
}
existing.availability = mergeScalar(existing.availability, row.availability);
existing.chapter = mergeScalar(existing.chapter, row.chapter);
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
existing.displayName = mergeScalar(existing.displayName, row.displayName);
existing.email = mergeScalar(existing.email, row.email);
existing.fte = mergeScalar(existing.fte, row.fte);
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
existing.managementLevelGroupName = mergeScalar(
existing.managementLevelGroupName,
row.managementLevelGroupName,
);
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
if (existing.availability === null && row.availability !== null) {
existing.availability = row.availability;
}
for (const roleToken of row.roleTokens) {
existing.roleTokens.add(roleToken);
}
existing.sourceKinds.push(row.sourceKind);
existing.warnings.push(...row.warnings);
existing.rawPayload = {
...existing.rawPayload,
...rawPayload,
};
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
}
}
return merged;
}
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
return (
DISPO_INTERNAL_PROJECT_BUCKETS.find(
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
)?.shortCode ?? null
);
}
function aggregateAssignments(
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
resourceIdByKey: ReadonlyMap<string, string>,
projectIdByShortCode: ReadonlyMap<string, string>,
roleIdByName: ReadonlyMap<string, string>,
): AggregatedAssignment[] {
const resolvedRows = rows
.filter((row) => !row.isUnassigned && !row.isTbd)
.map((row) => {
const projectShortCode = row.isInternal
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
: (row.projectKey ?? null);
const roleName = row.roleName ?? normalizeDispoRoleToken(row.roleToken);
const resourceId = resourceIdByKey.get(row.resourceExternalId);
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
const roleId = roleName ? roleIdByName.get(roleName) : null;
if (!resourceId) {
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
}
if (!projectShortCode || !projectId) {
throw new Error(
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (!roleName || !roleId) {
throw new Error(
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
}
return {
assignmentDate: normalizeDate(row.assignmentDate),
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId,
projectShortCode,
resourceId,
resourceKey: row.resourceExternalId,
roleId,
roleName,
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
};
})
.sort((left, right) =>
left.resourceKey.localeCompare(right.resourceKey) ||
left.projectShortCode.localeCompare(right.projectShortCode) ||
left.roleName.localeCompare(right.roleName) ||
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
);
const aggregated: AggregatedAssignment[] = [];
for (const row of resolvedRows) {
const previous = aggregated.at(-1);
const canMerge = previous &&
previous.resourceId === row.resourceId &&
previous.projectId === row.projectId &&
previous.roleId === row.roleId &&
previous.hoursPerDay === row.hoursPerDay &&
previous.percentage === row.percentage &&
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
if (canMerge) {
previous.endDate = row.assignmentDate;
previous.sourceDates.push(getDateKey(row.assignmentDate));
continue;
}
aggregated.push({
endDate: row.assignmentDate,
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId: row.projectId,
projectShortCode: row.projectShortCode,
resourceId: row.resourceId,
resourceKey: row.resourceKey,
roleId: row.roleId,
roleName: row.roleName,
sourceDates: [getDateKey(row.assignmentDate)],
startDate: row.assignmentDate,
utilizationCategoryCode: row.utilizationCategoryCode,
});
}
return aggregated;
}
async function upsertRoleSeeds(tx: TxClient) {
for (const role of DISPO_REQUIRED_ROLE_SEEDS) {
await tx.role.upsert({
where: { name: role.name },
update: {
description: role.description,
isActive: true,
},
create: {
...role,
isActive: true,
},
});
}
}
async function upsertUtilizationCategories(tx: TxClient) {
for (const category of DISPO_UTILIZATION_CATEGORIES) {
await tx.utilizationCategory.upsert({
where: { code: category.code },
update: {
description: category.description,
isActive: true,
isDefault: category.isDefault,
name: category.name,
sortOrder: category.sortOrder,
},
create: category,
});
}
}
function deriveOverlayAvailability(
baseAvailability: WeekdayAvailability,
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
): WeekdayAvailability {
const next = { ...baseAvailability };
const weekdayVotes = new Map<string, Map<number, number>>();
for (const rule of rules) {
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
if (!date) {
continue;
}
const weekdayIndex = normalizeDate(date).getUTCDay();
if (weekdayIndex === 0 || weekdayIndex === 6) {
continue;
}
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
continue;
}
const availableHours = rule.availableHours ?? (
rule.percentage !== null && rule.percentage !== undefined
? roundToOneDecimal((rule.percentage / 100) * 8)
: null
);
if (availableHours === null) {
continue;
}
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
weekdayVotes.set(weekdayKey, hoursMap);
}
for (const weekdayKey of WORKDAY_KEYS) {
const hoursMap = weekdayVotes.get(weekdayKey);
if (!hoursMap || hoursMap.size === 0) {
continue;
}
const firstEntry = [...hoursMap.entries()].sort(
(left, right) => right[1] - left[1] || left[0] - right[0],
)[0];
if (!firstEntry) {
continue;
}
const [resolvedHours] = firstEntry;
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
}
return next;
}
export async function commitDispoImportBatch(
db: CommitDbClient,
input: CommitDispoImportBatchInput,
): Promise<CommitDispoImportBatchResult> {
const batch = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, status: true, summary: true },
});
if (!batch) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
}
const unresolved = await db.stagedUnresolvedRecord.findMany({
where: {
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
},
});
const blockingUnresolved = unresolved.filter(
(record) => !(input.allowTbdUnresolved ?? true) || !isAllowedUnresolvedRecord(record),
);
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
if (blockingUnresolved.length > 0) {
throw new Error(
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
);
}
const result = await db.$transaction(async (tx) => {
await tx.importBatch.update({
where: { id: batch.id },
data: {
status: ImportBatchStatus.COMMITTING,
},
});
await upsertUtilizationCategories(tx);
await upsertRoleSeeds(tx);
const [
stagedResources,
stagedProjects,
stagedAssignments,
stagedVacations,
stagedAvailabilityRules,
adminUser,
countries,
metroCities,
managementLevelGroups,
managementLevels,
clients,
orgUnits,
roles,
utilizationCategories,
] = await Promise.all([
tx.stagedResource.findMany({ where: { importBatchId: batch.id } }),
tx.stagedProject.findMany({ where: { importBatchId: batch.id } }),
tx.stagedAssignment.findMany({ where: { importBatchId: batch.id } }),
tx.stagedVacation.findMany({ where: { importBatchId: batch.id } }),
tx.stagedAvailabilityRule.findMany({ where: { importBatchId: batch.id } }),
tx.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } }),
tx.country.findMany({ select: { id: true, code: true } }),
tx.metroCity.findMany({ select: { id: true, name: true } }),
tx.managementLevelGroup.findMany({ select: { id: true, name: true, targetPercentage: true } }),
tx.managementLevel.findMany({ select: { id: true, name: true } }),
tx.client.findMany({ select: { id: true, code: true, name: true } }),
tx.orgUnit.findMany({ select: { id: true, level: true, name: true } }),
tx.role.findMany({ select: { id: true, name: true } }),
tx.utilizationCategory.findMany({ select: { id: true, code: true } }),
]);
if (!adminUser) {
throw new Error("Cannot commit Dispo import without an ADMIN user in the database");
}
const countryIdByCode = new Map(countries.map((country) => [country.code, country.id]));
const metroCityIdByName = new Map(
metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
);
const managementLevelGroupByName = new Map(
managementLevelGroups.map((group) => [group.name, group]),
);
const managementLevelIdByName = new Map(
managementLevels.map((level) => [level.name, level.id]),
);
const clientIdByCode = new Map(clients.filter((client) => client.code).map((client) => [client.code!, client.id]));
const clientIdByName = new Map(clients.map((client) => [client.name.toLowerCase(), client.id]));
const orgUnitIdByLevelAndName = new Map(
orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
);
const roleIdByName = new Map(roles.map((role) => [role.name, role.id]));
const utilizationCategoryIdByCode = new Map(
utilizationCategories.map((category) => [category.code, category.id]),
);
const mergedResources = mergeStagedResources(stagedResources);
const resourceIdByKey = new Map<string, string>();
let upsertedResourceRoles = 0;
let updatedResourceAvailabilities = 0;
let updatedEntitlements = 0;
for (const resource of mergedResources.values()) {
const managementGroup = resource.managementLevelGroupName
? managementLevelGroupByName.get(resource.managementLevelGroupName)
: null;
const defaultChargeabilityTarget = managementGroup
? roundToOneDecimal(managementGroup.targetPercentage * 100)
: 80;
const availability = parseWeekdayAvailability(resource.availability, resource.fte);
const rawPayload = resource.rawPayload;
const orgUnitId =
(typeof rawPayload.sapOrgUnitLevelSeven === "string"
? orgUnitIdByLevelAndName.get(`7:${rawPayload.sapOrgUnitLevelSeven.toLowerCase()}`)
: null) ??
(typeof rawPayload.sapOrgUnitLevelSix === "string"
? orgUnitIdByLevelAndName.get(`6:${rawPayload.sapOrgUnitLevelSix.toLowerCase()}`)
: null) ??
(typeof rawPayload.sapOrgUnitLevelFive === "string"
? orgUnitIdByLevelAndName.get(`5:${rawPayload.sapOrgUnitLevelFive.toLowerCase()}`)
: null) ??
null;
const committed = await tx.resource.upsert({
where: { eid: resource.canonicalExternalId },
update: {
availability: availability as unknown as Prisma.InputJsonValue,
chapter: resource.chapter,
chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
clientUnitId: resource.clientUnitName
? (clientIdByCode.get(resource.clientUnitName) ?? clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
: null,
countryId: resource.countryCode ? (countryIdByCode.get(resource.countryCode) ?? null) : null,
displayName: resource.displayName ?? resource.canonicalExternalId,
email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
enterpriseId: resource.canonicalExternalId,
fte: resource.fte ?? 1,
lcrCents: resource.lcrCents ?? 0,
managementLevelGroupId: resource.managementLevelGroupName
? (managementGroup?.id ?? null)
: null,
managementLevelId: resource.managementLevelName
? (managementLevelIdByName.get(resource.managementLevelName) ?? null)
: null,
metroCityId: resource.metroCityName
? (metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
: null,
orgUnitId,
resourceType: resource.resourceType ?? "EMPLOYEE",
ucrCents: resource.ucrCents ?? 0,
dynamicFields: {
dispoImport: {
importBatchId: batch.id,
sourceKinds: resource.sourceKinds,
warnings: resource.warnings,
},
} as Prisma.InputJsonValue,
},
create: {
availability: availability as unknown as Prisma.InputJsonValue,
chapter: resource.chapter,
chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
clientUnitId: resource.clientUnitName
? (clientIdByCode.get(resource.clientUnitName) ?? clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
: null,
countryId: resource.countryCode ? (countryIdByCode.get(resource.countryCode) ?? null) : null,
displayName: resource.displayName ?? resource.canonicalExternalId,
dynamicFields: {
dispoImport: {
importBatchId: batch.id,
sourceKinds: resource.sourceKinds,
warnings: resource.warnings,
},
} as Prisma.InputJsonValue,
eid: resource.canonicalExternalId,
email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
enterpriseId: resource.canonicalExternalId,
fte: resource.fte ?? 1,
lcrCents: resource.lcrCents ?? 0,
ucrCents: resource.ucrCents ?? 0,
resourceType: resource.resourceType ?? "EMPLOYEE",
managementLevelGroupId: resource.managementLevelGroupName
? (managementGroup?.id ?? null)
: null,
managementLevelId: resource.managementLevelName
? (managementLevelIdByName.get(resource.managementLevelName) ?? null)
: null,
metroCityId: resource.metroCityName
? (metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
: null,
orgUnitId,
},
select: { id: true, availability: true },
});
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
for (const roleToken of resource.roleTokens) {
const roleName = normalizeDispoRoleToken(roleToken);
if (!roleName) {
continue;
}
const roleId = roleIdByName.get(roleName);
if (!roleId) {
continue;
}
await tx.resourceRole.upsert({
where: {
resourceId_roleId: {
resourceId: committed.id,
roleId,
},
},
update: {},
create: {
resourceId: committed.id,
roleId,
},
});
upsertedResourceRoles += 1;
}
if (resource.vacationDaysPerYear !== null && resource.vacationDaysPerYear !== undefined) {
const entitlementYears = new Set<number>(
stagedVacations
.filter((vacation) => vacation.resourceExternalId === resource.canonicalExternalId)
.map((vacation) => vacation.startDate.getUTCFullYear()),
);
if (entitlementYears.size === 0) {
entitlementYears.add(new Date().getUTCFullYear());
}
for (const year of entitlementYears) {
await tx.vacationEntitlement.upsert({
where: {
resourceId_year: {
resourceId: committed.id,
year,
},
},
update: {
entitledDays: resource.vacationDaysPerYear,
},
create: {
resourceId: committed.id,
year,
entitledDays: resource.vacationDaysPerYear,
},
});
updatedEntitlements += 1;
}
}
const resourceRules = stagedAvailabilityRules.filter(
(rule) => rule.resourceExternalId === resource.canonicalExternalId,
);
if (resourceRules.length > 0) {
const overlaidAvailability = deriveOverlayAvailability(
parseWeekdayAvailability(committed.availability, resource.fte),
resourceRules,
);
await tx.resource.update({
where: { id: committed.id },
data: {
availability: overlaidAvailability as unknown as Prisma.InputJsonValue,
dynamicFields: {
dispoImport: {
availabilityRules: resourceRules.map((rule) => ({
availableHours: rule.availableHours,
effectiveEndDate: rule.effectiveEndDate?.toISOString().slice(0, 10) ?? null,
effectiveStartDate: rule.effectiveStartDate?.toISOString().slice(0, 10) ?? null,
percentage: rule.percentage,
ruleType: rule.ruleType,
})),
importBatchId: batch.id,
sourceKinds: resource.sourceKinds,
warnings: resource.warnings,
},
} as Prisma.InputJsonValue,
},
});
updatedResourceAvailabilities += 1;
}
}
const projectIdByShortCode = new Map<string, string>();
for (const stagedProject of stagedProjects) {
if (stagedProject.isTbd) {
continue;
}
const shortCode = stagedProject.shortCode ?? stagedProject.projectKey;
const project = await tx.project.upsert({
where: { shortCode },
update: {
allocationType: stagedProject.allocationType ?? "EXT",
budgetCents: 0,
clientId: stagedProject.clientCode
? (clientIdByCode.get(stagedProject.clientCode) ?? null)
: null,
dynamicFields: {
dispoImport: {
importBatchId: batch.id,
isInternal: stagedProject.isInternal,
projectKey: stagedProject.projectKey,
warnings: stagedProject.warnings,
},
} as Prisma.InputJsonValue,
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
name: stagedProject.name ?? shortCode,
orderType: stagedProject.orderType ?? "CHARGEABLE",
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
status: ProjectStatus.ACTIVE,
utilizationCategoryId: stagedProject.utilizationCategoryCode
? (utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
: null,
winProbability: stagedProject.winProbability ?? 100,
},
create: {
shortCode,
name: stagedProject.name ?? shortCode,
orderType: stagedProject.orderType ?? "CHARGEABLE",
allocationType: stagedProject.allocationType ?? "EXT",
budgetCents: 0,
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
status: ProjectStatus.ACTIVE,
winProbability: stagedProject.winProbability ?? 100,
utilizationCategoryId: stagedProject.utilizationCategoryCode
? (utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
: null,
clientId: stagedProject.clientCode
? (clientIdByCode.get(stagedProject.clientCode) ?? null)
: null,
dynamicFields: {
dispoImport: {
importBatchId: batch.id,
isInternal: stagedProject.isInternal,
projectKey: stagedProject.projectKey,
warnings: stagedProject.warnings,
},
} as Prisma.InputJsonValue,
},
select: { id: true },
});
projectIdByShortCode.set(shortCode, project.id);
}
const aggregatedAssignments = aggregateAssignments(
stagedAssignments,
resourceIdByKey,
projectIdByShortCode,
roleIdByName,
);
for (const assignment of aggregatedAssignments) {
await tx.assignment.upsert({
where: {
unique_assignment: {
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignment.startDate,
endDate: assignment.endDate,
},
},
update: {
dailyCostCents: Math.round(assignment.hoursPerDay * (
mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0
)),
metadata: {
dispoImport: {
importBatchId: batch.id,
sourceDates: assignment.sourceDates,
utilizationCategoryCode: assignment.utilizationCategoryCode,
},
} as Prisma.InputJsonValue,
percentage: assignment.percentage,
role: assignment.roleName,
roleId: assignment.roleId,
status: AllocationStatus.PROPOSED,
},
create: {
dailyCostCents: Math.round(assignment.hoursPerDay * (
mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0
)),
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
metadata: {
dispoImport: {
importBatchId: batch.id,
sourceDates: assignment.sourceDates,
utilizationCategoryCode: assignment.utilizationCategoryCode,
},
} as Prisma.InputJsonValue,
percentage: assignment.percentage,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
role: assignment.roleName,
roleId: assignment.roleId,
startDate: assignment.startDate,
status: AllocationStatus.PROPOSED,
},
});
await tx.resourceRole.upsert({
where: {
resourceId_roleId: {
resourceId: assignment.resourceId,
roleId: assignment.roleId,
},
},
update: {},
create: {
resourceId: assignment.resourceId,
roleId: assignment.roleId,
},
});
upsertedResourceRoles += 1;
}
for (const stagedVacation of stagedVacations) {
const resourceId = resourceIdByKey.get(stagedVacation.resourceExternalId);
if (!resourceId) {
throw new Error(`Unable to resolve resource "${stagedVacation.resourceExternalId}" during vacation commit`);
}
const existing = await tx.vacation.findFirst({
where: {
endDate: stagedVacation.endDate,
halfDayPart: stagedVacation.halfDayPart,
isHalfDay: stagedVacation.isHalfDay,
note: stagedVacation.note,
resourceId,
startDate: stagedVacation.startDate,
status: VacationStatus.APPROVED,
type: stagedVacation.vacationType,
},
select: { id: true },
});
if (existing) {
await tx.vacation.update({
where: { id: existing.id },
data: {
approvedAt: new Date(),
approvedById: adminUser.id,
note: stagedVacation.note,
requestedById: adminUser.id,
},
});
} else {
await tx.vacation.create({
data: {
approvedAt: new Date(),
approvedById: adminUser.id,
endDate: stagedVacation.endDate,
halfDayPart: stagedVacation.halfDayPart,
isHalfDay: stagedVacation.isHalfDay,
note: stagedVacation.note,
requestedById: adminUser.id,
resourceId,
startDate: stagedVacation.startDate,
status: VacationStatus.APPROVED,
type: stagedVacation.vacationType,
},
});
}
}
await Promise.all([
tx.stagedResource.updateMany({
where: { importBatchId: batch.id },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedProject.updateMany({
where: { importBatchId: batch.id, isTbd: false },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedAssignment.updateMany({
where: { importBatchId: batch.id, isTbd: false, isUnassigned: false },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedVacation.updateMany({
where: { importBatchId: batch.id },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedAvailabilityRule.updateMany({
where: { importBatchId: batch.id },
data: { status: StagedRecordStatus.COMMITTED },
}),
]);
const nextSummary = {
...toJsonObject(batch.summary),
commit: {
committedAssignments: aggregatedAssignments.length,
committedProjects: projectIdByShortCode.size,
committedResources: mergedResources.size,
committedVacations: stagedVacations.length,
skippedTbdUnresolved,
updatedEntitlements,
updatedResourceAvailabilities,
upsertedResourceRoles,
},
};
await tx.importBatch.update({
where: { id: batch.id },
data: {
committedAt: new Date(),
status: ImportBatchStatus.COMMITTED,
summary: buildBatchSummaryEntry(nextSummary),
},
});
return {
batchId: batch.id,
counts: {
committedAssignments: aggregatedAssignments.length,
committedProjects: projectIdByShortCode.size,
committedResources: mergedResources.size,
committedVacations: stagedVacations.length,
updatedEntitlements,
updatedResourceAvailabilities,
upsertedResourceRoles,
},
unresolved: {
blocked: 0,
skippedTbd: skippedTbdUnresolved,
},
} satisfies CommitDispoImportBatchResult;
});
return result;
}
@@ -29,3 +29,8 @@ export {
type StageDispoImportBatchInput, type StageDispoImportBatchInput,
type StageDispoImportBatchResult, type StageDispoImportBatchResult,
} from "./stage-dispo-import-batch.js"; } from "./stage-dispo-import-batch.js";
export {
commitDispoImportBatch,
type CommitDispoImportBatchInput,
type CommitDispoImportBatchResult,
} from "./commit-dispo-import-batch.js";