778 lines
24 KiB
TypeScript
778 lines
24 KiB
TypeScript
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<string>;
|
|
winProbability: number | null;
|
|
}
|
|
|
|
interface VacationAccumulator {
|
|
endDate: Date;
|
|
firstColumnNumber: number;
|
|
halfDayParts: Set<string>;
|
|
holidayName: string | null;
|
|
isPublicHoliday: boolean;
|
|
note: string | null;
|
|
rawToken: string;
|
|
resourceExternalId: string;
|
|
sourceRow: number;
|
|
startDate: Date;
|
|
vacationType: VacationType;
|
|
warnings: Set<string>;
|
|
}
|
|
|
|
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<string>;
|
|
}
|
|
|
|
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<WorksheetCellValue>): 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<ReadonlyArray<WorksheetCellValue>>) {
|
|
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<string>(),
|
|
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<string>();
|
|
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<string>(),
|
|
};
|
|
}
|
|
|
|
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<string>();
|
|
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<ParsedPlanningWorkbook> {
|
|
const rows = await readWorksheetMatrix(workbookPath, DISPO_PLANNING_SHEET);
|
|
const planningColumns = buildPlanningColumns(rows);
|
|
const assignments = new Map<string, AssignmentAccumulator>();
|
|
const vacations = new Map<string, VacationAccumulator>();
|
|
const availabilityRules = new Map<string, AvailabilityAccumulator>();
|
|
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,
|
|
};
|
|
}
|