chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,335 @@
import { ImportBatchStatus, type Prisma } from "@planarchy/db";
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
import { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
import { readWorksheetMatrix } from "./read-workbook.js";
import {
DISPO_REFERENCE_SHEET,
type DispoImportDbClient,
isPseudoDemandResourceIdentity,
normalizeText,
toJsonObject,
} from "./shared.js";
export interface AssessDispoImportReadinessInput {
chargeabilityWorkbookPath: string;
costWorkbookPath?: string;
importBatchId?: string;
notes?: string | null;
planningWorkbookPath: string;
referenceWorkbookPath: string;
rosterWorkbookPath?: string;
}
export interface DispoImportReadinessIssue {
code:
| "FALLBACK_EMAIL_REQUIRED"
| "FALLBACK_LCR_REQUIRED"
| "FALLBACK_UCR_REQUIRED"
| "PLANNING_RESOURCE_MISSING_FROM_ROSTER"
| "REFERENCE_RESOURCE_MASTER_MISSING"
| "UNRESOLVED_RECORDS_PRESENT";
count: number;
message: string;
resolution: string;
severity: "blocker" | "warning";
}
export interface DispoImportReadinessReport {
assignmentCount: number;
availabilityRuleCount: number;
canCommitWithFallbacks: boolean;
canCommitWithStrictSourceData: boolean;
fallbackAssumptions: string[];
issues: DispoImportReadinessIssue[];
projectCount: number;
resourceCount: number;
unresolvedCount: number;
vacationCount: number;
}
interface MergedResourceReadinessRecord {
canonicalExternalId: string;
email: string | null;
lcrCents: number | null;
ucrCents: number | null;
}
function filterUnresolvedCount(
unresolved: ReadonlyArray<{ resourceExternalId?: string | null }>,
excludedIds: ReadonlySet<string>,
) {
return unresolved.filter(
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
).length;
}
function buildReadinessIssue(issue: DispoImportReadinessIssue): DispoImportReadinessIssue {
return issue;
}
function derivePlanningResourceIds(input: {
assignments: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["assignments"];
availabilityRules: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["availabilityRules"];
vacations: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["vacations"];
}) {
const resourceIds = new Set<string>();
for (const assignment of input.assignments) {
if (isPseudoDemandResourceIdentity(assignment.resourceExternalId)) {
continue;
}
resourceIds.add(assignment.resourceExternalId);
}
for (const vacation of input.vacations) {
if (isPseudoDemandResourceIdentity(vacation.resourceExternalId)) {
continue;
}
resourceIds.add(vacation.resourceExternalId);
}
for (const rule of input.availabilityRules) {
if (isPseudoDemandResourceIdentity(rule.resourceExternalId)) {
continue;
}
resourceIds.add(rule.resourceExternalId);
}
return resourceIds;
}
async function hasResourceMasterRows(referenceWorkbookPath: string) {
const rows = await readWorksheetMatrix(referenceWorkbookPath, DISPO_REFERENCE_SHEET);
for (let index = 1; index < rows.length; index += 1) {
const firstCell = normalizeText(rows[index]?.[0]);
if (!firstCell) {
continue;
}
if (/^[a-z0-9]+(?:\.[a-z0-9]+)+$/i.test(firstCell)) {
return true;
}
}
return false;
}
export async function assessDispoImportReadiness(
input: AssessDispoImportReadinessInput,
): Promise<DispoImportReadinessReport> {
const [
referenceWorkbook,
chargeabilityWorkbook,
planningWorkbook,
resourceMasterPresent,
rosterWorkbook,
] =
await Promise.all([
parseMandatoryDispoReferenceWorkbook(input.referenceWorkbookPath),
parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath),
parseDispoPlanningWorkbook(input.planningWorkbookPath),
hasResourceMasterRows(input.referenceWorkbookPath),
input.rosterWorkbookPath
? parseDispoRosterWorkbook(input.rosterWorkbookPath, {
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
})
: null,
]);
const mergedResources = new Map<string, MergedResourceReadinessRecord>();
const excludedIds = new Set(rosterWorkbook?.excludedCanonicalExternalIds ?? []);
for (const resource of chargeabilityWorkbook.resources) {
if (excludedIds.has(resource.canonicalExternalId)) {
continue;
}
mergedResources.set(resource.canonicalExternalId, {
canonicalExternalId: resource.canonicalExternalId,
email: resource.email,
lcrCents: null,
ucrCents: null,
});
}
for (const resource of rosterWorkbook?.resources ?? []) {
if (excludedIds.has(resource.canonicalExternalId)) {
continue;
}
const existing = mergedResources.get(resource.canonicalExternalId);
mergedResources.set(resource.canonicalExternalId, {
canonicalExternalId: resource.canonicalExternalId,
email: resource.email ?? existing?.email ?? null,
lcrCents: resource.lcrCents ?? existing?.lcrCents ?? null,
ucrCents: resource.ucrCents ?? existing?.ucrCents ?? null,
});
}
const rosterIds = new Set(mergedResources.keys());
const planningResourceIds = derivePlanningResourceIds(planningWorkbook);
const planningResourceMissingFromRoster = Array.from(planningResourceIds).filter(
(resourceId) => !excludedIds.has(resourceId) && !rosterIds.has(resourceId),
);
const unresolvedCount =
filterUnresolvedCount(chargeabilityWorkbook.unresolved, excludedIds) +
filterUnresolvedCount(planningWorkbook.unresolved, excludedIds) +
filterUnresolvedCount(rosterWorkbook?.unresolved ?? [], excludedIds);
const missingEmailCount = Array.from(mergedResources.values()).filter(
(resource) => !resource.email,
).length;
const missingLcrCount = Array.from(mergedResources.values()).filter(
(resource) => resource.lcrCents === null,
).length;
const missingUcrCount = Array.from(mergedResources.values()).filter(
(resource) => resource.ucrCents === null,
).length;
const issues: DispoImportReadinessIssue[] = [];
if (!resourceMasterPresent && !rosterWorkbook) {
issues.push(
buildReadinessIssue({
code: "REFERENCE_RESOURCE_MASTER_MISSING",
count: 1,
message:
"MandatoryDispoCategories_V3.xlsx contains reference/glossary sections only; it does not contain row-based resource master records.",
resolution:
"Provide a real resource master source or explicitly approve generated fallback values for missing required Resource fields.",
severity: "blocker",
}),
);
}
if (missingEmailCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_EMAIL_REQUIRED",
count: missingEmailCount,
message:
"Some imported resources still do not have real email addresses after merging the Dispo roster and SAP source data.",
resolution:
"Approve generated placeholder emails for the remaining resources or provide a missing roster/SAP source with email addresses.",
severity: "blocker",
}),
);
}
if (missingLcrCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_LCR_REQUIRED",
count: missingLcrCount,
message:
"Some staged resources still do not have resolved LCR values after merging roster data with the cost-rate workbook.",
resolution:
"Provide missing per-resource LCR values or approve placeholders only for the remaining unresolved resources.",
severity: "blocker",
}),
);
}
if (missingUcrCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_UCR_REQUIRED",
count: missingUcrCount,
message:
"Some staged resources still do not have resolved UCR values after merging roster data with the cost-rate workbook.",
resolution:
"Provide missing per-resource UCR values or approve placeholders only for the remaining unresolved resources.",
severity: "blocker",
}),
);
}
if (planningResourceMissingFromRoster.length > 0) {
issues.push(
buildReadinessIssue({
code: "PLANNING_RESOURCE_MISSING_FROM_ROSTER",
count: planningResourceMissingFromRoster.length,
message:
"Some resource identities appear in planning data but not in the merged roster/resource-master inputs.",
resolution:
"Add the missing resources to the roster or chargeability inputs, or decide how planning-only identities should be imported.",
severity: "blocker",
}),
);
}
if (unresolvedCount > 0) {
issues.push(
buildReadinessIssue({
code: "UNRESOLVED_RECORDS_PRESENT",
count: unresolvedCount,
message:
"The staging inputs contain unresolved rows, primarily [tbd] project references that must stay out of final project commit.",
resolution:
"Review unresolved rows before final commit or keep automatic commit scoped to resolved rows only.",
severity: "warning",
}),
);
}
const strictBlockers = issues.filter((issue) => issue.severity === "blocker");
const fallbackOnlyBlockers = new Set<DispoImportReadinessIssue["code"]>([
"FALLBACK_EMAIL_REQUIRED",
"FALLBACK_LCR_REQUIRED",
"FALLBACK_UCR_REQUIRED",
"REFERENCE_RESOURCE_MASTER_MISSING",
]);
const canCommitWithStrictSourceData = strictBlockers.length === 0;
const canCommitWithFallbacks = strictBlockers.every((issue) =>
fallbackOnlyBlockers.has(issue.code),
);
return {
resourceCount: mergedResources.size,
projectCount: planningWorkbook.assignments.filter(
(assignment) =>
assignment.projectKey !== null && !assignment.isTbd && !assignment.isUnassigned,
).length,
assignmentCount: planningWorkbook.assignments.length,
vacationCount: planningWorkbook.vacations.length,
availabilityRuleCount: planningWorkbook.availabilityRules.length,
unresolvedCount,
canCommitWithStrictSourceData,
canCommitWithFallbacks,
fallbackAssumptions: canCommitWithFallbacks
? [
"Generate fallback email as <enterpriseId>@accenture.com for imported resources that do not have one in the source files.",
"Commit placeholder LCR/UCR values only for resources still unresolved after roster-to-rate matching and level-average fallback.",
"Keep unresolved [tbd] rows staged and exclude them from final project creation.",
]
: [],
issues,
};
}
export async function persistDispoImportReadiness(
db: DispoImportDbClient,
input: AssessDispoImportReadinessInput & { importBatchId: string },
) {
const report = await assessDispoImportReadiness(input);
const batch = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, summary: true },
});
if (!batch) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
const nextSummary = {
...toJsonObject(batch.summary),
readiness: report,
};
await db.importBatch.update({
where: { id: batch.id },
data: {
status: ImportBatchStatus.STAGED,
summary: nextSummary as unknown as Prisma.InputJsonValue,
},
});
return report;
}
@@ -0,0 +1,31 @@
export { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
export {
assessDispoImportReadiness,
persistDispoImportReadiness,
type AssessDispoImportReadinessInput,
type DispoImportReadinessIssue,
type DispoImportReadinessReport,
} from "./assess-import-readiness.js";
export { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
export { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
export { parseResourceRosterMasterWorkbook } from "./parse-resource-roster-master-workbook.js";
export { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
export {
stageDispoReferenceData,
type StageDispoReferenceDataResult,
} from "./stage-reference-data.js";
export {
stageDispoChargeabilityResources,
type StageDispoChargeabilityResourcesResult,
} from "./stage-chargeability-resources.js";
export {
stageDispoRosterResources,
type StageDispoRosterResourcesResult,
} from "./stage-dispo-roster-resources.js";
export { stageDispoPlanningData, type StageDispoPlanningResult } from "./stage-dispo-planning.js";
export { stageDispoProjects, type StageDispoProjectsResult } from "./stage-dispo-projects.js";
export {
stageDispoImportBatch,
type StageDispoImportBatchInput,
type StageDispoImportBatchResult,
} from "./stage-dispo-import-batch.js";
@@ -0,0 +1,206 @@
import { DispoStagedRecordType } from "@planarchy/db";
import { DISPO_CHARGEABILITY_SHEET, type ParsedChargeabilityResource, type ParsedChargeabilityWorkbook, type ParsedUnresolvedRecord, buildFallbackAccentureEmail, createAvailabilityFromFte, deriveCountryCodeFromMetroCity, deriveDisplayNameFromEnterpriseId, deriveNormalizedChapter, deriveRoleTokens, ensurePercentageValue, mapChargeabilityResourceType, normalizeNullableWorkbookValue, normalizeText, resolveCanonicalEnterpriseIdentity } from "./shared.js";
import { readWorksheetMatrix } from "./read-workbook.js";
const CHGFC_HEADERS = {
clientUnit: "MV Client Unit",
enterpriseId: "Enterprise ID",
fte: "FTE",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
orgUnitLevel6: "Org Unit Level 6",
rawChapter: "MV Org Unit 1 / Chapter",
rawResourceType: "MV Ressource Type",
target: "Target (per Level)",
} as const;
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
headerName: string,
): unknown {
const index = headerMap.get(headerName);
if (index === undefined) {
return null;
}
return row[index] ?? null;
}
function buildResourceSignature(resource: ParsedChargeabilityResource): string {
return JSON.stringify({
chapter: resource.chapter,
chapterCode: resource.chapterCode,
chargeabilityTarget: resource.chargeabilityTarget,
clientUnitName: resource.clientUnitName,
countryCode: resource.countryCode,
fte: resource.fte,
managementLevelGroupName: resource.managementLevelGroupName,
metroCityName: resource.metroCityName,
resourceType: resource.resourceType,
roleTokens: resource.roleTokens,
});
}
export async function parseDispoChargeabilityWorkbook(
workbookPath: string,
): Promise<ParsedChargeabilityWorkbook> {
const rows = await readWorksheetMatrix(workbookPath, DISPO_CHARGEABILITY_SHEET);
const headerMap = buildHeaderMap(rows[0] ?? []);
const warnings: string[] = [];
const unresolved: ParsedUnresolvedRecord[] = [];
const resourceByCanonicalId = new Map<string, ParsedChargeabilityResource>();
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const enterpriseIdValue = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.enterpriseId),
);
if (!enterpriseIdValue) {
if (row.some((value) => normalizeText(value) !== null)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: null,
message: "Missing Enterprise ID in ChgFC row",
resolutionHint: "Populate Enterprise ID before staging resource data",
warnings: [],
normalizedData: {},
});
}
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: enterpriseIdValue,
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
resolutionHint: "Validate Enterprise ID formatting in ChgFC",
warnings: [],
normalizedData: {
enterpriseId: enterpriseIdValue,
},
});
continue;
}
const managementLevelGroupName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.managementLevelGroup),
);
const rawTarget = getCellValue(row, headerMap, CHGFC_HEADERS.target);
const fte = typeof getCellValue(row, headerMap, CHGFC_HEADERS.fte) === "number"
? Number(getCellValue(row, headerMap, CHGFC_HEADERS.fte))
: null;
const metroCityName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.metroCity),
);
const rawResourceType = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.rawResourceType),
);
const levelSixName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.orgUnitLevel6),
);
const rawChapter = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.rawChapter),
);
const clientUnitName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.clientUnit),
);
const roleTokens = deriveRoleTokens(levelSixName, rawChapter);
const normalizedChapter = deriveNormalizedChapter(rawChapter, roleTokens);
const resourceTypeResult = mapChargeabilityResourceType(rawResourceType);
const recordWarnings = resourceTypeResult.warning ? [resourceTypeResult.warning] : [];
const chargeabilityTarget =
typeof rawTarget === "number" ? ensurePercentageValue(rawTarget) : null;
const resource: ParsedChargeabilityResource = {
sourceRow: rowNumber,
canonicalExternalId,
enterpriseId: canonicalExternalId,
eid: canonicalExternalId,
displayName: deriveDisplayNameFromEnterpriseId(canonicalExternalId),
email: buildFallbackAccentureEmail(canonicalExternalId),
chapter: normalizedChapter.chapter,
chapterCode: normalizedChapter.chapterCode,
managementLevelGroupName,
managementLevelName: null,
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
metroCityName,
clientUnitName,
rawResourceType,
resourceType: resourceTypeResult.resourceType,
chargeabilityTarget,
fte,
availability: createAvailabilityFromFte(fte),
roleTokens,
warnings: recordWarnings,
};
const existing = resourceByCanonicalId.get(canonicalExternalId);
if (!existing) {
resourceByCanonicalId.set(canonicalExternalId, resource);
continue;
}
const existingSignature = buildResourceSignature(existing);
const nextSignature = buildResourceSignature(resource);
if (existingSignature === nextSignature) {
existing.warnings.push(`Duplicate ChgFC row ${rowNumber} ignored for ${canonicalExternalId}`);
continue;
}
existing.warnings.push(`Conflicting duplicate ChgFC row ${rowNumber} found for ${canonicalExternalId}`);
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Conflicting resource roster rows found for ${canonicalExternalId}`,
resolutionHint: "Resolve the differing ChgFC roster values before commit",
warnings: [...recordWarnings],
normalizedData: {
existing: {
sourceRow: existing.sourceRow,
chapter: existing.chapter,
clientUnitName: existing.clientUnitName,
fte: existing.fte,
metroCityName: existing.metroCityName,
},
conflicting: {
sourceRow: resource.sourceRow,
chapter: resource.chapter,
clientUnitName: resource.clientUnitName,
fte: resource.fte,
metroCityName: resource.metroCityName,
},
},
});
}
return {
resources: Array.from(resourceByCanonicalId.values()),
unresolved,
warnings,
};
}
@@ -0,0 +1,582 @@
import { DispoStagedRecordType } from "@planarchy/db";
import {
VacationType,
normalizeCanonicalResourceIdentity,
normalizeDispoRoleToken,
normalizeDispoUtilizationToken,
} from "@planarchy/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 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 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 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 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 projectKey = extractProjectKey(rawToken);
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,
): AvailabilityAccumulator {
const percentage = parsePercentage(rawToken);
const availableHours = percentage !== null
? Math.round((percentage / 100) * 8 * 100) / 100
: 8 - SLOT_HOURS;
return {
availableHours,
effectiveEndDate: column.assignmentDate,
effectiveStartDate: column.assignmentDate,
firstColumnNumber: column.columnNumber,
isResolved: false,
percentage,
rawToken,
resourceExternalId: metadata.eid,
ruleType: "PART_TIME",
sourceRow: 0,
warnings: new Set<string>(),
};
}
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] ?? [];
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 rawCellValue = normalizeNullableWorkbookValue(row[column.columnNumber - 1]);
if (!rawCellValue) {
continue;
}
const rawToken = normalizePlanningToken(rawCellValue);
const normalizedToken = rawToken.toUpperCase();
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;
}
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PART-TIME")) {
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;
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken);
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
}
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,
};
}
@@ -0,0 +1,570 @@
import { DispoStagedRecordType, ResourceType } from "@planarchy/db";
import { createWeekdayAvailabilityFromFte } from "@planarchy/shared";
import {
parseResourceRosterMasterWorkbook,
type ParsedResourceRosterLevelAverage,
type ParsedResourceRosterMasterWorkbook,
type ParsedResourceRosterRate,
} from "./parse-resource-roster-master-workbook.js";
import {
DISPO_ROSTER_SAP_SHEET,
DISPO_ROSTER_SHEET,
type ParsedRosterResource,
type ParsedRosterWorkbook,
type ParsedUnresolvedRecord,
buildFallbackAccentureEmail,
deriveCountryCodeFromMetroCity,
deriveDisplayNameFromEnterpriseId,
deriveNormalizedChapter,
deriveRoleTokens,
isPseudoDemandResourceIdentity,
mapChargeabilityResourceType,
normalizeNullableWorkbookValue,
normalizeText,
resolveCanonicalEnterpriseIdentity,
} from "./shared.js";
import { readWorksheetMatrix } from "./read-workbook.js";
const ROSTER_HEADERS = {
clientUnit: "MV Client Unit",
dailyWorkingHoursPerFte: "Daily Working Hours/FTE",
department: "MV Org Unit 2 / Department",
eid: "EID",
firstDayInDispo: "First day in dispo",
fte: "FTE",
lastDayInDispo: "Last day in dispo",
mainSkillset: "MV Main Skillset",
managementLevel: "Management Level",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
rawChapter: "MV Org Unit 1 / Chapter",
rawResourceType: "MV Ressource Type",
resourceHoursPerWeek: "Resource Hours/Week",
vacationDaysPerYear: "Vacation days / year",
} as const;
const SAP_HEADERS = {
employeeEmail: "Employee Email",
employeeName: "Employee Name",
enterpriseId: "Enterprise ID",
fte: "FTE",
managementLevel: "Management Level",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
orgUnitLevel5: "Org Unit Level 5",
orgUnitLevel6: "Org Unit Level 6",
orgUnitLevel7: "Org Unit Level 7",
} as const;
interface RosterSourceRow {
canonicalExternalId: string;
clientUnitName: string | null;
dailyWorkingHoursPerFte: number | null;
department: string | null;
fte: number | null;
mainSkillset: string | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawChapter: string | null;
rawResourceType: string | null;
resourceHoursPerWeek: number | null;
rowNumber: number;
vacationDaysPerYear: number | null;
firstDayInDispo: Date | null;
lastDayInDispo: Date | null;
}
interface SapSourceRow {
canonicalExternalId: string;
employeeEmail: string | null;
employeeName: string | null;
fte: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
orgUnitLevelFive: string | null;
orgUnitLevelSix: string | null;
orgUnitLevelSeven: string | null;
rowNumber: number;
}
interface ParseDispoRosterWorkbookOptions {
costWorkbookPath?: string;
}
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
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 parseOptionalDate(value: unknown): Date | null {
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
return value;
}
const normalized = normalizeNullableWorkbookValue(value);
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.valueOf()) ? null : parsed;
}
function normalizeSapDisplayName(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = value
.split(",")
.map((part) => normalizeText(part))
.filter((part): part is string => Boolean(part));
if (normalized.length === 2) {
return `${normalized[1]} ${normalized[0]}`;
}
return normalizeText(value);
}
function buildResourceWarnings(
resourceType: ResourceType,
resourceTypeWarning: string | null,
roster: RosterSourceRow | null,
sap: SapSourceRow | null,
): string[] {
const warnings: string[] = [];
if (resourceTypeWarning) {
warnings.push(resourceTypeWarning);
}
if (!roster) {
warnings.push("Missing DispoRoster row; resource imported from SAP_data only");
}
if (!sap) {
warnings.push("Missing SAP_data row; email and display name fall back to derived values");
}
if (resourceType === ResourceType.FREELANCER && !roster?.dailyWorkingHoursPerFte) {
warnings.push("Freelancer row has no daily working hours value; defaulting to 8h/day");
}
return warnings;
}
function shouldExcludeImportedResource(resource: {
canonicalExternalId: string;
sourceEmail: string | null;
managementLevelName: string | null;
}) {
return !resource.sourceEmail && !resource.managementLevelName;
}
function applyRateResolution(input: {
canonicalExternalId: string;
level: string | null;
rateRecord: ParsedResourceRosterRate | null;
levelAverage: ParsedResourceRosterLevelAverage | null;
warnings: string[];
}) {
const { canonicalExternalId, level, rateRecord, levelAverage, warnings } = input;
const exactLcr = rateRecord?.lcrCents ?? null;
const exactUcr = rateRecord?.ucrCents ?? null;
const fallbackLcr = levelAverage?.lcrCents ?? null;
const fallbackUcr = levelAverage?.ucrCents ?? null;
const lcrCents = exactLcr ?? fallbackLcr;
const ucrCents = exactUcr ?? fallbackUcr;
if (!rateRecord) {
if (levelAverage && lcrCents !== null && ucrCents !== null) {
warnings.push(
`Applied level-average rates for ${canonicalExternalId} using management level ${levelAverage.level}`,
);
return {
lcrCents,
ucrCents,
rateResolution: "LEVEL_AVERAGE" as const,
rateResolutionLevel: levelAverage.level,
};
}
warnings.push(
level
? `Missing rate row for ${canonicalExternalId}; no usable level-average rate found for ${level}`
: `Missing rate row for ${canonicalExternalId}; management level unavailable for fallback`,
);
return {
lcrCents,
ucrCents,
rateResolution: "MISSING" as const,
rateResolutionLevel: levelAverage?.level ?? level ?? null,
};
}
if (exactLcr !== null && exactUcr !== null) {
return {
lcrCents,
ucrCents,
rateResolution: "EXACT" as const,
rateResolutionLevel: rateRecord.level ?? null,
};
}
if (levelAverage && lcrCents !== null && ucrCents !== null) {
warnings.push(
`Completed incomplete rate row for ${canonicalExternalId} with level-average rates from ${levelAverage.level}`,
);
return {
lcrCents,
ucrCents,
rateResolution: "LEVEL_AVERAGE" as const,
rateResolutionLevel: levelAverage.level,
};
}
warnings.push(`Incomplete rate row for ${canonicalExternalId} could not be fully resolved`);
return {
lcrCents,
ucrCents,
rateResolution: "MISSING" as const,
rateResolutionLevel: rateRecord.level ?? level ?? null,
};
}
export async function parseDispoRosterWorkbook(
workbookPath: string,
options: ParseDispoRosterWorkbookOptions = {},
): Promise<ParsedRosterWorkbook> {
const [rosterRows, sapRows] = await Promise.all([
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SHEET),
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SAP_SHEET),
]);
const rateWorkbook: ParsedResourceRosterMasterWorkbook | null = options.costWorkbookPath
? await parseResourceRosterMasterWorkbook(options.costWorkbookPath)
: null;
const rosterHeaderMap = buildHeaderMap(rosterRows[0] ?? []);
const sapHeaderMap = buildHeaderMap(sapRows[1] ?? []);
const warnings: string[] = [...(rateWorkbook?.warnings ?? [])];
const unresolved: ParsedUnresolvedRecord[] = [];
const rosterById = new Map<string, RosterSourceRow>();
const sapById = new Map<string, SapSourceRow>();
let ignoredPseudoDemandRows = 0;
for (let rowNumber = 2; rowNumber <= rosterRows.length; rowNumber += 1) {
const row = rosterRows[rowNumber - 1] ?? [];
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
if (!eidValue) {
if (row.some((value) => normalizeText(value) !== null)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: null,
message: "Missing EID in DispoRoster row",
resolutionHint: "Populate EID before staging roster resource data",
warnings: [],
normalizedData: {},
});
}
continue;
}
if (isPseudoDemandResourceIdentity(eidValue)) {
ignoredPseudoDemandRows += 1;
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(eidValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: eidValue,
message: `Unable to normalize EID "${eidValue}"`,
resolutionHint: "Validate EID formatting in DispoRoster",
warnings: [],
normalizedData: { eid: eidValue },
});
continue;
}
if (rosterById.has(canonicalExternalId)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Duplicate DispoRoster row found for ${canonicalExternalId}`,
resolutionHint: "Keep exactly one operational roster row per EID",
warnings: [],
normalizedData: { eid: canonicalExternalId },
});
continue;
}
rosterById.set(canonicalExternalId, {
canonicalExternalId,
rowNumber,
metroCityName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.metroCity),
),
managementLevelGroupName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevelGroup),
),
managementLevelName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevel),
),
fte: parseOptionalNumber(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.fte)),
dailyWorkingHoursPerFte: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.dailyWorkingHoursPerFte),
),
resourceHoursPerWeek: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.resourceHoursPerWeek),
),
rawResourceType: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawResourceType),
),
clientUnitName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.clientUnit),
),
rawChapter: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawChapter),
),
department: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.department),
),
mainSkillset: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.mainSkillset),
),
firstDayInDispo: parseOptionalDate(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.firstDayInDispo),
),
lastDayInDispo: parseOptionalDate(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.lastDayInDispo),
),
vacationDaysPerYear: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.vacationDaysPerYear),
),
});
}
for (let rowNumber = 3; rowNumber <= sapRows.length; rowNumber += 1) {
const row = sapRows[rowNumber - 1] ?? [];
const enterpriseIdValue = normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.enterpriseId),
);
if (!enterpriseIdValue) {
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "C",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: enterpriseIdValue,
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
resolutionHint: "Validate Enterprise ID formatting in SAP_data",
warnings: [],
normalizedData: { enterpriseId: enterpriseIdValue },
});
continue;
}
if (sapById.has(canonicalExternalId)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "C",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Duplicate SAP_data row found for ${canonicalExternalId}`,
resolutionHint: "Keep exactly one SAP_data row per Enterprise ID",
warnings: [],
normalizedData: { enterpriseId: canonicalExternalId },
});
continue;
}
sapById.set(canonicalExternalId, {
canonicalExternalId,
rowNumber,
employeeName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeName),
),
employeeEmail: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeEmail),
)?.toLowerCase() ?? null,
metroCityName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.metroCity),
),
managementLevelGroupName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevelGroup),
),
managementLevelName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevel),
),
orgUnitLevelFive: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel5),
),
orgUnitLevelSix: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel6),
),
orgUnitLevelSeven: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel7),
),
fte: parseOptionalNumber(getCellValue(row, sapHeaderMap, SAP_HEADERS.fte)),
});
}
const resourceIds = new Set<string>([...rosterById.keys(), ...sapById.keys()]);
const resources: ParsedRosterResource[] = [];
const excludedCanonicalExternalIds = new Set<string>();
for (const canonicalExternalId of resourceIds) {
const roster = rosterById.get(canonicalExternalId) ?? null;
const sap = sapById.get(canonicalExternalId) ?? null;
const roleTokens = deriveRoleTokens(
roster?.department,
roster?.rawChapter,
roster?.mainSkillset,
sap?.orgUnitLevelSix,
sap?.orgUnitLevelSeven,
);
const normalizedChapter = deriveNormalizedChapter(roster?.rawChapter ?? null, roleTokens);
const resourceTypeResult = mapChargeabilityResourceType(roster?.rawResourceType ?? null);
const resourceType =
roster?.rawResourceType || sap ? resourceTypeResult.resourceType : ResourceType.EMPLOYEE;
const fte = sap?.fte ?? roster?.fte ?? null;
const dailyWorkingHoursPerFte = roster?.dailyWorkingHoursPerFte ?? null;
const displayName =
normalizeSapDisplayName(sap?.employeeName ?? null) ??
deriveDisplayNameFromEnterpriseId(canonicalExternalId);
const metroCityName = sap?.metroCityName ?? roster?.metroCityName ?? null;
const managementLevelName =
sap?.managementLevelName ?? roster?.managementLevelName ?? null;
const resourceWarnings = buildResourceWarnings(resourceType, resourceTypeResult.warning, roster, sap);
const rateResolution = applyRateResolution({
canonicalExternalId,
level: managementLevelName,
rateRecord: rateWorkbook?.rates.get(canonicalExternalId) ?? null,
levelAverage: managementLevelName
? rateWorkbook?.levelAverages.get(managementLevelName) ?? null
: null,
warnings: resourceWarnings,
});
const resource: ParsedRosterResource = {
sourceRow: roster?.rowNumber ?? sap?.rowNumber ?? 0,
sourceSheet: roster ? DISPO_ROSTER_SHEET : DISPO_ROSTER_SAP_SHEET,
canonicalExternalId,
enterpriseId: canonicalExternalId,
eid: canonicalExternalId,
displayName,
email: sap?.employeeEmail ?? buildFallbackAccentureEmail(canonicalExternalId),
chapter: normalizedChapter.chapter,
chapterCode: normalizedChapter.chapterCode,
managementLevelGroupName: sap?.managementLevelGroupName ?? roster?.managementLevelGroupName ?? null,
managementLevelName,
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
metroCityName,
clientUnitName: roster?.clientUnitName ?? null,
rawResourceType: roster?.rawResourceType ?? null,
resourceType,
fte,
lcrCents: rateResolution.lcrCents,
ucrCents: rateResolution.ucrCents,
rateResolution: rateResolution.rateResolution,
rateResolutionLevel: rateResolution.rateResolutionLevel,
availability: createWeekdayAvailabilityFromFte(
fte ?? 1,
dailyWorkingHoursPerFte ?? 8,
) as unknown as ParsedRosterResource["availability"],
roleTokens,
dailyWorkingHoursPerFte,
department: roster?.department ?? null,
mainSkillset: roster?.mainSkillset ?? null,
resourceHoursPerWeek: roster?.resourceHoursPerWeek ?? null,
firstDayInDispo: roster?.firstDayInDispo ?? null,
lastDayInDispo: roster?.lastDayInDispo ?? null,
vacationDaysPerYear: roster?.vacationDaysPerYear ?? null,
sapEmployeeName: sap?.employeeName ?? null,
sapOrgUnitLevelFive: sap?.orgUnitLevelFive ?? null,
sapOrgUnitLevelSix: sap?.orgUnitLevelSix ?? null,
sapOrgUnitLevelSeven: sap?.orgUnitLevelSeven ?? null,
warnings: resourceWarnings,
};
if (
shouldExcludeImportedResource({
canonicalExternalId,
sourceEmail: sap?.employeeEmail ?? null,
managementLevelName,
})
) {
excludedCanonicalExternalIds.add(canonicalExternalId);
warnings.push(
`Excluded ${canonicalExternalId} from import because neither email nor management level is present in the supplied sources`,
);
continue;
}
resources.push(resource);
}
if (ignoredPseudoDemandRows > 0) {
warnings.push(`Ignored ${ignoredPseudoDemandRows} pseudo-demand rows from DispoRoster`);
}
resources.sort((left, right) => left.canonicalExternalId.localeCompare(right.canonicalExternalId));
return {
excludedCanonicalExternalIds: Array.from(excludedCanonicalExternalIds).sort((left, right) =>
left.localeCompare(right),
),
resources,
unresolved,
warnings,
ignoredPseudoDemandRows,
};
}
@@ -0,0 +1,251 @@
import {
DISPO_PROJECT_REFERENCE_SHEET,
DISPO_REFERENCE_SHEET,
type ParsedClientReference,
type ParsedCountryReference,
type ParsedManagementLevelGroupReference,
type ParsedOrgUnitReference,
type ParsedReferenceWorkbook,
findSectionRow,
getCountryReferenceConfig,
normalizeClientCode,
normalizeNullableWorkbookValue,
normalizeText,
sanitizeClientName,
} from "./shared.js";
import { readWorksheetMatrix, toColumnLetter } from "./read-workbook.js";
function isTerminalSectionName(value: string | null, names: readonly string[]): boolean {
if (!value) {
return false;
}
const normalizedValue = value.toLowerCase();
return names.some((name) => name.toLowerCase() === normalizedValue);
}
function parseCountryReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { countries: ParsedCountryReference[]; warnings: string[] } {
const warnings: string[] = [];
const countries: ParsedCountryReference[] = [];
const startRow = findSectionRow(rows, "Country/Territory") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const firstCell = normalizeText(row[0]);
if (!firstCell) {
continue;
}
if (isTerminalSectionName(firstCell, ["Org Unit Level 5"])) {
break;
}
const config = getCountryReferenceConfig(firstCell);
if (!config) {
warnings.push(`Unsupported country reference "${firstCell}" in EID-Attr row ${rowNumber}`);
continue;
}
const metroCities = row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value));
countries.push({
sourceRow: rowNumber,
countryCode: config.code,
name: firstCell,
dailyWorkingHours: config.dailyWorkingHours,
metroCities,
...("scheduleRules" in config ? { scheduleRules: config.scheduleRules } : {}),
});
}
return { countries, warnings };
}
function parseOrgUnitReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { orgUnits: ParsedOrgUnitReference[]; warnings: string[] } {
const warnings: string[] = [];
const orgUnits: ParsedOrgUnitReference[] = [];
const levelFiveHeaderRow = findSectionRow(rows, "Org Unit Level 5");
const levelSixHeaderRow = findSectionRow(rows, "Org Unit Level 6");
const managementLevelRow = findSectionRow(rows, "Management Level Group");
for (let rowNumber = levelFiveHeaderRow + 1; rowNumber < levelSixHeaderRow; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const levelFiveName = normalizeNullableWorkbookValue(row[0]);
if (!levelFiveName) {
continue;
}
const secondCell = normalizeText(row[1]);
if (secondCell?.includes("wird nicht mehr benötigt")) {
warnings.push(`Ignored deprecated org unit row "${levelFiveName}" in EID-Attr row ${rowNumber}`);
continue;
}
orgUnits.push({
sourceRow: rowNumber,
level: 5,
name: levelFiveName,
parentName: null,
sortOrder: orgUnits.filter((entry) => entry.level === 5).length + 1,
});
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((levelSixName, index) => {
orgUnits.push({
sourceRow: rowNumber,
level: 6,
name: levelSixName,
parentName: levelFiveName,
sortOrder: index + 1,
});
});
}
for (let rowNumber = levelSixHeaderRow + 1; rowNumber < managementLevelRow; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const levelSixName = normalizeNullableWorkbookValue(row[0]);
if (!levelSixName) {
continue;
}
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((levelSevenName, index) => {
orgUnits.push({
sourceRow: rowNumber,
level: 7,
name: levelSevenName,
parentName: levelSixName,
sortOrder: index + 1,
});
});
}
return { orgUnits, warnings };
}
function parseManagementLevelReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { managementLevelGroups: ParsedManagementLevelGroupReference[]; warnings: string[] } {
const warnings: string[] = [];
const managementLevelGroups: ParsedManagementLevelGroupReference[] = [];
const startRow = findSectionRow(rows, "Management Level Group") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const groupName = normalizeNullableWorkbookValue(row[0]);
if (!groupName) {
continue;
}
if (isTerminalSectionName(groupName, ["FTE"])) {
break;
}
const targetPercentage = typeof row[1] === "number" ? row[1] : null;
if (targetPercentage === null) {
warnings.push(`Missing target percentage for management level group "${groupName}" in row ${rowNumber}`);
continue;
}
const levels = row
.slice(2)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value));
managementLevelGroups.push({
sourceRow: rowNumber,
name: groupName,
targetPercentage,
sortOrder: managementLevelGroups.length + 1,
levels,
});
}
return { managementLevelGroups, warnings };
}
function parseClientReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { clients: ParsedClientReference[]; warnings: string[] } {
const warnings: string[] = [];
const clients: ParsedClientReference[] = [];
const startRow = findSectionRow(rows, "WBS Master Client") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const masterClientName = normalizeNullableWorkbookValue(row[0]);
if (!masterClientName) {
continue;
}
const normalizedMasterName = sanitizeClientName(masterClientName);
const masterClientCode = normalizeClientCode(normalizedMasterName);
clients.push({
sourceRow: rowNumber,
sourceColumn: "A",
clientCode: masterClientCode,
name: normalizedMasterName,
parentClientCode: null,
parentName: null,
sortOrder: clients.filter((entry) => entry.parentName === null).length + 1,
});
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((childName, index) => {
clients.push({
sourceRow: rowNumber,
sourceColumn: toColumnLetter(index + 2),
clientCode: null,
name: sanitizeClientName(childName),
parentClientCode: masterClientCode,
parentName: normalizedMasterName,
sortOrder: index + 1,
});
});
}
return { clients, warnings };
}
export async function parseMandatoryDispoReferenceWorkbook(
workbookPath: string,
): Promise<ParsedReferenceWorkbook> {
const eidAttrRows = await readWorksheetMatrix(workbookPath, DISPO_REFERENCE_SHEET);
const projectAttrRows = await readWorksheetMatrix(workbookPath, DISPO_PROJECT_REFERENCE_SHEET);
const countryResult = parseCountryReferences(eidAttrRows);
const orgUnitResult = parseOrgUnitReferences(eidAttrRows);
const managementLevelResult = parseManagementLevelReferences(eidAttrRows);
const clientResult = parseClientReferences(projectAttrRows);
return {
countries: countryResult.countries,
orgUnits: orgUnitResult.orgUnits,
managementLevelGroups: managementLevelResult.managementLevelGroups,
clients: clientResult.clients,
warnings: [
...countryResult.warnings,
...orgUnitResult.warnings,
...managementLevelResult.warnings,
...clientResult.warnings,
],
};
}
@@ -0,0 +1,178 @@
import { normalizeCanonicalResourceIdentity } from "@planarchy/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<string, ParsedResourceRosterLevelAverage>;
rates: Map<string, ParsedResourceRosterRate>;
warnings: string[];
}
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
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<ParsedResourceRosterMasterWorkbook> {
const rows = await readWorksheetMatrix(workbookPath, RESOURCE_ROSTER_MASTER_SHEET);
const headerMap = buildHeaderMap(rows[0] ?? []);
const warnings: string[] = [];
const rates = new Map<string, ParsedResourceRosterRate>();
const levelBuckets = new Map<string, { lcr: number[]; ucr: number[] }>();
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<string, ParsedResourceRosterLevelAverage>();
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,
};
}
@@ -0,0 +1,72 @@
import * as XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
function normalizeWorksheetCellValue(value: unknown): WorksheetCellValue {
if (value === undefined || value === null) {
return null;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (value instanceof Date) {
return value;
}
return String(value);
}
export async function readWorksheetMatrix(
workbookPath: string,
sheetName: string,
): Promise<WorksheetMatrix> {
const workbook = XLSX.readFile(workbookPath, {
cellDates: true,
dense: true,
});
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
throw new Error(`Worksheet "${sheetName}" not found in workbook "${workbookPath}"`);
}
const rows = XLSX.utils.sheet_to_json<(WorksheetCellValue | null)[]>(worksheet, {
header: 1,
raw: true,
defval: null,
});
return rows.map((row) => row.map((value) => normalizeWorksheetCellValue(value)));
}
export function getCellString(
rows: WorksheetMatrix,
rowNumber: number,
columnNumber: number,
): string | null {
const value = rows[rowNumber - 1]?.[columnNumber - 1];
if (value === null || value === undefined) {
return null;
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
}
export function toColumnLetter(columnNumber: number): string {
let current = columnNumber;
let result = "";
while (current > 0) {
const remainder = (current - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
current = Math.floor((current - 1) / 26);
}
return result;
}
@@ -0,0 +1,639 @@
import path from "node:path";
import type { Prisma, PrismaClient } from "@planarchy/db";
import {
DispoImportSourceKind,
DispoStagedRecordType,
ImportBatchStatus,
ResourceType,
StagedRecordStatus,
} from "@planarchy/db";
import {
createWeekdayAvailabilityFromFte,
normalizeCanonicalResourceIdentity,
normalizeDispoChapterToken,
} from "@planarchy/shared";
export type DispoImportDbClient = Pick<
PrismaClient,
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedClient"
| "stagedProject"
| "stagedResource"
| "stagedVacation"
| "stagedUnresolvedRecord"
>;
export interface DispoReferenceImportInput {
importBatchId?: string;
notes?: string | null;
referenceWorkbookPath: string;
}
export interface DispoChargeabilityImportInput {
chargeabilityWorkbookPath: string;
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
}
export interface DispoRosterImportInput {
costWorkbookPath?: string;
importBatchId?: string;
notes?: string | null;
rosterWorkbookPath: string;
}
export interface DispoPlanningImportInput {
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
planningWorkbookPath: string;
}
export interface ParsedCountryReference {
sourceRow: number;
countryCode: string;
name: string;
dailyWorkingHours: number;
metroCities: string[];
scheduleRules?: Prisma.InputJsonValue;
}
export interface ParsedOrgUnitReference {
sourceRow: number;
level: number;
name: string;
parentName: string | null;
sortOrder: number;
}
export interface ParsedManagementLevelGroupReference {
sourceRow: number;
name: string;
targetPercentage: number;
sortOrder: number;
levels: string[];
}
export interface ParsedClientReference {
sourceColumn: string;
sourceRow: number;
clientCode: string | null;
name: string;
parentClientCode: string | null;
parentName: string | null;
sortOrder: number;
}
export interface ParsedReferenceWorkbook {
clients: ParsedClientReference[];
countries: ParsedCountryReference[];
managementLevelGroups: ParsedManagementLevelGroupReference[];
orgUnits: ParsedOrgUnitReference[];
warnings: string[];
}
export interface ParsedChargeabilityResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
fte: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceType: ResourceType;
roleTokens: string[];
sourceRow: number;
warnings: string[];
}
export interface ParsedUnresolvedRecord {
message: string;
normalizedData: Record<string, unknown>;
projectKey?: string | null;
recordType: DispoStagedRecordType;
resolutionHint?: string | null;
resourceExternalId?: string | null;
sourceColumn?: string | null;
sourceRow: number;
warnings: string[];
}
export interface ParsedChargeabilityWorkbook {
resources: ParsedChargeabilityResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedRosterResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
clientUnitName: string | null;
countryCode: string | null;
dailyWorkingHoursPerFte: number | null;
department: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
firstDayInDispo: Date | null;
fte: number | null;
lastDayInDispo: Date | null;
lcrCents: number | null;
mainSkillset: string | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceHoursPerWeek: number | null;
resourceType: ResourceType;
rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING";
rateResolutionLevel: string | null;
roleTokens: string[];
sapEmployeeName: string | null;
sapOrgUnitLevelFive: string | null;
sapOrgUnitLevelSix: string | null;
sapOrgUnitLevelSeven: string | null;
sourceRow: number;
sourceSheet: string;
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
export interface ParsedRosterWorkbook {
excludedCanonicalExternalIds: string[];
ignoredPseudoDemandRows: number;
resources: ParsedRosterResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedPlanningAssignment {
assignmentDate: Date;
chapterToken: string | null;
hoursPerDay: number;
isInternal: boolean;
isTbd: boolean;
isUnassigned: boolean;
percentage: number;
projectKey: string | null;
rawToken: string;
resourceExternalId: string;
roleName: string | null;
roleToken: string | null;
slotFraction: number;
sourceColumn: string;
sourceRow: number;
utilizationCategoryCode: string | null;
warnings: string[];
winProbability: number | null;
}
export interface ParsedPlanningVacation {
endDate: Date;
halfDayPart: string | null;
holidayName: string | null;
isHalfDay: boolean;
isPublicHoliday: boolean;
note: string | null;
rawToken: string;
resourceExternalId: string;
sourceColumn: string;
sourceRow: number;
startDate: Date;
vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK";
warnings: string[];
}
export interface ParsedPlanningAvailabilityRule {
availableHours: number | null;
effectiveEndDate: Date | null;
effectiveStartDate: Date | null;
isResolved: boolean;
percentage: number | null;
rawToken: string;
resourceExternalId: string;
ruleType: string;
sourceColumn: string;
sourceRow: number;
warnings: string[];
}
export interface ParsedPlanningWorkbook {
assignments: ParsedPlanningAssignment[];
availabilityRules: ParsedPlanningAvailabilityRule[];
unresolved: ParsedUnresolvedRecord[];
vacations: ParsedPlanningVacation[];
warnings: string[];
}
const COUNTRY_REFERENCE_CONFIG = {
"Costa Rica": {
code: "CR",
dailyWorkingHours: 8,
},
Germany: {
code: "DE",
dailyWorkingHours: 8,
},
Hungary: {
code: "HU",
dailyWorkingHours: 8,
},
India: {
code: "IN",
dailyWorkingHours: 9,
},
Italy: {
code: "IT",
dailyWorkingHours: 8,
},
Portugal: {
code: "PT",
dailyWorkingHours: 8,
},
Spain: {
code: "ES",
dailyWorkingHours: 8,
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
},
"United Kingdom": {
code: "GB",
dailyWorkingHours: 8,
},
} as const satisfies Record<
string,
{
code: string;
dailyWorkingHours: number;
scheduleRules?: Prisma.InputJsonValue;
}
>;
const CLIENT_CODE_OVERRIDES = {
BMW: "BMW",
DAIMLER: "DAIMLER",
"EXOR-STELLANTIS": "STELLANTIS",
VOLKSWAGEN: "VW",
"TATA MOTORS GROUP": "JLR",
} as const satisfies Record<string, string>;
const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]);
function collapseWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function normalizeText(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = collapseWhitespace(String(value));
return normalized.length > 0 ? normalized : null;
}
export function normalizeNullableWorkbookValue(value: unknown): string | null {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
return NULLISH_TOKENS.has(normalized) ? null : normalized;
}
export function buildFallbackAccentureEmail(canonicalExternalId: string): string {
return `${canonicalExternalId}@accenture.com`;
}
export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean {
return typeof value === "string" && value.toLowerCase().startsWith("demand_");
}
export function sanitizeClientName(value: string): string {
return collapseWhitespace(value.replace(/\s*-\s*$/, ""));
}
export function getWorkbookFileName(workbookPath: string): string {
return path.basename(workbookPath);
}
export function findSectionRow(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
firstCellValue: string,
): number {
const normalizedTarget = firstCellValue.toLowerCase();
for (let index = 0; index < rows.length; index += 1) {
const current = normalizeText(rows[index]?.[0]);
if (current?.toLowerCase() === normalizedTarget) {
return index + 1;
}
}
throw new Error(`Section row "${firstCellValue}" not found`);
}
export function getCountryReferenceConfig(countryName: string) {
return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null;
}
export function deriveCountryCodeFromMetroCity(
metroCityName: string | null | undefined,
): string | null {
if (!metroCityName) {
return null;
}
for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) {
if (metroCityName === countryName) {
return config.code;
}
const isGermanCity =
countryName === "Germany" &&
["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName);
const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon";
const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham";
const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica";
if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) {
return config.code;
}
}
return null;
}
export function normalizeClientCode(masterClientName: string): string | null {
return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null;
}
export function ensurePercentageValue(value: number | null): number | null {
if (value === null) {
return null;
}
return value <= 1 ? Math.round(value * 10000) / 100 : value;
}
export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string {
return enterpriseId
.split(".")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function deriveRoleTokens(...values: Array<string | null | undefined>): string[] {
const tokenSet = new Set<string>();
const combinedValue = values
.map((value) => normalizeText(value))
.filter((value): value is string => Boolean(value))
.join(" ")
.toUpperCase();
if (combinedValue.includes("2D")) {
tokenSet.add("2D");
}
if (combinedValue.includes("3D")) {
tokenSet.add("3D");
}
if (combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT")) {
tokenSet.add("PM");
}
if (combinedValue.includes("ART DIRECTION")) {
tokenSet.add("AD");
}
return Array.from(tokenSet);
}
export function deriveNormalizedChapter(
rawChapter: string | null,
roleTokens: string[],
): { chapter: string | null; chapterCode: string | null } {
const firstRoleToken = roleTokens[0] ?? null;
if (firstRoleToken) {
const normalizedChapter = normalizeDispoChapterToken(firstRoleToken);
if (normalizedChapter) {
return {
chapter: normalizedChapter,
chapterCode: firstRoleToken,
};
}
}
return {
chapter: rawChapter,
chapterCode: null,
};
}
export function mapChargeabilityResourceType(rawValue: string | null): {
resourceType: ResourceType;
warning: string | null;
} {
if (!rawValue) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
const normalizedValue = rawValue.toLowerCase();
if (normalizedValue.includes("freelancer")) {
return { resourceType: ResourceType.FREELANCER, warning: null };
}
if (normalizedValue.includes("intern")) {
return { resourceType: ResourceType.INTERN, warning: null };
}
if (normalizedValue.includes("student")) {
return { resourceType: ResourceType.STUDENT, warning: null };
}
if (normalizedValue.includes("apprentice")) {
return { resourceType: ResourceType.APPRENTICE, warning: null };
}
if (
normalizedValue === "production studios" ||
normalizedValue === "near&offshore" ||
normalizedValue === "accenture" ||
normalizedValue === "long-term absence"
) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
return {
resourceType: ResourceType.EMPLOYEE,
warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`,
};
}
export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue {
return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue;
}
export function buildBatchSummaryEntry(summary: Record<string, unknown>): Prisma.InputJsonValue {
return summary as Prisma.InputJsonValue;
}
export async function ensureImportBatch(
db: Pick<PrismaClient, "importBatch">,
input: {
chargeabilitySourceFile?: string;
importBatchId?: string;
notes?: string | null;
planningSourceFile?: string;
referenceSourceFile?: string;
},
): Promise<{ id: string; summary: Record<string, unknown> }> {
if (input.importBatchId) {
const existing = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, summary: true },
});
if (!existing) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
const updated = await db.importBatch.update({
where: { id: input.importBatchId },
data: {
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: updated.id,
summary: toJsonObject(updated.summary),
};
}
const created = await db.importBatch.create({
data: {
sourceSystem: "DISPO_V2",
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: created.id,
summary: toJsonObject(created.summary),
};
}
export async function finalizeImportBatchStage(
db: Pick<PrismaClient, "importBatch">,
input: {
batchId: string;
existingSummary: Record<string, unknown>;
key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster";
summary: Record<string, unknown>;
},
) {
const nextSummary = {
...input.existingSummary,
[input.key]: input.summary,
};
await db.importBatch.update({
where: { id: input.batchId },
data: {
status: ImportBatchStatus.STAGED,
stagedAt: new Date(),
summary: buildBatchSummaryEntry(nextSummary),
},
});
}
export function toJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null {
return value ? normalizeCanonicalResourceIdentity(value) : null;
}
export function createSourceTrace(
sourceKind: DispoImportSourceKind,
sourceWorkbook: string,
sourceSheet: string,
sourceRow: number,
sourceColumn?: string | null,
) {
return {
sourceKind,
sourceWorkbook,
sourceSheet,
sourceRow,
...(sourceColumn !== undefined ? { sourceColumn } : {}),
};
}
export const DISPO_REFERENCE_SHEET = "EID-Attr";
export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr";
export const DISPO_CHARGEABILITY_SHEET = "ChgFC";
export const DISPO_PLANNING_SHEET = "Dispo";
export const DISPO_ROSTER_SHEET = "DispoRoster";
export const DISPO_ROSTER_SAP_SHEET = "SAP_data";
export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };
@@ -0,0 +1,148 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
import { ensureImportBatch, finalizeImportBatchStage, getWorkbookFileName, type DispoChargeabilityImportInput, type DispoImportDbClient } from "./shared.js";
export interface StageDispoChargeabilityResourcesResult {
batchId: string;
counts: {
stagedResources: number;
unresolved: number;
warnings: number;
};
}
export async function stageDispoChargeabilityResources(
db: DispoImportDbClient,
input: DispoChargeabilityImportInput,
): Promise<StageDispoChargeabilityResourcesResult> {
const batchInput: {
chargeabilitySourceFile: string;
importBatchId?: string;
notes?: string | null;
} = {
chargeabilitySourceFile: getWorkbookFileName(input.chargeabilityWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const filteredResources = parsed.resources.filter(
(resource) => !excludedIds.has(resource.canonicalExternalId),
);
const filteredUnresolved = parsed.unresolved.filter(
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
);
const sourceWorkbook = getWorkbookFileName(input.chargeabilityWorkbookPath);
await db.stagedResource.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
},
});
if (filteredResources.length > 0) {
await db.stagedResource.createMany({
data: filteredResources.map((resource) => ({
importBatchId: batch.id,
status: resource.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
sourceWorkbook,
sourceSheet: "ChgFC",
sourceRow: resource.sourceRow,
canonicalExternalId: resource.canonicalExternalId,
enterpriseId: resource.enterpriseId,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
chapterCode: resource.chapterCode,
managementLevelGroupName: resource.managementLevelGroupName,
managementLevelName: resource.managementLevelName,
countryCode: resource.countryCode,
metroCityName: resource.metroCityName,
clientUnitName: resource.clientUnitName,
resourceType: resource.resourceType,
chargeabilityTarget: resource.chargeabilityTarget,
fte: resource.fte,
availability: resource.availability,
roleTokens: resource.roleTokens,
warnings: resource.warnings,
rawPayload: {
rawResourceType: resource.rawResourceType,
} as Prisma.InputJsonValue,
normalizedData: {
chapter: resource.chapter,
chapterCode: resource.chapterCode,
chargeabilityTarget: resource.chargeabilityTarget,
clientUnitName: resource.clientUnitName,
countryCode: resource.countryCode,
fte: resource.fte,
managementLevelGroupName: resource.managementLevelGroupName,
metroCityName: resource.metroCityName,
roleTokens: resource.roleTokens,
} as Prisma.InputJsonValue,
})),
});
}
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
},
});
if (filteredUnresolved.length > 0) {
await db.stagedUnresolvedRecord.createMany({
data: filteredUnresolved.map((record) => ({
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
sourceWorkbook,
sourceSheet: "ChgFC",
sourceRow: record.sourceRow,
sourceColumn: record.sourceColumn ?? null,
recordType: record.recordType,
resourceExternalId: record.resourceExternalId ?? null,
projectKey: record.projectKey ?? null,
message: record.message,
resolutionHint: record.resolutionHint ?? null,
warnings: record.warnings,
rawPayload: {} as Prisma.InputJsonValue,
normalizedData: record.normalizedData as Prisma.InputJsonValue,
})),
});
}
const warningCount =
parsed.warnings.length +
filteredResources.reduce((count, resource) => count + resource.warnings.length, 0);
const summary = {
stagedResources: filteredResources.length,
unresolved: filteredUnresolved.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "chargeability",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}
@@ -0,0 +1,106 @@
import {
assessDispoImportReadiness,
persistDispoImportReadiness,
type DispoImportReadinessReport,
} from "./assess-import-readiness.js";
import { type DispoImportDbClient } from "./shared.js";
import { stageDispoChargeabilityResources } from "./stage-chargeability-resources.js";
import { stageDispoPlanningData } from "./stage-dispo-planning.js";
import { stageDispoProjects } from "./stage-dispo-projects.js";
import { stageDispoRosterResources } from "./stage-dispo-roster-resources.js";
import { stageDispoReferenceData } from "./stage-reference-data.js";
export interface StageDispoImportBatchInput {
chargeabilityWorkbookPath: string;
costWorkbookPath?: string;
notes?: string | null;
planningWorkbookPath: string;
referenceWorkbookPath: string;
rosterWorkbookPath?: string;
}
export interface StageDispoImportBatchResult {
batchId: string;
counts: {
stagedAssignments: number;
stagedAvailabilityRules: number;
stagedClients: number;
stagedProjects: number;
stagedResources: number;
stagedRosterResources: number;
stagedVacations: number;
unresolved: number;
};
readiness: DispoImportReadinessReport;
}
export async function stageDispoImportBatch(
db: DispoImportDbClient,
input: StageDispoImportBatchInput,
): Promise<StageDispoImportBatchResult> {
const referenceResult = await stageDispoReferenceData(db, {
referenceWorkbookPath: input.referenceWorkbookPath,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const batchId = referenceResult.batchId;
const rosterResult = input.rosterWorkbookPath
? await stageDispoRosterResources(db, {
importBatchId: batchId,
rosterWorkbookPath: input.rosterWorkbookPath,
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
})
: { counts: { stagedResources: 0, unresolved: 0, warnings: 0, ignoredPseudoDemandRows: 0, excludedResources: 0 }, excludedCanonicalExternalIds: [] };
const chargeabilityResult = await stageDispoChargeabilityResources(db, {
importBatchId: batchId,
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const planningResult = await stageDispoPlanningData(db, {
importBatchId: batchId,
planningWorkbookPath: input.planningWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const projectResult = await stageDispoProjects(db, {
importBatchId: batchId,
planningWorkbookPath: input.planningWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const readiness = await persistDispoImportReadiness(db, {
importBatchId: batchId,
referenceWorkbookPath: input.referenceWorkbookPath,
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
planningWorkbookPath: input.planningWorkbookPath,
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.rosterWorkbookPath ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
return {
batchId,
counts: {
stagedClients: referenceResult.counts.stagedClients,
stagedResources:
chargeabilityResult.counts.stagedResources + (rosterResult?.counts.stagedResources ?? 0),
stagedRosterResources: rosterResult?.counts.stagedResources ?? 0,
stagedProjects: projectResult.counts.stagedProjects,
stagedAssignments: planningResult.counts.stagedAssignments,
stagedVacations: planningResult.counts.stagedVacations,
stagedAvailabilityRules: planningResult.counts.stagedAvailabilityRules,
unresolved:
chargeabilityResult.counts.unresolved +
planningResult.counts.unresolved +
(rosterResult?.counts.unresolved ?? 0),
},
readiness,
};
}
@@ -0,0 +1,254 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
finalizeImportBatchStage,
getWorkbookFileName,
isPseudoDemandResourceIdentity,
type DispoImportDbClient,
type DispoPlanningImportInput,
} from "./shared.js";
export interface StageDispoPlanningResult {
batchId: string;
counts: {
stagedAssignments: number;
stagedAvailabilityRules: number;
stagedVacations: number;
unresolved: number;
warnings: number;
};
}
export async function stageDispoPlanningData(
db: DispoImportDbClient,
input: DispoPlanningImportInput,
): Promise<StageDispoPlanningResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
planningSourceFile: string;
} = {
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const filteredAssignments = parsed.assignments.filter(
(assignment) =>
!excludedIds.has(assignment.resourceExternalId) &&
!isPseudoDemandResourceIdentity(assignment.resourceExternalId),
);
const filteredVacations = parsed.vacations.filter(
(vacation) =>
!excludedIds.has(vacation.resourceExternalId) &&
!isPseudoDemandResourceIdentity(vacation.resourceExternalId),
);
const filteredAvailabilityRules = parsed.availabilityRules.filter(
(rule) =>
!excludedIds.has(rule.resourceExternalId) &&
!isPseudoDemandResourceIdentity(rule.resourceExternalId),
);
const filteredUnresolved = parsed.unresolved.filter(
(record) =>
!record.resourceExternalId ||
(!excludedIds.has(record.resourceExternalId) &&
!isPseudoDemandResourceIdentity(record.resourceExternalId)),
);
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
await db.stagedAssignment.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedVacation.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedAvailabilityRule.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
if (filteredAssignments.length > 0) {
await db.stagedAssignment.createMany({
data: filteredAssignments.map((assignment) => ({
importBatchId: batch.id,
status: assignment.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: assignment.sourceRow,
sourceColumn: assignment.sourceColumn,
resourceExternalId: assignment.resourceExternalId,
projectKey: assignment.projectKey,
assignmentDate: assignment.assignmentDate,
startDate: assignment.assignmentDate,
endDate: assignment.assignmentDate,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
slotFraction: assignment.slotFraction,
roleToken: assignment.roleToken,
roleName: assignment.roleName,
chapterToken: assignment.chapterToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
winProbability: assignment.winProbability,
isInternal: assignment.isInternal,
isUnassigned: assignment.isUnassigned,
isTbd: assignment.isTbd,
warnings: assignment.warnings,
rawPayload: {
rawToken: assignment.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
assignmentDate: assignment.assignmentDate.toISOString().slice(0, 10),
chapterToken: assignment.chapterToken,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
roleToken: assignment.roleToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
winProbability: assignment.winProbability,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredVacations.length > 0) {
await db.stagedVacation.createMany({
data: filteredVacations.map((vacation) => ({
importBatchId: batch.id,
status: vacation.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: vacation.sourceRow,
sourceColumn: vacation.sourceColumn,
resourceExternalId: vacation.resourceExternalId,
vacationType: vacation.vacationType,
startDate: vacation.startDate,
endDate: vacation.endDate,
note: vacation.note,
holidayName: vacation.holidayName,
isHalfDay: vacation.isHalfDay,
halfDayPart: vacation.halfDayPart,
isPublicHoliday: vacation.isPublicHoliday,
warnings: vacation.warnings,
rawPayload: {
rawToken: vacation.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
holidayName: vacation.holidayName,
isHalfDay: vacation.isHalfDay,
isPublicHoliday: vacation.isPublicHoliday,
note: vacation.note,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredAvailabilityRules.length > 0) {
await db.stagedAvailabilityRule.createMany({
data: filteredAvailabilityRules.map((rule) => ({
importBatchId: batch.id,
status: rule.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: rule.sourceRow,
sourceColumn: rule.sourceColumn,
resourceExternalId: rule.resourceExternalId,
ruleType: rule.ruleType,
weekday: null,
effectiveStartDate: rule.effectiveStartDate,
effectiveEndDate: rule.effectiveEndDate,
availableHours: rule.availableHours,
percentage: rule.percentage,
isResolved: rule.isResolved,
warnings: rule.warnings,
rawPayload: {
rawToken: rule.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
availableHours: rule.availableHours,
percentage: rule.percentage,
ruleType: rule.ruleType,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredUnresolved.length > 0) {
await db.stagedUnresolvedRecord.createMany({
data: filteredUnresolved.map((record) => ({
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: record.sourceRow,
sourceColumn: record.sourceColumn ?? null,
recordType: record.recordType,
resourceExternalId: record.resourceExternalId ?? null,
projectKey: record.projectKey ?? null,
message: record.message,
resolutionHint: record.resolutionHint ?? null,
warnings: record.warnings,
rawPayload: {} as Prisma.InputJsonValue,
normalizedData: record.normalizedData as Prisma.InputJsonValue,
})),
});
}
const warningCount =
parsed.warnings.length +
filteredAssignments.reduce((count, assignment) => count + assignment.warnings.length, 0) +
filteredVacations.reduce((count, vacation) => count + vacation.warnings.length, 0) +
filteredAvailabilityRules.reduce((count, rule) => count + rule.warnings.length, 0);
const summary = {
stagedAssignments: filteredAssignments.length,
stagedAvailabilityRules: filteredAvailabilityRules.length,
stagedVacations: filteredVacations.length,
unresolved: filteredUnresolved.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "planning",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}
@@ -0,0 +1,313 @@
import type { Prisma } from "@planarchy/db";
import { AllocationType, DispoImportSourceKind, OrderType, StagedRecordStatus } from "@planarchy/db";
import { DISPO_INTERNAL_PROJECT_BUCKETS } from "@planarchy/shared";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
finalizeImportBatchStage,
getWorkbookFileName,
isPseudoDemandResourceIdentity,
type DispoImportDbClient,
type DispoPlanningImportInput,
normalizeText,
} from "./shared.js";
interface ResolvedStagedProject {
allocationType: AllocationType;
clientCode: string | null;
isInternal: boolean;
isTbd: boolean;
name: string;
orderType: OrderType;
projectKey: string;
rawTokens: Set<string>;
shortCode: string;
sourceColumn: string;
sourceRow: number;
startDate: Date;
endDate: Date;
utilizationCategoryCode: string | null;
warnings: Set<string>;
winProbability: number | null;
}
function extractBracketTokens(token: string): string[] {
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function deriveProjectName(token: string, fallbackProjectKey: string): string {
const normalized = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return normalized.length > 0 ? normalized : `Project ${fallbackProjectKey}`;
}
function updateDateRange(project: ResolvedStagedProject, assignmentDate: Date) {
if (assignmentDate < project.startDate) {
project.startDate = assignmentDate;
}
if (assignmentDate > project.endDate) {
project.endDate = assignmentDate;
}
}
export interface StageDispoProjectsResult {
batchId: string;
counts: {
stagedProjects: number;
warnings: number;
};
}
export async function stageDispoProjects(
db: DispoImportDbClient,
input: DispoPlanningImportInput,
): Promise<StageDispoProjectsResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
planningSourceFile: string;
} = {
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
const projects = new Map<string, ResolvedStagedProject>();
for (const bucket of DISPO_INTERNAL_PROJECT_BUCKETS) {
projects.set(bucket.shortCode, {
allocationType: AllocationType.INT,
clientCode: null,
isInternal: true,
isTbd: false,
name: bucket.name,
orderType: OrderType.INTERNAL,
projectKey: bucket.shortCode,
rawTokens: new Set<string>([`{${bucket.sourceToken}}`]),
shortCode: bucket.shortCode,
sourceColumn: "A",
sourceRow: 0,
startDate: new Date("2100-01-01T00:00:00.000Z"),
endDate: new Date("1970-01-01T00:00:00.000Z"),
utilizationCategoryCode: bucket.utilizationCategoryCode,
warnings: new Set<string>(),
winProbability: 100,
});
}
for (const assignment of parsed.assignments) {
if (
excludedIds.has(assignment.resourceExternalId) ||
isPseudoDemandResourceIdentity(assignment.resourceExternalId)
) {
continue;
}
if (assignment.isTbd || assignment.isUnassigned) {
continue;
}
if (assignment.isInternal) {
const internalBucket = DISPO_INTERNAL_PROJECT_BUCKETS.find(
(bucket) => assignment.rawToken.includes(`{${bucket.sourceToken}}`),
);
if (!internalBucket) {
continue;
}
const project = projects.get(internalBucket.shortCode);
if (!project) {
continue;
}
updateDateRange(project, assignment.assignmentDate);
project.rawTokens.add(assignment.rawToken);
continue;
}
if (!assignment.projectKey) {
continue;
}
const derivedClientCode = extractClientCode(assignment.rawToken);
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
const shortCode = assignment.projectKey;
const existing = projects.get(assignment.projectKey);
if (!existing) {
projects.set(assignment.projectKey, {
allocationType: assignment.utilizationCategoryCode === "Chg"
? AllocationType.EXT
: AllocationType.INT,
clientCode: derivedClientCode,
isInternal: false,
isTbd: false,
name: derivedName,
orderType: assignment.utilizationCategoryCode === "Chg"
? OrderType.CHARGEABLE
: assignment.utilizationCategoryCode === "BD"
? OrderType.BD
: OrderType.INTERNAL,
projectKey: assignment.projectKey,
rawTokens: new Set<string>([assignment.rawToken]),
shortCode,
sourceColumn: assignment.sourceColumn,
sourceRow: assignment.sourceRow,
startDate: assignment.assignmentDate,
endDate: assignment.assignmentDate,
utilizationCategoryCode: assignment.utilizationCategoryCode,
warnings: new Set<string>(assignment.warnings),
winProbability: assignment.winProbability,
});
continue;
}
updateDateRange(existing, assignment.assignmentDate);
existing.rawTokens.add(assignment.rawToken);
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
existing.warnings.add(
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
);
}
if (!existing.clientCode && derivedClientCode) {
existing.clientCode = derivedClientCode;
}
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
existing.warnings.add(
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
);
}
if (
existing.utilizationCategoryCode &&
assignment.utilizationCategoryCode &&
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
) {
existing.warnings.add(
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
);
}
if (!existing.utilizationCategoryCode && assignment.utilizationCategoryCode) {
existing.utilizationCategoryCode = assignment.utilizationCategoryCode;
}
if (
existing.winProbability !== null &&
assignment.winProbability !== null &&
existing.winProbability !== assignment.winProbability
) {
existing.warnings.add(
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
);
}
if (existing.winProbability === null && assignment.winProbability !== null) {
existing.winProbability = assignment.winProbability;
}
}
await db.stagedProject.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
const stagedProjects = Array.from(projects.values())
.filter((project) => {
if (!project.isInternal) {
return true;
}
return project.startDate <= project.endDate;
})
.map((project) => ({
importBatchId: batch.id,
status: project.warnings.size > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: project.sourceRow,
sourceColumn: project.sourceColumn,
projectKey: project.projectKey,
shortCode: project.shortCode,
name: project.name,
clientCode: project.clientCode,
utilizationCategoryCode: project.utilizationCategoryCode,
orderType: project.orderType,
allocationType: project.allocationType,
winProbability: project.winProbability,
isInternal: project.isInternal,
isTbd: project.isTbd,
startDate: project.startDate,
endDate: project.endDate,
warnings: Array.from(project.warnings),
rawPayload: {
rawTokens: Array.from(project.rawTokens),
} as Prisma.InputJsonValue,
normalizedData: {
clientCode: project.clientCode,
name: project.name,
utilizationCategoryCode: project.utilizationCategoryCode,
winProbability: project.winProbability,
} as Prisma.InputJsonValue,
}));
if (stagedProjects.length > 0) {
await db.stagedProject.createMany({
data: stagedProjects,
});
}
const warningCount = stagedProjects.reduce((count, project) => count + project.warnings.length, 0);
const summary = {
stagedProjects: stagedProjects.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "projectResolution",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}
@@ -0,0 +1,172 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
import {
ensureImportBatch,
finalizeImportBatchStage,
getWorkbookFileName,
type DispoImportDbClient,
type DispoRosterImportInput,
} from "./shared.js";
export interface StageDispoRosterResourcesResult {
batchId: string;
counts: {
excludedResources: number;
ignoredPseudoDemandRows: number;
stagedResources: number;
unresolved: number;
warnings: number;
};
excludedCanonicalExternalIds: string[];
}
export async function stageDispoRosterResources(
db: DispoImportDbClient,
input: DispoRosterImportInput,
): Promise<StageDispoRosterResourcesResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
} = {};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoRosterWorkbook(input.rosterWorkbookPath, {
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
});
const sourceWorkbook = getWorkbookFileName(input.rosterWorkbookPath);
await db.stagedResource.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.ROSTER,
},
});
if (parsed.resources.length > 0) {
await db.stagedResource.createMany({
data: parsed.resources.map((resource) => ({
importBatchId: batch.id,
status: resource.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.ROSTER,
sourceWorkbook,
sourceSheet: resource.sourceSheet,
sourceRow: resource.sourceRow,
canonicalExternalId: resource.canonicalExternalId,
enterpriseId: resource.enterpriseId,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
chapterCode: resource.chapterCode,
managementLevelGroupName: resource.managementLevelGroupName,
managementLevelName: resource.managementLevelName,
countryCode: resource.countryCode,
metroCityName: resource.metroCityName,
clientUnitName: resource.clientUnitName,
resourceType: resource.resourceType,
chargeabilityTarget: null,
fte: resource.fte,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
availability: resource.availability,
roleTokens: resource.roleTokens,
warnings: resource.warnings,
rawPayload: {
dailyWorkingHoursPerFte: resource.dailyWorkingHoursPerFte,
department: resource.department,
firstDayInDispo: resource.firstDayInDispo?.toISOString() ?? null,
lastDayInDispo: resource.lastDayInDispo?.toISOString() ?? null,
mainSkillset: resource.mainSkillset,
rawResourceType: resource.rawResourceType,
resourceHoursPerWeek: resource.resourceHoursPerWeek,
rateResolution: resource.rateResolution,
rateResolutionLevel: resource.rateResolutionLevel,
sapEmployeeName: resource.sapEmployeeName,
sapOrgUnitLevelFive: resource.sapOrgUnitLevelFive,
sapOrgUnitLevelSix: resource.sapOrgUnitLevelSix,
sapOrgUnitLevelSeven: resource.sapOrgUnitLevelSeven,
vacationDaysPerYear: resource.vacationDaysPerYear,
} as Prisma.InputJsonValue,
normalizedData: {
chapter: resource.chapter,
chapterCode: resource.chapterCode,
clientUnitName: resource.clientUnitName,
countryCode: resource.countryCode,
department: resource.department,
email: resource.email,
fte: resource.fte,
managementLevelGroupName: resource.managementLevelGroupName,
managementLevelName: resource.managementLevelName,
metroCityName: resource.metroCityName,
roleTokens: resource.roleTokens,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
rateResolution: resource.rateResolution,
rateResolutionLevel: resource.rateResolutionLevel,
} as Prisma.InputJsonValue,
})),
});
}
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.ROSTER,
},
});
if (parsed.unresolved.length > 0) {
await db.stagedUnresolvedRecord.createMany({
data: parsed.unresolved.map((record) => ({
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
sourceKind: DispoImportSourceKind.ROSTER,
sourceWorkbook,
sourceSheet: record.sourceRow >= 3 && record.sourceColumn === "C" ? "SAP_data" : "DispoRoster",
sourceRow: record.sourceRow,
sourceColumn: record.sourceColumn ?? null,
recordType: record.recordType,
resourceExternalId: record.resourceExternalId ?? null,
projectKey: record.projectKey ?? null,
message: record.message,
resolutionHint: record.resolutionHint ?? null,
warnings: record.warnings,
rawPayload: {} as Prisma.InputJsonValue,
normalizedData: record.normalizedData as Prisma.InputJsonValue,
})),
});
}
const warningCount =
parsed.warnings.length +
parsed.resources.reduce((count, resource) => count + resource.warnings.length, 0);
const summary = {
stagedResources: parsed.resources.length,
unresolved: parsed.unresolved.length,
warnings: warningCount,
excludedResources: parsed.excludedCanonicalExternalIds.length,
ignoredPseudoDemandRows: parsed.ignoredPseudoDemandRows,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "roster",
summary,
});
return {
batchId: batch.id,
counts: summary,
excludedCanonicalExternalIds: parsed.excludedCanonicalExternalIds,
};
}
@@ -0,0 +1,318 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
import { ensureImportBatch, finalizeImportBatchStage, getWorkbookFileName, type DispoImportDbClient, type DispoReferenceImportInput, toJsonObject } from "./shared.js";
async function upsertRootClient(
db: DispoImportDbClient,
input: { clientCode: string | null; name: string; sortOrder: number },
) {
const existing = await db.client.findFirst({
where: { name: input.name, parentId: null },
select: { id: true },
});
if (existing) {
return db.client.update({
where: { id: existing.id },
data: {
...(input.clientCode ? { code: input.clientCode } : {}),
sortOrder: input.sortOrder,
isActive: true,
},
select: { id: true },
});
}
return db.client.create({
data: {
name: input.name,
...(input.clientCode ? { code: input.clientCode } : {}),
sortOrder: input.sortOrder,
isActive: true,
},
select: { id: true },
});
}
async function upsertRootOrgUnit(
db: DispoImportDbClient,
input: { name: string; level: number; sortOrder: number },
) {
const existing = await db.orgUnit.findFirst({
where: { name: input.name, level: input.level, parentId: null },
select: { id: true },
});
if (existing) {
return db.orgUnit.update({
where: { id: existing.id },
data: { sortOrder: input.sortOrder, isActive: true },
select: { id: true },
});
}
return db.orgUnit.create({
data: {
name: input.name,
level: input.level,
sortOrder: input.sortOrder,
isActive: true,
},
select: { id: true },
});
}
export interface StageDispoReferenceDataResult {
batchId: string;
counts: {
countries: number;
managementLevelGroups: number;
managementLevels: number;
metroCities: number;
orgUnits: number;
stagedClients: number;
unresolved: number;
warnings: number;
};
}
export async function stageDispoReferenceData(
db: DispoImportDbClient,
input: DispoReferenceImportInput,
): Promise<StageDispoReferenceDataResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
referenceSourceFile: string;
} = {
referenceSourceFile: getWorkbookFileName(input.referenceWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseMandatoryDispoReferenceWorkbook(input.referenceWorkbookPath);
for (const country of parsed.countries) {
const createdCountry = await db.country.upsert({
where: { code: country.countryCode },
update: {
name: country.name,
dailyWorkingHours: country.dailyWorkingHours,
...(country.scheduleRules !== undefined ? { scheduleRules: country.scheduleRules } : {}),
isActive: true,
},
create: {
code: country.countryCode,
name: country.name,
dailyWorkingHours: country.dailyWorkingHours,
...(country.scheduleRules !== undefined ? { scheduleRules: country.scheduleRules } : {}),
isActive: true,
},
});
for (const metroCityName of country.metroCities) {
await db.metroCity.upsert({
where: {
countryId_name: {
countryId: createdCountry.id,
name: metroCityName,
},
},
update: {},
create: {
countryId: createdCountry.id,
name: metroCityName,
},
});
}
}
const rootOrgUnits = parsed.orgUnits.filter((orgUnit) => orgUnit.parentName === null);
const rootOrgUnitIdByName = new Map<string, string>();
for (const rootOrgUnit of rootOrgUnits) {
const created = await upsertRootOrgUnit(db, {
name: rootOrgUnit.name,
level: rootOrgUnit.level,
sortOrder: rootOrgUnit.sortOrder,
});
rootOrgUnitIdByName.set(rootOrgUnit.name, created.id);
}
const orgUnitIdByKey = new Map<string, string>();
for (const rootOrgUnit of rootOrgUnits) {
const rootId = rootOrgUnitIdByName.get(rootOrgUnit.name);
if (rootId) {
orgUnitIdByKey.set(`${rootOrgUnit.level}:${rootOrgUnit.name}`, rootId);
}
}
for (const orgUnit of parsed.orgUnits.filter((entry) => entry.parentName !== null)) {
const parentLevel = orgUnit.level - 1;
const parentId = orgUnitIdByKey.get(`${parentLevel}:${orgUnit.parentName}`);
if (!parentId) {
continue;
}
const created = await db.orgUnit.upsert({
where: {
parentId_name: {
parentId,
name: orgUnit.name,
},
},
update: {
sortOrder: orgUnit.sortOrder,
isActive: true,
},
create: {
parentId,
name: orgUnit.name,
level: orgUnit.level,
sortOrder: orgUnit.sortOrder,
isActive: true,
},
select: { id: true },
});
orgUnitIdByKey.set(`${orgUnit.level}:${orgUnit.name}`, created.id);
}
let managementLevelCount = 0;
for (const group of parsed.managementLevelGroups) {
const createdGroup = await db.managementLevelGroup.upsert({
where: { name: group.name },
update: {
targetPercentage: group.targetPercentage,
sortOrder: group.sortOrder,
},
create: {
name: group.name,
targetPercentage: group.targetPercentage,
sortOrder: group.sortOrder,
},
});
for (const levelName of group.levels) {
managementLevelCount += 1;
await db.managementLevel.upsert({
where: { name: levelName },
update: { groupId: createdGroup.id },
create: { name: levelName, groupId: createdGroup.id },
});
}
}
const rootClientIdByName = new Map<string, string>();
for (const rootClient of parsed.clients.filter((entry) => entry.parentName === null)) {
const created = await upsertRootClient(db, {
clientCode: rootClient.clientCode,
name: rootClient.name,
sortOrder: rootClient.sortOrder,
});
rootClientIdByName.set(rootClient.name, created.id);
}
for (const client of parsed.clients.filter((entry) => entry.parentName !== null)) {
const parentId = rootClientIdByName.get(client.parentName ?? "");
if (!parentId) {
continue;
}
const existing = await db.client.findFirst({
where: { name: client.name, parentId },
select: { id: true },
});
if (existing) {
await db.client.update({
where: { id: existing.id },
data: {
sortOrder: client.sortOrder,
isActive: true,
},
});
} else {
await db.client.create({
data: {
name: client.name,
parentId,
sortOrder: client.sortOrder,
isActive: true,
},
});
}
}
await db.stagedClient.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.REFERENCE,
},
});
if (parsed.clients.length > 0) {
await db.stagedClient.createMany({
data: parsed.clients.map((client) => ({
importBatchId: batch.id,
status: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.REFERENCE,
sourceWorkbook: getWorkbookFileName(input.referenceWorkbookPath),
sourceSheet: "Project-Attr",
sourceRow: client.sourceRow,
sourceColumn: client.sourceColumn,
clientCode: client.clientCode,
parentClientCode: client.parentClientCode,
name: client.name,
sortOrder: client.sortOrder,
isActive: true,
warnings: [],
rawPayload: {
name: client.name,
parentName: client.parentName,
} as Prisma.InputJsonValue,
normalizedData: {
clientCode: client.clientCode,
name: client.name,
parentClientCode: client.parentClientCode,
parentName: client.parentName,
} as Prisma.InputJsonValue,
})),
});
}
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.REFERENCE,
},
});
const summary = {
countries: parsed.countries.length,
managementLevelGroups: parsed.managementLevelGroups.length,
managementLevels: managementLevelCount,
metroCities: parsed.countries.reduce((count, country) => count + country.metroCities.length, 0),
orgUnits: parsed.orgUnits.length,
stagedClients: parsed.clients.length,
unresolved: 0,
warnings: parsed.warnings.length,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "reference",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}