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