Files
CapaKraken/packages/application/src/use-cases/dispo-import/parse-dispo-roster-workbook.ts
T

562 lines
19 KiB
TypeScript

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<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
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<ParsedRosterWorkbook> {
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<string, RosterSourceRow>();
const sapById = new Map<string, SapSourceRow>();
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<string>([...rosterById.keys(), ...sapById.keys()]);
const resources: ParsedRosterResource[] = [];
const excludedCanonicalExternalIds = new Set<string>();
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,
};
}