require('dotenv/config'); const fs = require('node:fs'); const path = require('node:path'); const ExcelJS = require('exceljs'); const { createPrismaClient } = require('../prismaClient'); const SUPPORTED_TYPES = ['projects', 'members', 'milestones', 'risks', 'contracts']; function printHelp() { console.log(`用法: node src/scripts/import-project-ledgers.js --type <${SUPPORTED_TYPES.join('|')}> --file [--apply] [--json] 说明: - 默认执行 dry-run,只做校验与差异摘要,不写数据库 - 传入 --apply 后才会写库 - CSV 第一行为表头,建议使用 docs/migration-mapping.md 中的最小列 示例: node src/scripts/import-project-ledgers.js --type projects --file ./tmp/projects.csv node src/scripts/import-project-ledgers.js --type contracts --file ./tmp/contracts.csv --apply `); } function parseArgs(argv) { const result = { type: null, file: null, apply: false, json: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-h') { result.help = true; continue; } if (arg === '--apply') { result.apply = true; continue; } if (arg === '--json') { result.json = true; continue; } if (arg === '--type') { result.type = argv[index + 1] ?? null; index += 1; continue; } if (arg === '--file') { result.file = argv[index + 1] ?? null; index += 1; continue; } } return result; } function normalizeHeader(value) { return String(value ?? '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); } function normalizeCell(value) { if (value == null) return ''; return String(value).trim(); } function parseDateOnly(value) { const raw = normalizeCell(value); if (!raw) return null; const date = new Date(`${raw}T00:00:00.000Z`); if (Number.isNaN(date.getTime())) { throw new Error(`invalid_date:${raw}`); } return date; } function parseBoolean(value) { const raw = normalizeCell(value).toLowerCase(); return ['1', 'true', 'yes', 'y', '是'].includes(raw); } async function readCsvRows(filePath) { const workbook = new ExcelJS.Workbook(); const worksheet = await workbook.csv.readFile(filePath); const headerRow = worksheet.getRow(1); const headers = headerRow.values .slice(1) .map((value) => normalizeHeader(value)); const rows = []; worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; const item = {}; headers.forEach((header, index) => { item[header] = normalizeCell(row.getCell(index + 1).value); }); if (Object.values(item).some((value) => value !== '')) { rows.push({ rowNumber, data: item }); } }); return rows; } function assertRequiredHeaders(rows, required) { const first = rows[0]?.data ?? {}; const missing = required.filter((key) => !(key in first)); if (missing.length) { throw new Error(`missing_headers:${missing.join(',')}`); } } async function buildReferenceMaps(prisma) { const [employees, projects] = await Promise.all([ prisma.employee.findMany({ select: { id: true, employee_no: true, name: true, status: true } }), prisma.project.findMany({ select: { id: true, project_no: true, name: true } }), ]); return { employeesByNo: new Map(employees.map((item) => [item.employee_no, item])), projectsByNo: new Map(projects.map((item) => [item.project_no, item])), }; } function makeSummary(type, filePath, apply, totalRows) { return { type, file: path.resolve(filePath), mode: apply ? 'apply' : 'dry-run', totalRows, createCount: 0, updateCount: 0, skipCount: 0, errorCount: 0, errors: [], }; } function pushError(summary, rowNumber, message) { summary.errorCount += 1; summary.errors.push({ rowNumber, message }); } async function handleProjects({ prisma, rows, apply, summary }) { assertRequiredHeaders(rows, ['project_no', 'name', 'client_name', 'owner_employee_no']); const refs = await buildReferenceMaps(prisma); const existing = new Map((await prisma.project.findMany({ select: { id: true, project_no: true } })).map((item) => [item.project_no, item])); for (const row of rows) { const data = row.data; const owner = refs.employeesByNo.get(data.owner_employee_no); const manager = data.manager_employee_no ? refs.employeesByNo.get(data.manager_employee_no) : null; if (!owner) { pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`); continue; } if (data.manager_employee_no && !manager) { pushError(summary, row.rowNumber, `manager_employee_no_not_found:${data.manager_employee_no}`); continue; } const payload = { project_no: data.project_no, name: data.name, client_name: data.client_name, client_alias: data.client_alias || null, owner_employee_id: owner.id, manager_employee_id: manager?.id ?? null, status: data.status || 'draft', starts_at: data.starts_at ? parseDateOnly(data.starts_at) : null, target_end_at: data.target_end_at ? parseDateOnly(data.target_end_at) : null, actual_end_at: data.actual_end_at ? parseDateOnly(data.actual_end_at) : null, summary: data.summary || null, is_confidential: parseBoolean(data.is_confidential), }; const current = existing.get(data.project_no); if (!current) { summary.createCount += 1; if (apply) { await prisma.project.create({ data: { ...payload, members: { create: { employee_id: owner.id, role: 'owner' } }, }, }); } continue; } summary.updateCount += 1; if (apply) { await prisma.project.update({ where: { id: current.id }, data: payload }); await prisma.projectMember.upsert({ where: { project_id_employee_id: { project_id: current.id, employee_id: owner.id } }, create: { project_id: current.id, employee_id: owner.id, role: 'owner' }, update: { role: 'owner', left_at: null }, }); } } } async function handleMembers({ prisma, rows, apply, summary }) { assertRequiredHeaders(rows, ['project_no', 'employee_no', 'role']); const refs = await buildReferenceMaps(prisma); for (const row of rows) { const data = row.data; const project = refs.projectsByNo.get(data.project_no); const employee = refs.employeesByNo.get(data.employee_no); if (!project) { pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`); continue; } if (!employee) { pushError(summary, row.rowNumber, `employee_no_not_found:${data.employee_no}`); continue; } const current = await prisma.projectMember.findUnique({ where: { project_id_employee_id: { project_id: project.id, employee_id: employee.id } }, select: { id: true }, }); if (current) summary.updateCount += 1; else summary.createCount += 1; if (apply) { await prisma.projectMember.upsert({ where: { project_id_employee_id: { project_id: project.id, employee_id: employee.id } }, create: { project_id: project.id, employee_id: employee.id, role: data.role || 'member' }, update: { role: data.role || 'member', left_at: null }, }); } } } async function handleMilestones({ prisma, rows, apply, summary }) { assertRequiredHeaders(rows, ['project_no', 'title']); const refs = await buildReferenceMaps(prisma); for (const row of rows) { const data = row.data; const project = refs.projectsByNo.get(data.project_no); const owner = data.owner_employee_no ? refs.employeesByNo.get(data.owner_employee_no) : null; if (!project) { pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`); continue; } if (data.owner_employee_no && !owner) { pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`); continue; } summary.createCount += 1; if (apply) { await prisma.projectMilestone.create({ data: { project_id: project.id, title: data.title, description: data.description || null, status: data.status || 'pending', due_date: data.due_date ? parseDateOnly(data.due_date) : null, owner_employee_id: owner?.id ?? null, sort_order: Number(data.sort_order || 100), }, }); } } } async function handleRisks({ prisma, rows, apply, summary }) { assertRequiredHeaders(rows, ['project_no', 'title', 'detail']); const refs = await buildReferenceMaps(prisma); for (const row of rows) { const data = row.data; const project = refs.projectsByNo.get(data.project_no); const owner = data.owner_employee_no ? refs.employeesByNo.get(data.owner_employee_no) : null; if (!project) { pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`); continue; } if (data.owner_employee_no && !owner) { pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`); continue; } summary.createCount += 1; if (apply) { await prisma.projectRisk.create({ data: { project_id: project.id, title: data.title, detail: data.detail, level: data.level || 'medium', status: data.status || 'open', owner_employee_id: owner?.id ?? null, }, }); } } } async function handleContracts({ prisma, rows, apply, summary }) { assertRequiredHeaders(rows, ['contract_no', 'counterparty_name', 'summary', 'archive_location', 'owner_employee_no']); const refs = await buildReferenceMaps(prisma); const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true } })).map((item) => [item.contract_no, item])); for (const row of rows) { const data = row.data; const project = data.project_no ? refs.projectsByNo.get(data.project_no) : null; const owner = refs.employeesByNo.get(data.owner_employee_no); if (data.project_no && !project) { pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`); continue; } if (!owner) { pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`); continue; } const payload = { contract_no: data.contract_no, project_id: project?.id ?? null, counterparty_name: data.counterparty_name, summary: data.summary, confidentiality_level: data.confidentiality_level || 'internal', archive_location: data.archive_location, offline_archive_no: data.offline_archive_no || null, owner_employee_id: owner.id, status: data.status || 'active', }; const current = existing.get(data.contract_no); if (!current) summary.createCount += 1; else summary.updateCount += 1; if (apply) { await prisma.contractRegistry.upsert({ where: { contract_no: data.contract_no }, create: payload, update: payload, }); } } } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.type || !args.file) { printHelp(); process.exit(args.help ? 0 : 1); } if (!SUPPORTED_TYPES.includes(args.type)) { throw new Error(`unsupported_type:${args.type}`); } if (!fs.existsSync(args.file)) { throw new Error(`file_not_found:${args.file}`); } const rows = await readCsvRows(args.file); const summary = makeSummary(args.type, args.file, args.apply, rows.length); const { prisma, pool } = createPrismaClient(); try { const handlers = { projects: handleProjects, members: handleMembers, milestones: handleMilestones, risks: handleRisks, contracts: handleContracts, }; await handlers[args.type]({ prisma, rows, apply: args.apply, summary }); const output = args.json ? JSON.stringify(summary, null, 2) : [ `type=${summary.type}`, `mode=${summary.mode}`, `file=${summary.file}`, `totalRows=${summary.totalRows}`, `createCount=${summary.createCount}`, `updateCount=${summary.updateCount}`, `skipCount=${summary.skipCount}`, `errorCount=${summary.errorCount}`, ...(summary.errors.length ? ['errors=', ...summary.errors.map((item) => ` row ${item.rowNumber}: ${item.message}`)] : []), ].join('\n'); process.stdout.write(`${output}\n`); if (summary.errorCount > 0) { process.exitCode = 2; } } finally { await prisma.$disconnect(); await pool.end(); } } main().catch((error) => { console.error(error?.message ?? error); process.exit(1); });