407 行
13 KiB
JavaScript
407 行
13 KiB
JavaScript
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 <csv路径> [--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);
|
|
});
|