feat(application): complete dispo import operator flow

This commit is contained in:
2026-03-14 15:14:55 +01:00
parent 6a2f552ccb
commit 4dabb9d4ce
11 changed files with 1034 additions and 32 deletions
@@ -101,6 +101,18 @@ interface AvailabilityAccumulator {
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;
@@ -241,6 +253,14 @@ function extractProjectKey(token: string): string | 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, "")
@@ -269,6 +289,84 @@ function parsePercentage(value: string): number | 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,
@@ -278,7 +376,8 @@ function buildAssignmentAccumulator(
const roleName = normalizeDispoRoleToken(roleToken);
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken);
const projectKey = extractProjectKey(rawToken);
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 ?? "");
@@ -340,11 +439,21 @@ function buildAvailabilityAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
rawToken: string,
input: {
availableHours?: number | null;
percentage?: number | null;
ruleType?: string;
warning?: string;
} = {},
): AvailabilityAccumulator {
const percentage = parsePercentage(rawToken);
const percentage = input.percentage ?? parsePercentage(rawToken);
const availableHours = percentage !== null
? Math.round((percentage / 100) * 8 * 100) / 100
: 8 - SLOT_HOURS;
: (input.availableHours ?? (8 - SLOT_HOURS));
const warnings = new Set<string>();
if (input.warning) {
warnings.add(input.warning);
}
return {
availableHours,
@@ -355,9 +464,9 @@ function buildAvailabilityAccumulator(
percentage,
rawToken,
resourceExternalId: metadata.eid,
ruleType: "PART_TIME",
ruleType: input.ruleType ?? "PART_TIME",
sourceRow: 0,
warnings: new Set<string>(),
warnings,
};
}
@@ -388,7 +497,18 @@ export async function parseDispoPlanningWorkbook(
};
for (const column of planningColumns) {
const rawCellValue = normalizeNullableWorkbookValue(row[column.columnNumber - 1]);
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;
}
@@ -396,6 +516,10 @@ export async function parseDispoPlanningWorkbook(
const rawToken = normalizePlanningToken(rawCellValue);
const normalizedToken = rawToken.toUpperCase();
if (normalizedToken === "IN DAYS") {
continue;
}
if (normalizedToken === "[_NA] WEEKEND {NA}") {
continue;
}
@@ -442,20 +566,69 @@ export async function parseDispoPlanningWorkbook(
continue;
}
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PART-TIME")) {
const naHandling = classifyNaPlanningToken(rawToken);
if (naHandling.kind === "availability") {
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`;
const existing = availabilityRules.get(key);
if (existing) {
existing.availableHours = buildAvailabilityAccumulator(column, metadata, rawToken).availableHours;
existing.percentage = buildAvailabilityAccumulator(column, metadata, rawToken).percentage;
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
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);
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
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;
}