207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
import { DispoStagedRecordType } from "@planarchy/db";
|
|
import { DISPO_CHARGEABILITY_SHEET, type ParsedChargeabilityResource, type ParsedChargeabilityWorkbook, type ParsedUnresolvedRecord, buildFallbackAccentureEmail, createAvailabilityFromFte, deriveCountryCodeFromMetroCity, deriveDisplayNameFromEnterpriseId, deriveNormalizedChapter, deriveRoleTokens, ensurePercentageValue, mapChargeabilityResourceType, normalizeNullableWorkbookValue, normalizeText, resolveCanonicalEnterpriseIdentity } from "./shared.js";
|
|
import { readWorksheetMatrix } from "./read-workbook.js";
|
|
|
|
const CHGFC_HEADERS = {
|
|
clientUnit: "MV Client Unit",
|
|
enterpriseId: "Enterprise ID",
|
|
fte: "FTE",
|
|
managementLevelGroup: "Management Level Group",
|
|
metroCity: "Metro City",
|
|
orgUnitLevel6: "Org Unit Level 6",
|
|
rawChapter: "MV Org Unit 1 / Chapter",
|
|
rawResourceType: "MV Ressource Type",
|
|
target: "Target (per Level)",
|
|
} as const;
|
|
|
|
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 buildResourceSignature(resource: ParsedChargeabilityResource): string {
|
|
return JSON.stringify({
|
|
chapter: resource.chapter,
|
|
chapterCode: resource.chapterCode,
|
|
chargeabilityTarget: resource.chargeabilityTarget,
|
|
clientUnitName: resource.clientUnitName,
|
|
countryCode: resource.countryCode,
|
|
fte: resource.fte,
|
|
managementLevelGroupName: resource.managementLevelGroupName,
|
|
metroCityName: resource.metroCityName,
|
|
resourceType: resource.resourceType,
|
|
roleTokens: resource.roleTokens,
|
|
});
|
|
}
|
|
|
|
export async function parseDispoChargeabilityWorkbook(
|
|
workbookPath: string,
|
|
): Promise<ParsedChargeabilityWorkbook> {
|
|
const rows = await readWorksheetMatrix(workbookPath, DISPO_CHARGEABILITY_SHEET);
|
|
const headerMap = buildHeaderMap(rows[0] ?? []);
|
|
const warnings: string[] = [];
|
|
const unresolved: ParsedUnresolvedRecord[] = [];
|
|
const resourceByCanonicalId = new Map<string, ParsedChargeabilityResource>();
|
|
|
|
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
|
|
const row = rows[rowNumber - 1] ?? [];
|
|
const enterpriseIdValue = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.enterpriseId),
|
|
);
|
|
|
|
if (!enterpriseIdValue) {
|
|
if (row.some((value) => normalizeText(value) !== null)) {
|
|
unresolved.push({
|
|
sourceRow: rowNumber,
|
|
sourceColumn: "A",
|
|
recordType: DispoStagedRecordType.RESOURCE,
|
|
resourceExternalId: null,
|
|
message: "Missing Enterprise ID in ChgFC row",
|
|
resolutionHint: "Populate Enterprise ID before staging resource data",
|
|
warnings: [],
|
|
normalizedData: {},
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
|
|
if (!canonicalExternalId) {
|
|
unresolved.push({
|
|
sourceRow: rowNumber,
|
|
sourceColumn: "A",
|
|
recordType: DispoStagedRecordType.RESOURCE,
|
|
resourceExternalId: enterpriseIdValue,
|
|
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
|
|
resolutionHint: "Validate Enterprise ID formatting in ChgFC",
|
|
warnings: [],
|
|
normalizedData: {
|
|
enterpriseId: enterpriseIdValue,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const managementLevelGroupName = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.managementLevelGroup),
|
|
);
|
|
const rawTarget = getCellValue(row, headerMap, CHGFC_HEADERS.target);
|
|
const fte = typeof getCellValue(row, headerMap, CHGFC_HEADERS.fte) === "number"
|
|
? Number(getCellValue(row, headerMap, CHGFC_HEADERS.fte))
|
|
: null;
|
|
const metroCityName = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.metroCity),
|
|
);
|
|
const rawResourceType = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.rawResourceType),
|
|
);
|
|
const levelSixName = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.orgUnitLevel6),
|
|
);
|
|
const rawChapter = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.rawChapter),
|
|
);
|
|
const clientUnitName = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, CHGFC_HEADERS.clientUnit),
|
|
);
|
|
|
|
const roleTokens = deriveRoleTokens(levelSixName, rawChapter);
|
|
const normalizedChapter = deriveNormalizedChapter(rawChapter, roleTokens);
|
|
const resourceTypeResult = mapChargeabilityResourceType(rawResourceType);
|
|
const recordWarnings = resourceTypeResult.warning ? [resourceTypeResult.warning] : [];
|
|
const chargeabilityTarget =
|
|
typeof rawTarget === "number" ? ensurePercentageValue(rawTarget) : null;
|
|
|
|
const resource: ParsedChargeabilityResource = {
|
|
sourceRow: rowNumber,
|
|
canonicalExternalId,
|
|
enterpriseId: canonicalExternalId,
|
|
eid: canonicalExternalId,
|
|
displayName: deriveDisplayNameFromEnterpriseId(canonicalExternalId),
|
|
email: buildFallbackAccentureEmail(canonicalExternalId),
|
|
chapter: normalizedChapter.chapter,
|
|
chapterCode: normalizedChapter.chapterCode,
|
|
managementLevelGroupName,
|
|
managementLevelName: null,
|
|
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
|
|
metroCityName,
|
|
clientUnitName,
|
|
rawResourceType,
|
|
resourceType: resourceTypeResult.resourceType,
|
|
chargeabilityTarget,
|
|
fte,
|
|
availability: createAvailabilityFromFte(fte),
|
|
roleTokens,
|
|
warnings: recordWarnings,
|
|
};
|
|
|
|
const existing = resourceByCanonicalId.get(canonicalExternalId);
|
|
if (!existing) {
|
|
resourceByCanonicalId.set(canonicalExternalId, resource);
|
|
continue;
|
|
}
|
|
|
|
const existingSignature = buildResourceSignature(existing);
|
|
const nextSignature = buildResourceSignature(resource);
|
|
|
|
if (existingSignature === nextSignature) {
|
|
existing.warnings.push(`Duplicate ChgFC row ${rowNumber} ignored for ${canonicalExternalId}`);
|
|
continue;
|
|
}
|
|
|
|
existing.warnings.push(`Conflicting duplicate ChgFC row ${rowNumber} found for ${canonicalExternalId}`);
|
|
unresolved.push({
|
|
sourceRow: rowNumber,
|
|
sourceColumn: "A",
|
|
recordType: DispoStagedRecordType.RESOURCE,
|
|
resourceExternalId: canonicalExternalId,
|
|
message: `Conflicting resource roster rows found for ${canonicalExternalId}`,
|
|
resolutionHint: "Resolve the differing ChgFC roster values before commit",
|
|
warnings: [...recordWarnings],
|
|
normalizedData: {
|
|
existing: {
|
|
sourceRow: existing.sourceRow,
|
|
chapter: existing.chapter,
|
|
clientUnitName: existing.clientUnitName,
|
|
fte: existing.fte,
|
|
metroCityName: existing.metroCityName,
|
|
},
|
|
conflicting: {
|
|
sourceRow: resource.sourceRow,
|
|
chapter: resource.chapter,
|
|
clientUnitName: resource.clientUnitName,
|
|
fte: resource.fte,
|
|
metroCityName: resource.metroCityName,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
resources: Array.from(resourceByCanonicalId.values()),
|
|
unresolved,
|
|
warnings,
|
|
};
|
|
}
|