Files
CapaKraken/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts
T

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,
};
}