import path from "node:path"; import type { Prisma, PrismaClient } from "@capakraken/db"; import { DispoImportSourceKind, DispoStagedRecordType, ImportBatchStatus, ResourceType, StagedRecordStatus, } from "@capakraken/db"; import { createWeekdayAvailabilityFromFte, normalizeCanonicalResourceIdentity, normalizeDispoChapterToken, } from "@capakraken/shared"; export type DispoImportDbClient = Pick< PrismaClient, | "client" | "country" | "importBatch" | "managementLevel" | "managementLevelGroup" | "metroCity" | "orgUnit" | "stagedAssignment" | "stagedAvailabilityRule" | "stagedClient" | "stagedProject" | "stagedResource" | "stagedVacation" | "stagedUnresolvedRecord" >; export interface DispoReferenceImportInput { importBatchId?: string; notes?: string | null; referenceWorkbookPath: string; } export interface DispoChargeabilityImportInput { chargeabilityWorkbookPath: string; excludedResourceExternalIds?: string[]; importBatchId?: string; notes?: string | null; } export interface DispoRosterImportInput { costWorkbookPath?: string; importBatchId?: string; notes?: string | null; rosterWorkbookPath: string; } export interface DispoPlanningImportInput { excludedResourceExternalIds?: string[]; importBatchId?: string; notes?: string | null; planningWorkbookPath: string; } export interface ParsedCountryReference { sourceRow: number; countryCode: string; name: string; dailyWorkingHours: number; metroCities: string[]; scheduleRules?: Prisma.InputJsonValue; } export interface ParsedOrgUnitReference { sourceRow: number; level: number; name: string; parentName: string | null; sortOrder: number; } export interface ParsedManagementLevelGroupReference { sourceRow: number; name: string; targetPercentage: number; sortOrder: number; levels: string[]; } export interface ParsedClientReference { sourceColumn: string; sourceRow: number; clientCode: string | null; name: string; parentClientCode: string | null; parentName: string | null; sortOrder: number; } export interface ParsedReferenceWorkbook { clients: ParsedClientReference[]; countries: ParsedCountryReference[]; managementLevelGroups: ParsedManagementLevelGroupReference[]; orgUnits: ParsedOrgUnitReference[]; warnings: string[]; } export interface ParsedChargeabilityResource { availability: Prisma.InputJsonValue; canonicalExternalId: string; chapter: string | null; chapterCode: string | null; chargeabilityTarget: number | null; clientUnitName: string | null; countryCode: string | null; displayName: string; eid: string; email: string | null; enterpriseId: string; fte: number | null; managementLevelGroupName: string | null; managementLevelName: string | null; metroCityName: string | null; rawResourceType: string | null; resourceType: ResourceType; roleTokens: string[]; sourceRow: number; warnings: string[]; } export interface ParsedUnresolvedRecord { message: string; normalizedData: Record; projectKey?: string | null; recordType: DispoStagedRecordType; resolutionHint?: string | null; resourceExternalId?: string | null; sourceColumn?: string | null; sourceRow: number; warnings: string[]; } export interface ParsedChargeabilityWorkbook { resources: ParsedChargeabilityResource[]; unresolved: ParsedUnresolvedRecord[]; warnings: string[]; } export interface ParsedRosterResource { availability: Prisma.InputJsonValue; canonicalExternalId: string; chapter: string | null; chapterCode: string | null; clientUnitName: string | null; countryCode: string | null; dailyWorkingHoursPerFte: number | null; department: string | null; displayName: string; eid: string; email: string | null; enterpriseId: string; firstDayInDispo: Date | null; fte: number | null; lastDayInDispo: Date | null; lcrCents: number | null; mainSkillset: string | null; managementLevelGroupName: string | null; managementLevelName: string | null; metroCityName: string | null; rawResourceType: string | null; resourceHoursPerWeek: number | null; resourceType: ResourceType; rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING"; rateResolutionLevel: string | null; roleTokens: string[]; sapEmployeeName: string | null; sapOrgUnitLevelFive: string | null; sapOrgUnitLevelSix: string | null; sapOrgUnitLevelSeven: string | null; sourceRow: number; sourceSheet: string; ucrCents: number | null; vacationDaysPerYear: number | null; warnings: string[]; } export interface ParsedRosterWorkbook { excludedCanonicalExternalIds: string[]; ignoredPseudoDemandRows: number; resources: ParsedRosterResource[]; unresolved: ParsedUnresolvedRecord[]; warnings: string[]; } export interface ParsedPlanningAssignment { assignmentDate: Date; chapterToken: string | null; hoursPerDay: number; isInternal: boolean; isTbd: boolean; isUnassigned: boolean; percentage: number; projectKey: string | null; rawToken: string; resourceExternalId: string; roleName: string | null; roleToken: string | null; slotFraction: number; sourceColumn: string; sourceRow: number; utilizationCategoryCode: string | null; warnings: string[]; winProbability: number | null; } export interface ParsedPlanningVacation { endDate: Date; halfDayPart: string | null; holidayName: string | null; isHalfDay: boolean; isPublicHoliday: boolean; note: string | null; rawToken: string; resourceExternalId: string; sourceColumn: string; sourceRow: number; startDate: Date; vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK"; warnings: string[]; } export interface ParsedPlanningAvailabilityRule { availableHours: number | null; effectiveEndDate: Date | null; effectiveStartDate: Date | null; isResolved: boolean; percentage: number | null; rawToken: string; resourceExternalId: string; ruleType: string; sourceColumn: string; sourceRow: number; warnings: string[]; } export interface ParsedPlanningWorkbook { assignments: ParsedPlanningAssignment[]; availabilityRules: ParsedPlanningAvailabilityRule[]; unresolved: ParsedUnresolvedRecord[]; vacations: ParsedPlanningVacation[]; warnings: string[]; } const COUNTRY_REFERENCE_CONFIG = { "Costa Rica": { code: "CR", dailyWorkingHours: 8, }, Germany: { code: "DE", dailyWorkingHours: 8, }, Hungary: { code: "HU", dailyWorkingHours: 8, }, India: { code: "IN", dailyWorkingHours: 9, }, Italy: { code: "IT", dailyWorkingHours: 8, }, Portugal: { code: "PT", dailyWorkingHours: 8, }, Spain: { code: "ES", dailyWorkingHours: 8, scheduleRules: { type: "spain", fridayHours: 6.5, summerPeriod: { from: "07-01", to: "09-15" }, summerHours: 6.5, regularHours: 9, }, }, "United Kingdom": { code: "GB", dailyWorkingHours: 8, }, } as const satisfies Record< string, { code: string; dailyWorkingHours: number; scheduleRules?: Prisma.InputJsonValue; } >; const CLIENT_CODE_OVERRIDES = { BMW: "BMW", DAIMLER: "DAIMLER", "EXOR-STELLANTIS": "STELLANTIS", VOLKSWAGEN: "VW", "TATA MOTORS GROUP": "JLR", } as const satisfies Record; const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]); function collapseWhitespace(value: string): string { return value.trim().replace(/\s+/g, " "); } export function normalizeText(value: unknown): string | null { if (value === null || value === undefined) { return null; } const normalized = collapseWhitespace(String(value)); return normalized.length > 0 ? normalized : null; } export function normalizeNullableWorkbookValue(value: unknown): string | null { const normalized = normalizeText(value); if (!normalized) { return null; } return NULLISH_TOKENS.has(normalized) ? null : normalized; } export function buildFallbackAccentureEmail(canonicalExternalId: string): string { return `${canonicalExternalId}@accenture.com`; } export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean { return typeof value === "string" && value.toLowerCase().startsWith("demand_"); } export function sanitizeClientName(value: string): string { return collapseWhitespace(value.replace(/\s*-\s*$/, "")); } export function getWorkbookFileName(workbookPath: string): string { return path.basename(workbookPath); } export function findSectionRow( rows: ReadonlyArray>, firstCellValue: string, ): number { const normalizedTarget = firstCellValue.toLowerCase(); for (let index = 0; index < rows.length; index += 1) { const current = normalizeText(rows[index]?.[0]); if (current?.toLowerCase() === normalizedTarget) { return index + 1; } } throw new Error(`Section row "${firstCellValue}" not found`); } export function getCountryReferenceConfig(countryName: string) { return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null; } export function deriveCountryCodeFromMetroCity( metroCityName: string | null | undefined, ): string | null { if (!metroCityName) { return null; } for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) { if (metroCityName === countryName) { return config.code; } const isGermanCity = countryName === "Germany" && ["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName); const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon"; const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham"; const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica"; if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) { return config.code; } } return null; } export function normalizeClientCode(masterClientName: string): string | null { return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null; } export function ensurePercentageValue(value: number | null): number | null { if (value === null) { return null; } return value <= 1 ? Math.round(value * 10000) / 100 : value; } export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string { return enterpriseId .split(".") .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } export function deriveRoleTokens(...values: Array): string[] { const tokenSet = new Set(); const combinedValue = values .map((value) => normalizeText(value)) .filter((value): value is string => Boolean(value)) .join(" ") .toUpperCase(); if ( combinedValue.includes("2D") || combinedValue.includes("NUKE") || combinedValue.includes("PHOTOSHOP") || combinedValue.includes("RETOUCH") || combinedValue.includes("ARTWORK") || combinedValue.includes("MOTION DESIGN") ) { tokenSet.add("2D"); } if (combinedValue.includes("3D") || combinedValue.includes("MODELING")) { tokenSet.add("3D"); } if ( combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT") || combinedValue.includes("PRODUCER") || combinedValue.includes("HEAD") ) { tokenSet.add("PM"); } if ( combinedValue.includes("ART DIRECTION") || combinedValue.includes("ART DIRECTOR") || combinedValue.includes("DIGITAL DESIGNER") ) { tokenSet.add("AD"); } return Array.from(tokenSet); } export function deriveNormalizedChapter( rawChapter: string | null, roleTokens: string[], ): { chapter: string | null; chapterCode: string | null } { const firstRoleToken = roleTokens[0] ?? null; if (firstRoleToken) { const normalizedChapter = normalizeDispoChapterToken(firstRoleToken); if (normalizedChapter) { return { chapter: normalizedChapter, chapterCode: firstRoleToken, }; } } return { chapter: rawChapter, chapterCode: null, }; } export function mapChargeabilityResourceType(rawValue: string | null): { resourceType: ResourceType; warning: string | null; } { if (!rawValue) { return { resourceType: ResourceType.EMPLOYEE, warning: null, }; } const normalizedValue = rawValue.toLowerCase(); if (normalizedValue.includes("freelancer")) { return { resourceType: ResourceType.FREELANCER, warning: null }; } if (normalizedValue.includes("intern")) { return { resourceType: ResourceType.INTERN, warning: null }; } if (normalizedValue.includes("student")) { return { resourceType: ResourceType.STUDENT, warning: null }; } if (normalizedValue.includes("apprentice")) { return { resourceType: ResourceType.APPRENTICE, warning: null }; } if ( normalizedValue === "production studios" || normalizedValue === "near&offshore" || normalizedValue === "accenture" || normalizedValue === "long-term absence" ) { return { resourceType: ResourceType.EMPLOYEE, warning: null, }; } return { resourceType: ResourceType.EMPLOYEE, warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`, }; } export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue { return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue; } export function buildBatchSummaryEntry(summary: Record): Prisma.InputJsonValue { return summary as Prisma.InputJsonValue; } export async function ensureImportBatch( db: Pick, input: { chargeabilitySourceFile?: string; importBatchId?: string; notes?: string | null; planningSourceFile?: string; referenceSourceFile?: string; }, ): Promise<{ id: string; summary: Record }> { if (input.importBatchId) { const existing = await db.importBatch.findUnique({ where: { id: input.importBatchId }, select: { id: true, summary: true }, }); if (!existing) { throw new Error(`Import batch "${input.importBatchId}" not found`); } const updated = await db.importBatch.update({ where: { id: input.importBatchId }, data: { status: ImportBatchStatus.STAGING, ...(input.referenceSourceFile !== undefined ? { referenceSourceFile: input.referenceSourceFile } : {}), ...(input.chargeabilitySourceFile !== undefined ? { chargeabilitySourceFile: input.chargeabilitySourceFile } : {}), ...(input.planningSourceFile !== undefined ? { planningSourceFile: input.planningSourceFile } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), startedAt: new Date(), }, select: { id: true, summary: true }, }); return { id: updated.id, summary: toJsonObject(updated.summary), }; } const created = await db.importBatch.create({ data: { sourceSystem: "DISPO_V2", status: ImportBatchStatus.STAGING, ...(input.referenceSourceFile !== undefined ? { referenceSourceFile: input.referenceSourceFile } : {}), ...(input.chargeabilitySourceFile !== undefined ? { chargeabilitySourceFile: input.chargeabilitySourceFile } : {}), ...(input.planningSourceFile !== undefined ? { planningSourceFile: input.planningSourceFile } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), startedAt: new Date(), }, select: { id: true, summary: true }, }); return { id: created.id, summary: toJsonObject(created.summary), }; } export async function finalizeImportBatchStage( db: Pick, input: { batchId: string; existingSummary: Record; key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster"; summary: Record; }, ) { const nextSummary = { ...input.existingSummary, [input.key]: input.summary, }; await db.importBatch.update({ where: { id: input.batchId }, data: { status: ImportBatchStatus.STAGED, stagedAt: new Date(), summary: buildBatchSummaryEntry(nextSummary), }, }); } export function toJsonObject(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record; } return {}; } export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null { return value ? normalizeCanonicalResourceIdentity(value) : null; } export function createSourceTrace( sourceKind: DispoImportSourceKind, sourceWorkbook: string, sourceSheet: string, sourceRow: number, sourceColumn?: string | null, ) { return { sourceKind, sourceWorkbook, sourceSheet, sourceRow, ...(sourceColumn !== undefined ? { sourceColumn } : {}), }; } export const DISPO_REFERENCE_SHEET = "EID-Attr"; export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr"; export const DISPO_CHARGEABILITY_SHEET = "ChgFC"; export const DISPO_PLANNING_SHEET = "Dispo"; export const DISPO_ROSTER_SHEET = "DispoRoster"; export const DISPO_ROSTER_SAP_SHEET = "SAP_data"; export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };