import { DispoStagedRecordType, ResourceType } from "@planarchy/db"; import { createWeekdayAvailabilityFromFte } from "@planarchy/shared"; import { parseResourceRosterMasterWorkbook, type ParsedResourceRosterLevelAverage, type ParsedResourceRosterMasterWorkbook, type ParsedResourceRosterRate, } from "./parse-resource-roster-master-workbook.js"; import { DISPO_ROSTER_SAP_SHEET, DISPO_ROSTER_SHEET, type ParsedRosterResource, type ParsedRosterWorkbook, type ParsedUnresolvedRecord, buildFallbackAccentureEmail, deriveCountryCodeFromMetroCity, deriveDisplayNameFromEnterpriseId, deriveNormalizedChapter, deriveRoleTokens, isPseudoDemandResourceIdentity, mapChargeabilityResourceType, normalizeNullableWorkbookValue, normalizeText, resolveCanonicalEnterpriseIdentity, } from "./shared.js"; import { readWorksheetMatrix } from "./read-workbook.js"; const ROSTER_HEADERS = { clientUnit: "MV Client Unit", dailyWorkingHoursPerFte: "Daily Working Hours/FTE", department: "MV Org Unit 2 / Department", eid: "EID", firstDayInDispo: "First day in dispo", fte: "FTE", lastDayInDispo: "Last day in dispo", mainSkillset: "MV Main Skillset", managementLevel: "Management Level", managementLevelGroup: "Management Level Group", metroCity: "Metro City", rawChapter: "MV Org Unit 1 / Chapter", rawResourceType: "MV Ressource Type", resourceHoursPerWeek: "Resource Hours/Week", vacationDaysPerYear: "Vacation days / year", } as const; const SAP_HEADERS = { employeeEmail: "Employee Email", employeeName: "Employee Name", enterpriseId: "Enterprise ID", fte: "FTE", managementLevel: "Management Level", managementLevelGroup: "Management Level Group", metroCity: "Metro City", orgUnitLevel5: "Org Unit Level 5", orgUnitLevel6: "Org Unit Level 6", orgUnitLevel7: "Org Unit Level 7", } as const; interface RosterSourceRow { canonicalExternalId: string; clientUnitName: string | null; dailyWorkingHoursPerFte: number | null; department: string | null; fte: number | null; mainSkillset: string | null; managementLevelGroupName: string | null; managementLevelName: string | null; metroCityName: string | null; rawChapter: string | null; rawResourceType: string | null; resourceHoursPerWeek: number | null; rowNumber: number; vacationDaysPerYear: number | null; firstDayInDispo: Date | null; lastDayInDispo: Date | null; } interface SapSourceRow { canonicalExternalId: string; employeeEmail: string | null; employeeName: string | null; fte: number | null; managementLevelGroupName: string | null; managementLevelName: string | null; metroCityName: string | null; orgUnitLevelFive: string | null; orgUnitLevelSix: string | null; orgUnitLevelSeven: string | null; rowNumber: number; } interface ParseDispoRosterWorkbookOptions { costWorkbookPath?: string; } function buildHeaderMap(headerRow: ReadonlyArray): Map { const headerMap = new Map(); headerRow.forEach((value, index) => { const normalized = normalizeText(value); if (normalized) { headerMap.set(normalized, index); } }); return headerMap; } function getCellValue( row: ReadonlyArray, headerMap: Map, headerName: string, ): unknown { const index = headerMap.get(headerName); if (index === undefined) { return null; } return row[index] ?? null; } function parseOptionalNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } const normalized = normalizeNullableWorkbookValue(value); if (!normalized) { return null; } const parsed = Number(normalized.replace(",", ".")); return Number.isFinite(parsed) ? parsed : null; } function parseOptionalDate(value: unknown): Date | null { if (value instanceof Date && !Number.isNaN(value.valueOf())) { return value; } const normalized = normalizeNullableWorkbookValue(value); if (!normalized) { return null; } const parsed = new Date(normalized); return Number.isNaN(parsed.valueOf()) ? null : parsed; } function normalizeSapDisplayName(value: string | null): string | null { if (!value) { return null; } const normalized = value .split(",") .map((part) => normalizeText(part)) .filter((part): part is string => Boolean(part)); if (normalized.length === 2) { return `${normalized[1]} ${normalized[0]}`; } return normalizeText(value); } function buildResourceWarnings( resourceType: ResourceType, resourceTypeWarning: string | null, roster: RosterSourceRow | null, sap: SapSourceRow | null, ): string[] { const warnings: string[] = []; if (resourceTypeWarning) { warnings.push(resourceTypeWarning); } if (!roster) { warnings.push("Missing DispoRoster row; resource imported from SAP_data only"); } if (!sap) { warnings.push("Missing SAP_data row; email and display name fall back to derived values"); } if (resourceType === ResourceType.FREELANCER && !roster?.dailyWorkingHoursPerFte) { warnings.push("Freelancer row has no daily working hours value; defaulting to 8h/day"); } return warnings; } function shouldExcludeImportedResource(resource: { canonicalExternalId: string; sourceEmail: string | null; managementLevelName: string | null; }) { return !resource.sourceEmail && !resource.managementLevelName; } function applyRateResolution(input: { canonicalExternalId: string; level: string | null; rateRecord: ParsedResourceRosterRate | null; levelAverage: ParsedResourceRosterLevelAverage | null; warnings: string[]; }) { const { canonicalExternalId, level, rateRecord, levelAverage, warnings } = input; const exactLcr = rateRecord?.lcrCents ?? null; const exactUcr = rateRecord?.ucrCents ?? null; const fallbackLcr = levelAverage?.lcrCents ?? null; const fallbackUcr = levelAverage?.ucrCents ?? null; const lcrCents = exactLcr ?? fallbackLcr; const ucrCents = exactUcr ?? fallbackUcr; if (!rateRecord) { if (levelAverage && lcrCents !== null && ucrCents !== null) { warnings.push( `Applied level-average rates for ${canonicalExternalId} using management level ${levelAverage.level}`, ); return { lcrCents, ucrCents, rateResolution: "LEVEL_AVERAGE" as const, rateResolutionLevel: levelAverage.level, }; } warnings.push( level ? `Missing rate row for ${canonicalExternalId}; no usable level-average rate found for ${level}` : `Missing rate row for ${canonicalExternalId}; management level unavailable for fallback`, ); return { lcrCents, ucrCents, rateResolution: "MISSING" as const, rateResolutionLevel: levelAverage?.level ?? level ?? null, }; } if (exactLcr !== null && exactUcr !== null) { return { lcrCents, ucrCents, rateResolution: "EXACT" as const, rateResolutionLevel: rateRecord.level ?? null, }; } if (levelAverage && lcrCents !== null && ucrCents !== null) { warnings.push( `Completed incomplete rate row for ${canonicalExternalId} with level-average rates from ${levelAverage.level}`, ); return { lcrCents, ucrCents, rateResolution: "LEVEL_AVERAGE" as const, rateResolutionLevel: levelAverage.level, }; } warnings.push(`Incomplete rate row for ${canonicalExternalId} could not be fully resolved`); return { lcrCents, ucrCents, rateResolution: "MISSING" as const, rateResolutionLevel: rateRecord.level ?? level ?? null, }; } export async function parseDispoRosterWorkbook( workbookPath: string, options: ParseDispoRosterWorkbookOptions = {}, ): Promise { const [rosterRows, sapRows] = await Promise.all([ readWorksheetMatrix(workbookPath, DISPO_ROSTER_SHEET), readWorksheetMatrix(workbookPath, DISPO_ROSTER_SAP_SHEET), ]); const rateWorkbook: ParsedResourceRosterMasterWorkbook | null = options.costWorkbookPath ? await parseResourceRosterMasterWorkbook(options.costWorkbookPath) : null; const rosterHeaderMap = buildHeaderMap(rosterRows[0] ?? []); const sapHeaderMap = buildHeaderMap(sapRows[1] ?? []); const warnings: string[] = [...(rateWorkbook?.warnings ?? [])]; const unresolved: ParsedUnresolvedRecord[] = []; const rosterById = new Map(); const sapById = new Map(); let ignoredPseudoDemandRows = 0; for (let rowNumber = 2; rowNumber <= rosterRows.length; rowNumber += 1) { const row = rosterRows[rowNumber - 1] ?? []; const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid)); if (!eidValue) { if (row.some((value) => normalizeText(value) !== null)) { warnings.push(`Ignoring DispoRoster row ${rowNumber} because EID is missing`); } continue; } if (isPseudoDemandResourceIdentity(eidValue)) { ignoredPseudoDemandRows += 1; continue; } const canonicalExternalId = resolveCanonicalEnterpriseIdentity(eidValue); if (!canonicalExternalId) { unresolved.push({ sourceRow: rowNumber, sourceColumn: "A", recordType: DispoStagedRecordType.RESOURCE, resourceExternalId: eidValue, message: `Unable to normalize EID "${eidValue}"`, resolutionHint: "Validate EID formatting in DispoRoster", warnings: [], normalizedData: { eid: eidValue }, }); continue; } if (rosterById.has(canonicalExternalId)) { unresolved.push({ sourceRow: rowNumber, sourceColumn: "A", recordType: DispoStagedRecordType.RESOURCE, resourceExternalId: canonicalExternalId, message: `Duplicate DispoRoster row found for ${canonicalExternalId}`, resolutionHint: "Keep exactly one operational roster row per EID", warnings: [], normalizedData: { eid: canonicalExternalId }, }); continue; } rosterById.set(canonicalExternalId, { canonicalExternalId, rowNumber, metroCityName: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.metroCity), ), managementLevelGroupName: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevelGroup), ), managementLevelName: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevel), ), fte: parseOptionalNumber(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.fte)), dailyWorkingHoursPerFte: parseOptionalNumber( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.dailyWorkingHoursPerFte), ), resourceHoursPerWeek: parseOptionalNumber( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.resourceHoursPerWeek), ), rawResourceType: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawResourceType), ), clientUnitName: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.clientUnit), ), rawChapter: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawChapter), ), department: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.department), ), mainSkillset: normalizeNullableWorkbookValue( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.mainSkillset), ), firstDayInDispo: parseOptionalDate( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.firstDayInDispo), ), lastDayInDispo: parseOptionalDate( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.lastDayInDispo), ), vacationDaysPerYear: parseOptionalNumber( getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.vacationDaysPerYear), ), }); } for (let rowNumber = 3; rowNumber <= sapRows.length; rowNumber += 1) { const row = sapRows[rowNumber - 1] ?? []; const enterpriseIdValue = normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.enterpriseId), ); if (!enterpriseIdValue) { continue; } const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue); if (!canonicalExternalId) { unresolved.push({ sourceRow: rowNumber, sourceColumn: "C", recordType: DispoStagedRecordType.RESOURCE, resourceExternalId: enterpriseIdValue, message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`, resolutionHint: "Validate Enterprise ID formatting in SAP_data", warnings: [], normalizedData: { enterpriseId: enterpriseIdValue }, }); continue; } if (sapById.has(canonicalExternalId)) { unresolved.push({ sourceRow: rowNumber, sourceColumn: "C", recordType: DispoStagedRecordType.RESOURCE, resourceExternalId: canonicalExternalId, message: `Duplicate SAP_data row found for ${canonicalExternalId}`, resolutionHint: "Keep exactly one SAP_data row per Enterprise ID", warnings: [], normalizedData: { enterpriseId: canonicalExternalId }, }); continue; } sapById.set(canonicalExternalId, { canonicalExternalId, rowNumber, employeeName: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeName), ), employeeEmail: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeEmail), )?.toLowerCase() ?? null, metroCityName: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.metroCity), ), managementLevelGroupName: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevelGroup), ), managementLevelName: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevel), ), orgUnitLevelFive: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel5), ), orgUnitLevelSix: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel6), ), orgUnitLevelSeven: normalizeNullableWorkbookValue( getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel7), ), fte: parseOptionalNumber(getCellValue(row, sapHeaderMap, SAP_HEADERS.fte)), }); } const resourceIds = new Set([...rosterById.keys(), ...sapById.keys()]); const resources: ParsedRosterResource[] = []; const excludedCanonicalExternalIds = new Set(); for (const canonicalExternalId of resourceIds) { const roster = rosterById.get(canonicalExternalId) ?? null; const sap = sapById.get(canonicalExternalId) ?? null; const roleTokens = deriveRoleTokens( roster?.department, roster?.rawChapter, roster?.mainSkillset, sap?.orgUnitLevelSix, sap?.orgUnitLevelSeven, ); const normalizedChapter = deriveNormalizedChapter(roster?.rawChapter ?? null, roleTokens); const resourceTypeResult = mapChargeabilityResourceType(roster?.rawResourceType ?? null); const resourceType = roster?.rawResourceType || sap ? resourceTypeResult.resourceType : ResourceType.EMPLOYEE; const fte = sap?.fte ?? roster?.fte ?? null; const dailyWorkingHoursPerFte = roster?.dailyWorkingHoursPerFte ?? null; const displayName = normalizeSapDisplayName(sap?.employeeName ?? null) ?? deriveDisplayNameFromEnterpriseId(canonicalExternalId); const metroCityName = sap?.metroCityName ?? roster?.metroCityName ?? null; const managementLevelName = sap?.managementLevelName ?? roster?.managementLevelName ?? null; const resourceWarnings = buildResourceWarnings(resourceType, resourceTypeResult.warning, roster, sap); const rateResolution = applyRateResolution({ canonicalExternalId, level: managementLevelName, rateRecord: rateWorkbook?.rates.get(canonicalExternalId) ?? null, levelAverage: managementLevelName ? rateWorkbook?.levelAverages.get(managementLevelName) ?? null : null, warnings: resourceWarnings, }); const resource: ParsedRosterResource = { sourceRow: roster?.rowNumber ?? sap?.rowNumber ?? 0, sourceSheet: roster ? DISPO_ROSTER_SHEET : DISPO_ROSTER_SAP_SHEET, canonicalExternalId, enterpriseId: canonicalExternalId, eid: canonicalExternalId, displayName, email: sap?.employeeEmail ?? buildFallbackAccentureEmail(canonicalExternalId), chapter: normalizedChapter.chapter, chapterCode: normalizedChapter.chapterCode, managementLevelGroupName: sap?.managementLevelGroupName ?? roster?.managementLevelGroupName ?? null, managementLevelName, countryCode: deriveCountryCodeFromMetroCity(metroCityName), metroCityName, clientUnitName: roster?.clientUnitName ?? null, rawResourceType: roster?.rawResourceType ?? null, resourceType, fte, lcrCents: rateResolution.lcrCents, ucrCents: rateResolution.ucrCents, rateResolution: rateResolution.rateResolution, rateResolutionLevel: rateResolution.rateResolutionLevel, availability: createWeekdayAvailabilityFromFte( fte ?? 1, dailyWorkingHoursPerFte ?? 8, ) as unknown as ParsedRosterResource["availability"], roleTokens, dailyWorkingHoursPerFte, department: roster?.department ?? null, mainSkillset: roster?.mainSkillset ?? null, resourceHoursPerWeek: roster?.resourceHoursPerWeek ?? null, firstDayInDispo: roster?.firstDayInDispo ?? null, lastDayInDispo: roster?.lastDayInDispo ?? null, vacationDaysPerYear: roster?.vacationDaysPerYear ?? null, sapEmployeeName: sap?.employeeName ?? null, sapOrgUnitLevelFive: sap?.orgUnitLevelFive ?? null, sapOrgUnitLevelSix: sap?.orgUnitLevelSix ?? null, sapOrgUnitLevelSeven: sap?.orgUnitLevelSeven ?? null, warnings: resourceWarnings, }; if ( shouldExcludeImportedResource({ canonicalExternalId, sourceEmail: sap?.employeeEmail ?? null, managementLevelName, }) ) { excludedCanonicalExternalIds.add(canonicalExternalId); warnings.push( `Excluded ${canonicalExternalId} from import because neither email nor management level is present in the supplied sources`, ); continue; } resources.push(resource); } if (ignoredPseudoDemandRows > 0) { warnings.push(`Ignored ${ignoredPseudoDemandRows} pseudo-demand rows from DispoRoster`); } resources.sort((left, right) => left.canonicalExternalId.localeCompare(right.canonicalExternalId)); return { excludedCanonicalExternalIds: Array.from(excludedCanonicalExternalIds).sort((left, right) => left.localeCompare(right), ), resources, unresolved, warnings, ignoredPseudoDemandRows, }; }