import { fileURLToPath, pathToFileURL } from "node:url"; import { resolve } from "node:path"; import { PrismaClient, StagedRecordStatus } from "@prisma/client"; import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js"; loadWorkspaceEnv(); const prisma = new PrismaClient(); const DEFAULT_REFERENCE_WORKBOOK = resolveWorkspacePath( "samples", "Dispov2", "MandatoryDispoCategories_V3.xlsx", ); const DEFAULT_PLANNING_WORKBOOK = resolveWorkspacePath( "samples", "Dispov2", "DISPO_2026.xlsx", ); const DEFAULT_CHARGEABILITY_WORKBOOK = resolveWorkspacePath( "samples", "Dispov2", "20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0.943_4Hartmut.xlsx", ); const DEFAULT_ROSTER_WORKBOOK = resolveWorkspacePath( "samples", "Dispov2", "MV_DispoRoster.xlsx", ); const DEFAULT_COST_WORKBOOK = resolveWorkspacePath( "samples", "Dispov2", "Resource Roster_MASTER_FY26_CJ_20251201.xlsx", ); export interface ImportDispoBatchOptions { allowTbdUnresolved: boolean; chargeabilityWorkbookPath: string; costWorkbookPath: string | undefined; importTbdProjects: boolean; notes: string | undefined; planningWorkbookPath: string; previewUnresolvedLimit: number; referenceWorkbookPath: string; rosterWorkbookPath: string | undefined; skipCommit: boolean; strictSourceData: boolean; } interface DispoImportReadinessIssue { code: string; count: number; message: string; resolution: string; severity: "blocker" | "warning"; } interface DispoImportReadinessReport { assignmentCount: number; availabilityRuleCount: number; canCommitWithFallbacks: boolean; canCommitWithStrictSourceData: boolean; fallbackAssumptions: string[]; issues: DispoImportReadinessIssue[]; projectCount: number; resourceCount: number; unresolvedCount: number; vacationCount: number; } interface StageDispoImportBatchInput { chargeabilityWorkbookPath: string; costWorkbookPath?: string; notes?: string | null; planningWorkbookPath: string; referenceWorkbookPath: string; rosterWorkbookPath?: string; } interface StageDispoImportBatchResult { batchId: string; counts: { stagedAssignments: number; stagedAvailabilityRules: number; stagedClients: number; stagedProjects: number; stagedResources: number; stagedRosterResources: number; stagedVacations: number; unresolved: number; }; readiness: DispoImportReadinessReport; } interface CommitDispoImportBatchInput { allowTbdUnresolved?: boolean; importTbdProjects?: boolean; importBatchId: string; } interface CommitDispoImportBatchResult { batchId: string; counts: { committedAssignments: number; committedProjects: number; committedResources: number; committedVacations: number; updatedEntitlements: number; updatedResourceAvailabilities: number; upsertedResourceRoles: number; }; unresolved: { blocked: number; skippedTbd: number; }; } interface DispoImportModule { stageDispoImportBatch( db: PrismaClient, input: StageDispoImportBatchInput, ): Promise; commitDispoImportBatch( db: PrismaClient, input: CommitDispoImportBatchInput, ): Promise; } interface UnresolvedPreviewRecord { message: string; projectKey: string | null; recordType: string; resolutionHint: string | null; resourceExternalId: string | null; sourceRow: number; sourceSheet: string; } function requireValue(argv: string[], index: number, flag: string): string { const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`Missing value for ${flag}`); } return value; } export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptions { const options: ImportDispoBatchOptions = { allowTbdUnresolved: true, chargeabilityWorkbookPath: DEFAULT_CHARGEABILITY_WORKBOOK, costWorkbookPath: DEFAULT_COST_WORKBOOK, importTbdProjects: false, notes: undefined, planningWorkbookPath: DEFAULT_PLANNING_WORKBOOK, previewUnresolvedLimit: 10, referenceWorkbookPath: DEFAULT_REFERENCE_WORKBOOK, rosterWorkbookPath: DEFAULT_ROSTER_WORKBOOK, skipCommit: false, strictSourceData: false, }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--reference-workbook") { options.referenceWorkbookPath = resolve(requireValue(argv, index, argument)); index += 1; continue; } if (argument === "--planning-workbook") { options.planningWorkbookPath = resolve(requireValue(argv, index, argument)); index += 1; continue; } if (argument === "--chargeability-workbook") { options.chargeabilityWorkbookPath = resolve(requireValue(argv, index, argument)); index += 1; continue; } if (argument === "--roster-workbook") { options.rosterWorkbookPath = resolve(requireValue(argv, index, argument)); index += 1; continue; } if (argument === "--cost-workbook") { options.costWorkbookPath = resolve(requireValue(argv, index, argument)); index += 1; continue; } if (argument === "--notes") { options.notes = requireValue(argv, index, argument); index += 1; continue; } if (argument === "--preview-unresolved") { const value = Number.parseInt(requireValue(argv, index, argument), 10); if (!Number.isInteger(value) || value < 0) { throw new Error(`Invalid value for ${argument}: expected a non-negative integer`); } options.previewUnresolvedLimit = value; index += 1; continue; } if (argument === "--skip-commit") { options.skipCommit = true; continue; } if (argument === "--strict-source-data") { options.strictSourceData = true; continue; } if (argument === "--disallow-tbd") { options.allowTbdUnresolved = false; continue; } if (argument === "--import-tbd-projects") { options.importTbdProjects = true; continue; } if (argument === "--no-roster") { options.rosterWorkbookPath = undefined; continue; } if (argument === "--no-cost") { options.costWorkbookPath = undefined; continue; } if (argument === "--help" || argument === "-h") { throw new Error(buildHelpText()); } throw new Error(`Unknown argument: ${argument}`); } return options; } function buildHelpText() { return [ "Usage: pnpm --filter @planarchy/db db:import:dispo [options]", "", "Options:", " --reference-workbook Override MandatoryDispoCategories workbook", " --planning-workbook Override DISPO planning workbook", " --chargeability-workbook Override chargeability workbook", " --roster-workbook Override dispo roster workbook", " --cost-workbook Override cost-rate workbook", " --no-roster Stage without roster workbook", " --no-cost Stage without cost-rate workbook", " --notes Attach operator notes to the import batch", " --preview-unresolved Print up to N unresolved rows (default 10)", " --skip-commit Stage and assess readiness only", " --strict-source-data Require readiness without fallback assumptions", " --disallow-tbd Fail commit if [tbd] unresolved rows remain", " --import-tbd-projects Commit [tbd] rows as provisional DRAFT projects", ].join("\n"); } async function loadDispoImportModule(): Promise { const modulePath = resolveWorkspacePath("packages", "application", "src", "index.ts"); return import(pathToFileURL(modulePath).href) as Promise; } function printWorkbookSources(options: ImportDispoBatchOptions) { console.log("Dispo import sources:"); console.log(` reference: ${options.referenceWorkbookPath}`); console.log(` planning: ${options.planningWorkbookPath}`); console.log(` chargeability: ${options.chargeabilityWorkbookPath}`); console.log(` roster: ${options.rosterWorkbookPath ?? "(disabled)"}`); console.log(` cost rates: ${options.costWorkbookPath ?? "(disabled)"}`); } function printReadiness(report: DispoImportReadinessReport) { console.log("Readiness:"); console.log(` resources: ${report.resourceCount}`); console.log(` assignment rows: ${report.assignmentCount}`); console.log(` project rows: ${report.projectCount}`); console.log(` vacations: ${report.vacationCount}`); console.log(` availability rules: ${report.availabilityRuleCount}`); console.log(` unresolved rows: ${report.unresolvedCount}`); console.log(` strict commit ready: ${report.canCommitWithStrictSourceData ? "yes" : "no"}`); console.log(` fallback commit ready:${report.canCommitWithFallbacks ? " yes" : " no"}`); if (report.issues.length === 0) { console.log(" issues: none"); return; } console.log(" issues:"); for (const issue of report.issues) { console.log(` - [${issue.severity}] ${issue.code} (${issue.count})`); console.log(` ${issue.message}`); console.log(` resolution: ${issue.resolution}`); } if (report.fallbackAssumptions.length > 0) { console.log(" approved fallback assumptions required:"); for (const assumption of report.fallbackAssumptions) { console.log(` - ${assumption}`); } } } async function loadUnresolvedPreview( batchId: string, limit: number, ): Promise { if (limit <= 0) { return []; } return prisma.stagedUnresolvedRecord.findMany({ where: { importBatchId: batchId, status: StagedRecordStatus.UNRESOLVED, }, orderBy: [ { recordType: "asc" }, { sourceSheet: "asc" }, { sourceRow: "asc" }, ], take: limit, select: { message: true, projectKey: true, recordType: true, resolutionHint: true, resourceExternalId: true, sourceRow: true, sourceSheet: true, }, }); } function printUnresolvedPreview(records: ReadonlyArray, total: number) { if (records.length === 0) { return; } console.log(`Unresolved preview (${records.length}/${total}):`); for (const record of records) { const location = `${record.sourceSheet}:${record.sourceRow}`; const identity = [record.resourceExternalId, record.projectKey].filter(Boolean).join(" | "); console.log(` - ${record.recordType} @ ${location}${identity ? ` | ${identity}` : ""}`); console.log(` ${record.message}`); if (record.resolutionHint) { console.log(` resolution: ${record.resolutionHint}`); } } } function ensureCommitAllowed(options: ImportDispoBatchOptions, readiness: DispoImportReadinessReport) { if (options.strictSourceData) { if (!readiness.canCommitWithStrictSourceData) { throw new Error("Readiness is not strict-source-data clean. Re-run without --strict-source-data or fix blockers."); } return; } if (!readiness.canCommitWithFallbacks) { throw new Error("Readiness has unresolved blocker issues that are not covered by the agreed fallback rules."); } } export async function runImportDispoBatch(options: ImportDispoBatchOptions) { const dispoImport = await loadDispoImportModule(); printWorkbookSources(options); console.log(""); console.log("Staging workbook data..."); const stageResult = await dispoImport.stageDispoImportBatch(prisma, { chargeabilityWorkbookPath: options.chargeabilityWorkbookPath, ...(options.costWorkbookPath ? { costWorkbookPath: options.costWorkbookPath } : {}), ...(options.notes ? { notes: options.notes } : {}), planningWorkbookPath: options.planningWorkbookPath, referenceWorkbookPath: options.referenceWorkbookPath, ...(options.rosterWorkbookPath ? { rosterWorkbookPath: options.rosterWorkbookPath } : {}), }); console.log(`Staged import batch: ${stageResult.batchId}`); console.log("Stage counts:"); console.log(` clients: ${stageResult.counts.stagedClients}`); console.log(` resources: ${stageResult.counts.stagedResources}`); console.log(` roster resources: ${stageResult.counts.stagedRosterResources}`); console.log(` projects: ${stageResult.counts.stagedProjects}`); console.log(` assignments: ${stageResult.counts.stagedAssignments}`); console.log(` vacations: ${stageResult.counts.stagedVacations}`); console.log(` availability rules: ${stageResult.counts.stagedAvailabilityRules}`); console.log(` unresolved: ${stageResult.counts.unresolved}`); printReadiness(stageResult.readiness); const unresolvedPreview = await loadUnresolvedPreview( stageResult.batchId, options.previewUnresolvedLimit, ); printUnresolvedPreview(unresolvedPreview, stageResult.readiness.unresolvedCount); if (options.skipCommit) { console.log(""); console.log("Commit skipped by operator flag."); return { stageResult, commitResult: null }; } ensureCommitAllowed(options, stageResult.readiness); console.log(""); console.log("Committing staged rows into live Planarchy tables..."); const commitResult = await dispoImport.commitDispoImportBatch(prisma, { allowTbdUnresolved: options.allowTbdUnresolved, importTbdProjects: options.importTbdProjects, importBatchId: stageResult.batchId, }); console.log(`Committed import batch: ${commitResult.batchId}`); console.log("Commit counts:"); console.log(` resources: ${commitResult.counts.committedResources}`); console.log(` resource roles: ${commitResult.counts.upsertedResourceRoles}`); console.log(` projects: ${commitResult.counts.committedProjects}`); console.log(` assignments: ${commitResult.counts.committedAssignments}`); console.log(` vacations/public holidays: ${commitResult.counts.committedVacations}`); console.log(` vacation entitlements: ${commitResult.counts.updatedEntitlements}`); console.log(` availability overlays: ${commitResult.counts.updatedResourceAvailabilities}`); console.log(` blocked unresolved: ${commitResult.unresolved.blocked}`); console.log(` skipped [tbd] unresolved: ${commitResult.unresolved.skippedTbd}`); return { stageResult, commitResult }; } async function main() { try { const options = parseImportDispoBatchArgs(process.argv.slice(2)); await runImportDispoBatch(options); } catch (error) { if (error instanceof Error) { console.error(error.message); } else { console.error(error); } process.exitCode = 1; } finally { await prisma.$disconnect(); } } const currentFile = fileURLToPath(import.meta.url); const entryFile = process.argv[1] ? resolve(process.argv[1]) : null; if (entryFile === currentFile) { void main(); }