import { DispoStagedRecordType } from "@capakraken/db"; import { VacationType, normalizeCanonicalResourceIdentity, normalizeDispoRoleToken, normalizeDispoUtilizationToken, } from "@capakraken/shared"; import { readWorksheetMatrix, toColumnLetter, type WorksheetCellValue } from "./read-workbook.js"; import { DISPO_PLANNING_SHEET, type ParsedPlanningAssignment, type ParsedPlanningAvailabilityRule, type ParsedPlanningVacation, type ParsedPlanningWorkbook, type ParsedUnresolvedRecord, deriveRoleTokens, normalizeNullableWorkbookValue, normalizeText, } from "./shared.js"; const DISPO_HEADER_ROW = 5; const DISPO_DATE_ROW = 2; const DISPO_SLOT_ROW = 3; const DISPO_DATA_START_ROW = 6; const DISPO_EID_COLUMN = 3; const DISPO_CHAPTER_COLUMN = 4; const DISPO_TYPE_OF_WORK_COLUMN = 5; const DISPO_UNIT_SPECIFIC_FIELD_COLUMN = 7; const DISPO_PLANNING_START_COLUMN = 11; const SLOT_HOURS = 4; const WEEKDAY_LABELS = new Set(["MO", "DI", "MI", "DO", "FR", "SA", "SO"]); const BERLIN_DATE_FORMATTER = new Intl.DateTimeFormat("en-CA", { day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin", year: "numeric", }); interface PlanningColumn { assignmentDate: Date; columnLetter: string; columnNumber: number; halfDayPart: "AFTERNOON" | "MORNING" | null; slotLabel: string; weekdayLabel: string | null; } interface PlanningRowMetadata { chapter: string | null; eid: string; typeOfWork: string | null; unitSpecificField: string | null; } interface AssignmentAccumulator { assignmentDate: Date; chapterToken: string | null; firstColumnNumber: number; hoursPerDay: number; isInternal: boolean; isTbd: boolean; isUnassigned: boolean; projectKey: string | null; rawToken: string; resourceExternalId: string; roleName: string | null; roleToken: string | null; slotCount: number; sourceRow: number; utilizationCategoryCode: string | null; warnings: Set; winProbability: number | null; } interface VacationAccumulator { endDate: Date; firstColumnNumber: number; halfDayParts: Set; holidayName: string | null; isPublicHoliday: boolean; note: string | null; rawToken: string; resourceExternalId: string; sourceRow: number; startDate: Date; vacationType: VacationType; warnings: Set; } interface AvailabilityAccumulator { availableHours: number | null; effectiveEndDate: Date; effectiveStartDate: Date; firstColumnNumber: number; isResolved: boolean; percentage: number | null; rawToken: string; resourceExternalId: string; ruleType: string; sourceRow: number; warnings: Set; } interface NaTokenHandlingResult { availableHours?: number | null; isPublicHoliday: boolean; kind: "availability" | "assignment" | "skip" | "vacation"; note?: string | null; percentage?: number | null; projectKey?: string | null; ruleType?: string; vacationType?: VacationType; warning?: string; } interface ParsedAssignmentToken { chapterToken: string | null; isInternal: boolean; isTbd: boolean; isUnassigned: boolean; projectKey: string | null; roleName: string | null; roleToken: string | null; utilizationCategoryCode: string | null; winProbability: number | null; } function isWeekdayLabel(value: string | null): boolean { return value !== null && WEEKDAY_LABELS.has(value.toUpperCase()); } function toDateOnlyInBerlin(value: WorksheetCellValue): Date | null { if (!(value instanceof Date)) { return null; } const parts = BERLIN_DATE_FORMATTER.formatToParts(value); const year = Number(parts.find((part) => part.type === "year")?.value); const month = Number(parts.find((part) => part.type === "month")?.value); const day = Number(parts.find((part) => part.type === "day")?.value); if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { return null; } return new Date(Date.UTC(year, month - 1, day)); } function getDateKey(value: Date): string { return value.toISOString().slice(0, 10); } function getSlotHalfDayPart(slotLabel: string | null): "AFTERNOON" | "MORNING" | null { const normalized = normalizeText(slotLabel)?.toLowerCase() ?? null; if (!normalized) { return null; } if (normalized.includes("9.-13")) { return "MORNING"; } if (normalized.includes("14.-18")) { return "AFTERNOON"; } return null; } function isPlanningSummaryRow(row: ReadonlyArray): boolean { if ((row[0] ?? null) !== null || (row[1] ?? null) !== null) { return false; } const repeatedLabels = row .slice(DISPO_EID_COLUMN - 1, 9) .map((value) => normalizeNullableWorkbookValue(value)) .filter((value): value is string => value !== null); if (repeatedLabels.length === 0) { return false; } const normalizedLabels = new Set(repeatedLabels.map((value) => value.toLowerCase())); const label = repeatedLabels[0] ?? null; return normalizedLabels.size === 1 && label !== null && label.startsWith("(") && label.endsWith(")"); } function buildPlanningColumns(rows: ReadonlyArray>) { const columns: PlanningColumn[] = []; const headerWidth = Math.max(rows[DISPO_DATE_ROW - 1]?.length ?? 0, rows[DISPO_SLOT_ROW - 1]?.length ?? 0); for (let columnNumber = DISPO_PLANNING_START_COLUMN; columnNumber <= headerWidth; columnNumber += 1) { const slotLabel = normalizeNullableWorkbookValue(rows[DISPO_SLOT_ROW - 1]?.[columnNumber - 1]); if (!slotLabel) { continue; } const currentHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber - 1] ?? null; const previousHeaderLabel = normalizeNullableWorkbookValue(rows[DISPO_DATE_ROW - 1]?.[columnNumber - 2]); const currentHeaderLabel = normalizeNullableWorkbookValue(currentHeaderValue); const nextHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber] ?? null; const assignmentDate = toDateOnlyInBerlin(currentHeaderValue) ?? toDateOnlyInBerlin(nextHeaderValue); if (!assignmentDate) { continue; } const weekdayLabel = isWeekdayLabel(currentHeaderLabel) ? currentHeaderLabel : isWeekdayLabel(previousHeaderLabel) ? previousHeaderLabel : null; columns.push({ assignmentDate, columnLetter: toColumnLetter(columnNumber), columnNumber, halfDayPart: getSlotHalfDayPart(slotLabel), slotLabel, weekdayLabel, }); } return columns; } function normalizePlanningToken(token: string): string { return token .trim() .replace(/\s+/g, " ") .replace(/\s+(?:HB|SB)_?\s*$/i, "") .trim(); } function extractBracketTokens(token: string): string[] { return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean); } function extractUtilizationToken(token: string): { utilizationToken: string | null; winProbability: number | null } { const matches = Array.from(token.matchAll(/\{([A-Z]+)(\d{0,3})\}/gi)); const lastMatch = matches.at(-1); if (!lastMatch) { return { utilizationToken: null, winProbability: null, }; } const utilizationToken = lastMatch[1]?.toUpperCase() ?? null; const winProbability = lastMatch[2] ? Number(lastMatch[2]) : null; return { utilizationToken, winProbability: Number.isFinite(winProbability) ? winProbability : null, }; } function extractRoleToken(token: string, metadata: PlanningRowMetadata): string | null { const explicitRoleToken = token.match(/^(2D|3D|PM|AD)\b/i)?.[1]?.toUpperCase() ?? null; if (explicitRoleToken) { return explicitRoleToken; } return deriveRoleTokens(metadata.chapter, metadata.typeOfWork, metadata.unitSpecificField)[0] ?? null; } function extractProjectKey(token: string): string | null { const bracketTokens = extractBracketTokens(token).filter((entry) => !entry.startsWith("_")); const lastToken = bracketTokens.at(-1) ?? null; return lastToken && lastToken.toLowerCase() !== "tbd" ? lastToken : null; } function slugifyProjectKeyFragment(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); } function extractLabel(token: string): string | null { const stripped = token .replace(/^(2D|3D|PM|AD)\s+/i, "") .replace(/\[[^\]]+\]/g, " ") .replace(/\{[^}]+\}/g, " ") .replace(/\s+(?:HB|SB)_?\s*$/i, " ") .replace(/\s+/g, " ") .trim(); return stripped.length > 0 ? stripped : null; } function parsePercentage(value: string): number | null { const percentageMatch = value.match(/(\d+(?:[.,]\d+)?)\s*%/); if (percentageMatch) { const normalized = Number(percentageMatch[1]?.replace(",", ".")); return Number.isFinite(normalized) ? normalized : null; } const fteMatch = value.match(/FTE:\s*(\d+(?:[.,]\d+)?)/i); if (fteMatch) { const normalized = Number(fteMatch[1]?.replace(",", ".")); return Number.isFinite(normalized) ? Math.round(normalized * 10000) / 100 : null; } return null; } function classifyNaPlanningToken(rawToken: string): NaTokenHandlingResult { const normalized = rawToken.toLowerCase(); const label = extractLabel(rawToken); const { utilizationToken } = extractUtilizationToken(rawToken); if (!rawToken.toUpperCase().startsWith("[_NA]")) { return { isPublicHoliday: false, kind: "assignment", }; } if (normalized.includes("public holiday")) { return { isPublicHoliday: true, kind: "vacation", note: label, vacationType: VacationType.PUBLIC_HOLIDAY, }; } if (normalized.includes("part-time")) { return { availableHours: null, isPublicHoliday: false, kind: "availability", percentage: parsePercentage(rawToken), ruleType: "PART_TIME", }; } if (normalized.includes("not bookable")) { return { availableHours: 0, isPublicHoliday: false, kind: "availability", percentage: 0, ruleType: "NOT_BOOKABLE", }; } if (normalized.includes("reduced spanish working hours")) { return { availableHours: 7, isPublicHoliday: false, kind: "availability", percentage: 87.5, ruleType: "REDUCED_SPANISH_WORKING_HOURS", warning: "Assumed 7h/day for reduced Spanish working hours", }; } if (normalized.includes("parental leave")) { return { isPublicHoliday: false, kind: "vacation", note: label, vacationType: VacationType.OTHER, }; } if (utilizationToken && utilizationToken !== "NA" && utilizationToken !== "UN") { const projectKey = label ? `NA-${slugifyProjectKeyFragment(label)}` : null; return { isPublicHoliday: false, kind: projectKey ? "assignment" : "skip", note: label, projectKey, }; } return { isPublicHoliday: false, kind: "skip", note: label, }; } function buildAssignmentAccumulator( column: PlanningColumn, metadata: PlanningRowMetadata, rawToken: string, ): AssignmentAccumulator | null { const roleToken = extractRoleToken(rawToken, metadata); const roleName = normalizeDispoRoleToken(roleToken); const { utilizationToken, winProbability } = extractUtilizationToken(rawToken); const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken); const naHandling = classifyNaPlanningToken(rawToken); const projectKey = extractProjectKey(rawToken) ?? naHandling.projectKey ?? null; const isTbd = /\[tbd\]/i.test(rawToken); const isUnassigned = utilizationToken === "UN"; const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? ""); if (isUnassigned) { return null; } return { assignmentDate: column.assignmentDate, chapterToken: roleToken, firstColumnNumber: column.columnNumber, hoursPerDay: SLOT_HOURS, isInternal, isTbd, isUnassigned: false, projectKey, rawToken, resourceExternalId: metadata.eid, roleName, roleToken, slotCount: 1, sourceRow: 0, utilizationCategoryCode, warnings: new Set(), winProbability, }; } function buildVacationAccumulator( column: PlanningColumn, metadata: PlanningRowMetadata, rawToken: string, vacationType: VacationType, input: { holidayName?: string | null; isPublicHoliday: boolean; note?: string | null }, ): VacationAccumulator { const halfDayParts = new Set(); if (column.halfDayPart) { halfDayParts.add(column.halfDayPart); } return { endDate: column.assignmentDate, firstColumnNumber: column.columnNumber, halfDayParts, holidayName: input.holidayName ?? null, isPublicHoliday: input.isPublicHoliday, note: input.note ?? null, rawToken, resourceExternalId: metadata.eid, sourceRow: 0, startDate: column.assignmentDate, vacationType, warnings: new Set(), }; } function buildAvailabilityAccumulator( column: PlanningColumn, metadata: PlanningRowMetadata, rawToken: string, input: { availableHours?: number | null; percentage?: number | null; ruleType?: string; warning?: string; } = {}, ): AvailabilityAccumulator { const percentage = input.percentage ?? parsePercentage(rawToken); const availableHours = percentage !== null ? Math.round((percentage / 100) * 8 * 100) / 100 : (input.availableHours ?? (8 - SLOT_HOURS)); const warnings = new Set(); if (input.warning) { warnings.add(input.warning); } return { availableHours, effectiveEndDate: column.assignmentDate, effectiveStartDate: column.assignmentDate, firstColumnNumber: column.columnNumber, isResolved: false, percentage, rawToken, resourceExternalId: metadata.eid, ruleType: input.ruleType ?? "PART_TIME", sourceRow: 0, warnings, }; } export async function parseDispoPlanningWorkbook( workbookPath: string, ): Promise { const rows = await readWorksheetMatrix(workbookPath, DISPO_PLANNING_SHEET); const planningColumns = buildPlanningColumns(rows); const assignments = new Map(); const vacations = new Map(); const availabilityRules = new Map(); const unresolved: ParsedUnresolvedRecord[] = []; const warnings: string[] = []; for (let rowNumber = DISPO_DATA_START_ROW; rowNumber <= rows.length; rowNumber += 1) { const row = rows[rowNumber - 1] ?? []; if (isPlanningSummaryRow(row)) { continue; } const eid = normalizeNullableWorkbookValue(row[DISPO_EID_COLUMN - 1]); if (!eid) { continue; } const metadata: PlanningRowMetadata = { chapter: normalizeNullableWorkbookValue(row[DISPO_CHAPTER_COLUMN - 1]), eid: normalizeCanonicalResourceIdentity(eid), typeOfWork: normalizeNullableWorkbookValue(row[DISPO_TYPE_OF_WORK_COLUMN - 1]), unitSpecificField: normalizeNullableWorkbookValue(row[DISPO_UNIT_SPECIFIC_FIELD_COLUMN - 1]), }; for (const column of planningColumns) { const worksheetValue = row[column.columnNumber - 1]; if ( worksheetValue === null || worksheetValue === undefined || typeof worksheetValue === "number" || typeof worksheetValue === "boolean" || worksheetValue instanceof Date ) { continue; } const rawCellValue = normalizeNullableWorkbookValue(worksheetValue); if (!rawCellValue) { continue; } const rawToken = normalizePlanningToken(rawCellValue); const normalizedToken = rawToken.toUpperCase(); if (normalizedToken === "IN DAYS") { continue; } if (normalizedToken === "[_NA] WEEKEND {NA}") { continue; } if (normalizedToken.startsWith("[_AB]")) { const note = extractLabel(rawToken); const vacationType = note?.toLowerCase().includes("sick") ? VacationType.SICK : VacationType.ANNUAL; const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|VAC|${rawToken}`; const existing = vacations.get(key); if (existing) { existing.endDate = column.assignmentDate; if (column.halfDayPart) { existing.halfDayParts.add(column.halfDayPart); } } else { const vacation = buildVacationAccumulator(column, metadata, rawToken, vacationType, { isPublicHoliday: false, note, }); vacation.sourceRow = rowNumber; vacations.set(key, vacation); } continue; } if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PUBLIC HOLIDAY")) { const holidayName = extractLabel(rawToken); const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PH|${rawToken}`; const existing = vacations.get(key); if (existing) { existing.endDate = column.assignmentDate; if (column.halfDayPart) { existing.halfDayParts.add(column.halfDayPart); } } else { const vacation = buildVacationAccumulator(column, metadata, rawToken, VacationType.PUBLIC_HOLIDAY, { holidayName, isPublicHoliday: true, note: holidayName, }); vacation.sourceRow = rowNumber; vacations.set(key, vacation); } continue; } const naHandling = classifyNaPlanningToken(rawToken); if (naHandling.kind === "availability") { const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`; const existing = availabilityRules.get(key); if (existing) { const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, { ...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}), ...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}), ...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}), ...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}), }); existing.availableHours = nextAvailability.availableHours; existing.percentage = nextAvailability.percentage; if (naHandling.warning) { existing.warnings.add(naHandling.warning); } } else { const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, { ...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}), ...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}), ...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}), ...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}), }); availabilityRule.sourceRow = rowNumber; availabilityRules.set(key, availabilityRule); } continue; } if (naHandling.kind === "vacation" && naHandling.vacationType) { const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|VAC|${rawToken}`; const existing = vacations.get(key); if (existing) { existing.endDate = column.assignmentDate; if (column.halfDayPart) { existing.halfDayParts.add(column.halfDayPart); } } else { const vacation = buildVacationAccumulator( column, metadata, rawToken, naHandling.vacationType, { holidayName: naHandling.isPublicHoliday ? extractLabel(rawToken) : null, isPublicHoliday: naHandling.isPublicHoliday, note: naHandling.note ?? extractLabel(rawToken), }, ); vacation.sourceRow = rowNumber; if (naHandling.warning) { vacation.warnings.add(naHandling.warning); } vacations.set(key, vacation); } continue; } if (naHandling.kind === "skip") { continue; } if (normalizedToken.startsWith("[_UN]")) { continue; } const assignment = buildAssignmentAccumulator(column, metadata, rawToken); if (!assignment) { continue; } assignment.sourceRow = rowNumber; if (!assignment.utilizationCategoryCode) { assignment.warnings.add(`Unable to resolve utilization category from token "${rawToken}"`); } if (!assignment.projectKey && !assignment.isInternal && !assignment.isTbd) { unresolved.push({ sourceRow: rowNumber, sourceColumn: column.columnLetter, recordType: DispoStagedRecordType.ASSIGNMENT, resourceExternalId: metadata.eid, projectKey: null, message: `Unable to resolve project key from planning token "${rawToken}"`, resolutionHint: "Add a WBS token or classify this cell as an internal bucket before commit", warnings: Array.from(assignment.warnings), normalizedData: { assignmentDate: getDateKey(column.assignmentDate), rawToken, roleToken: assignment.roleToken, utilizationCategoryCode: assignment.utilizationCategoryCode, }, }); continue; } if (assignment.isTbd) { unresolved.push({ sourceRow: rowNumber, sourceColumn: column.columnLetter, recordType: DispoStagedRecordType.PROJECT, resourceExternalId: metadata.eid, projectKey: null, message: `Planning token "${rawToken}" references [tbd] and requires project resolution`, resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit", warnings: Array.from(assignment.warnings), normalizedData: { assignmentDate: getDateKey(column.assignmentDate), rawToken, roleToken: assignment.roleToken, utilizationCategoryCode: assignment.utilizationCategoryCode, winProbability: assignment.winProbability, }, }); } const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|ASN|${rawToken}`; const existing = assignments.get(key); if (existing) { existing.hoursPerDay += SLOT_HOURS; existing.slotCount += 1; } else { assignments.set(key, assignment); } } } const parsedAssignments: ParsedPlanningAssignment[] = Array.from(assignments.values()).map((entry) => ({ assignmentDate: entry.assignmentDate, chapterToken: entry.chapterToken, hoursPerDay: entry.hoursPerDay, isInternal: entry.isInternal, isTbd: entry.isTbd, isUnassigned: entry.isUnassigned, percentage: entry.slotCount * 50, projectKey: entry.projectKey, rawToken: entry.rawToken, resourceExternalId: entry.resourceExternalId, roleName: entry.roleName, roleToken: entry.roleToken, slotFraction: entry.slotCount / 2, sourceColumn: toColumnLetter(entry.firstColumnNumber), sourceRow: entry.sourceRow, utilizationCategoryCode: entry.utilizationCategoryCode, warnings: Array.from(entry.warnings), winProbability: entry.winProbability, })); const parsedVacations: ParsedPlanningVacation[] = Array.from(vacations.values()).map((entry) => ({ endDate: entry.endDate, halfDayPart: entry.halfDayParts.size === 1 ? Array.from(entry.halfDayParts)[0] ?? null : null, holidayName: entry.holidayName, isHalfDay: entry.halfDayParts.size === 1, isPublicHoliday: entry.isPublicHoliday, note: entry.note, rawToken: entry.rawToken, resourceExternalId: entry.resourceExternalId, sourceColumn: toColumnLetter(entry.firstColumnNumber), sourceRow: entry.sourceRow, startDate: entry.startDate, vacationType: entry.vacationType, warnings: Array.from(entry.warnings), })); const parsedAvailabilityRules: ParsedPlanningAvailabilityRule[] = Array.from(availabilityRules.values()).map((entry) => ({ availableHours: entry.availableHours, effectiveEndDate: entry.effectiveEndDate, effectiveStartDate: entry.effectiveStartDate, isResolved: entry.isResolved, percentage: entry.percentage, rawToken: entry.rawToken, resourceExternalId: entry.resourceExternalId, ruleType: entry.ruleType, sourceColumn: toColumnLetter(entry.firstColumnNumber), sourceRow: entry.sourceRow, warnings: Array.from(entry.warnings), })); return { assignments: parsedAssignments, availabilityRules: parsedAvailabilityRules, unresolved, vacations: parsedVacations, warnings, }; }