import { normalizeCanonicalResourceIdentity } from "@capakraken/shared"; import { readWorksheetMatrix } from "./read-workbook.js"; import { normalizeNullableWorkbookValue, normalizeText } from "./shared.js"; const RESOURCE_ROSTER_MASTER_SHEET = "Dispo Namen"; const HEADERS = { chapter: "Chapter", employeeName: "Mitarbeiter (laut Dispo)", experience: "Experience", fte: "FTE", lcr: "LCR (EUR)", level: "Level", location: "Location", status: "Status", typeOfWork: "Type of work", ucr: "UCR (EUR)", } as const; export interface ParsedResourceRosterRate { canonicalExternalId: string; chapter: string | null; experience: string | null; fte: number | null; lcrCents: number | null; level: string | null; location: string | null; sourceRow: number; status: string | null; typeOfWork: string | null; ucrCents: number | null; warnings: string[]; } export interface ParsedResourceRosterLevelAverage { lcrCents: number | null; level: string; sampleCount: number; ucrCents: number | null; } export interface ParsedResourceRosterMasterWorkbook { levelAverages: Map; rates: Map; warnings: 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 toCents(value: number | null): number | null { return value === null ? null : Math.round(value * 100); } export async function parseResourceRosterMasterWorkbook( workbookPath: string, ): Promise { const rows = await readWorksheetMatrix(workbookPath, RESOURCE_ROSTER_MASTER_SHEET); const headerMap = buildHeaderMap(rows[0] ?? []); const warnings: string[] = []; const rates = new Map(); const levelBuckets = new Map(); for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) { const row = rows[rowNumber - 1] ?? []; const employeeName = normalizeNullableWorkbookValue( getCellValue(row, headerMap, HEADERS.employeeName), ); if (!employeeName) { continue; } const canonicalExternalId = normalizeCanonicalResourceIdentity(employeeName); const level = normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.level)); const ucrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.ucr)); const lcrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.lcr)); const recordWarnings: string[] = []; if (rates.has(canonicalExternalId)) { recordWarnings.push(`Duplicate rate row ${rowNumber} ignored for ${canonicalExternalId}`); warnings.push(recordWarnings[0] ?? `Duplicate rate row ${rowNumber} ignored`); continue; } if (ucrValue === null || lcrValue === null) { recordWarnings.push(`Incomplete rate row for ${canonicalExternalId}`); } rates.set(canonicalExternalId, { canonicalExternalId, sourceRow: rowNumber, ucrCents: toCents(ucrValue), lcrCents: toCents(lcrValue), fte: parseOptionalNumber(getCellValue(row, headerMap, HEADERS.fte)), level, typeOfWork: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.typeOfWork)), chapter: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.chapter)), location: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.location)), status: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.status)), experience: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.experience)), warnings: recordWarnings, }); if (level) { const bucket = levelBuckets.get(level) ?? { lcr: [], ucr: [] }; if (lcrValue !== null) { bucket.lcr.push(lcrValue); } if (ucrValue !== null) { bucket.ucr.push(ucrValue); } levelBuckets.set(level, bucket); } } const levelAverages = new Map(); for (const [level, bucket] of levelBuckets.entries()) { const lcrAverage = bucket.lcr.length > 0 ? Math.round((bucket.lcr.reduce((sum, value) => sum + value, 0) / bucket.lcr.length) * 100) : null; const ucrAverage = bucket.ucr.length > 0 ? Math.round((bucket.ucr.reduce((sum, value) => sum + value, 0) / bucket.ucr.length) * 100) : null; levelAverages.set(level, { level, sampleCount: Math.max(bucket.lcr.length, bucket.ucr.length), lcrCents: lcrAverage, ucrCents: ucrAverage, }); } return { rates, levelAverages, warnings, }; }