470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
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";
|
|
import { assertCapaKrakenDbTarget } from "./safe-destructive-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<StageDispoImportBatchResult>;
|
|
commitDispoImportBatch(
|
|
db: PrismaClient,
|
|
input: CommitDispoImportBatchInput,
|
|
): Promise<CommitDispoImportBatchResult>;
|
|
}
|
|
|
|
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 @capakraken/db db:import:dispo [options]",
|
|
"",
|
|
"Options:",
|
|
" --reference-workbook <path> Override MandatoryDispoCategories workbook",
|
|
" --planning-workbook <path> Override DISPO planning workbook",
|
|
" --chargeability-workbook <path> Override chargeability workbook",
|
|
" --roster-workbook <path> Override dispo roster workbook",
|
|
" --cost-workbook <path> Override cost-rate workbook",
|
|
" --no-roster Stage without roster workbook",
|
|
" --no-cost Stage without cost-rate workbook",
|
|
" --notes <text> Attach operator notes to the import batch",
|
|
" --preview-unresolved <count> 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<DispoImportModule> {
|
|
const modulePath = resolveWorkspacePath("packages", "application", "src", "index.ts");
|
|
return import(pathToFileURL(modulePath).href) as Promise<DispoImportModule>;
|
|
}
|
|
|
|
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<UnresolvedPreviewRecord[]> {
|
|
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<UnresolvedPreviewRecord>, 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) {
|
|
assertCapaKrakenDbTarget("db:import:dispo");
|
|
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 CapaKraken 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();
|
|
}
|