const fs = require('fs') const path = require('path') const { spawnSync } = require('child_process') const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx') const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' const normalizeText = (value) => String(value == null ? '' : value) .replace(/\r/g, ' ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() const requireArgValue = (args, index, argumentName) => { const value = args[index + 1] if (!value || String(value).startsWith('--')) { throw new Error(`${argumentName} 缺少参数值`) } return value } const parseArgs = (args = process.argv.slice(2)) => { const options = { inputDir: '', importBackupDir: '', output: DEFAULT_OUTPUT, nullLocationJson: '', plinkPath: '', sshHost: '', sshHostkey: '', sshPassword: '' } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--input-dir') { options.inputDir = requireArgValue(args, index, arg) index += 1 } else if (arg === '--import-backup-dir') { options.importBackupDir = requireArgValue(args, index, arg) index += 1 } else if (arg === '--output') { options.output = requireArgValue(args, index, arg) index += 1 } else if (arg === '--null-location-json') { options.nullLocationJson = requireArgValue(args, index, arg) index += 1 } else if (arg === '--plink-path') { options.plinkPath = requireArgValue(args, index, arg) index += 1 } else if (arg === '--ssh-host') { options.sshHost = requireArgValue(args, index, arg) index += 1 } else if (arg === '--ssh-hostkey') { options.sshHostkey = requireArgValue(args, index, arg) index += 1 } else if (arg === '--ssh-password') { options.sshPassword = requireArgValue(args, index, arg) index += 1 } else { throw new Error(`不支持的参数: ${arg}`) } } return options } const resolveRemoteConnection = (options, env = process.env) => { const connection = { plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH), sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST), sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY), sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || '' } const missing = [] if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path') if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host') if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey') if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password') if (missing.length > 0) { throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`) } return connection } const resolveLatestDir = (prefix) => { const candidates = fs .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) .map((entry) => entry.name) .sort() if (!candidates.length) { throw new Error(`未找到目录前缀 ${prefix} 的输出结果`) } return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) } const resolvePathArg = (value, fallbackPrefix) => { if (!value) { return resolveLatestDir(fallbackPrefix) } const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value) if (!fs.existsSync(resolved)) { throw new Error(`路径不存在: ${resolved}`) } return resolved } const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) const readJsonl = (filePath) => fs .readFileSync(filePath, 'utf8') .split(/\r?\n/) .filter(Boolean) .map((line) => JSON.parse(line)) const parseDescription = (description) => { const result = {} const normalized = normalizeText(description) if (!normalized) { return result } for (const part of normalized.split(';')) { const trimmed = normalizeText(part) if (!trimmed) { continue } const splitIndex = trimmed.indexOf('=') if (splitIndex === -1) { continue } const key = normalizeText(trimmed.slice(0, splitIndex)) const value = normalizeText(trimmed.slice(splitIndex + 1)) if (key) { result[key] = value } } return result } const withEditableFields = (row, fields) => ({ ...row, ...fields }) const buildPendingRows = (records) => records .filter((record) => record.bucket === 'pending-review') .map((record) => withEditableFields( { source_row: record.sourceRow, legacy_asset_code: normalizeText(record.assetCodeRaw), asset_name: normalizeText(record.assetNameRaw), source_status: normalizeText(record.currentStatusRaw), raw_owner_name: normalizeText(record.ownerRaw), raw_department: normalizeText(record.departmentRaw), normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), raw_location: normalizeText(record.addressRaw), pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '', suggested_action: Array.isArray(record.pendingReasons) && record.pendingReasons.includes('unmapped_department') ? '先确认业务部门映射' : '人工核对后再补录' }, { 行政填写_目标部门: '', 行政填写_目标员工: '', 行政填写_标准地址: '', 行政填写_资产状态: '', 行政填写_备注: '' } ) ) const flattenDuplicateRows = (duplicateDetails) => duplicateDetails.flatMap((item) => item.items.map((detail, index) => ({ legacy_code: item.legacyCode, conflict_index: index + 1, bucket: detail.bucket, source_row: detail.sourceRow, asset_name: detail.assetName, owner: detail.owner, department: detail.department, source_status: detail.sourceStatus, address: detail.address, unit_price: detail.unitPrice, purchase_date: detail.purchaseDate, suggested_action: '同一个资产编码对应了多条记录,先人工确认保留哪一条' })) ) const runRemoteNodeScript = (script, connection) => { const result = spawnSync( connection.plinkPath, [ '-batch', '-ssh', '-hostkey', connection.sshHostkey, '-pw', connection.sshPassword, connection.sshHost, REMOTE_NODE_COMMAND ], { encoding: 'utf8', input: script, maxBuffer: 32 * 1024 * 1024, windowsHide: true } ) if (result.error) { throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。') } if (result.status !== 0) { throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。') } const stdout = normalizeText(result.stdout) if (!stdout) { throw new Error('远程脚本返回为空') } try { return JSON.parse(stdout) } catch { throw new Error('远程脚本返回格式无效') } } const readNullLocationRows = (nullLocationJsonArg, options) => { const resolvedPath = nullLocationJsonArg ? path.isAbsolute(nullLocationJsonArg) ? nullLocationJsonArg : path.resolve(process.cwd(), nullLocationJsonArg) : '' if (resolvedPath) { if (!fs.existsSync(resolvedPath)) { throw new Error(`待补地址 JSON 不存在: ${resolvedPath}`) } return readJson(resolvedPath) } return runRemoteNodeScript(buildRemoteNullLocationScript(), resolveRemoteConnection(options)) } const buildRemoteNullLocationScript = () => ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() ;(async () => { const rows = await prisma.equipment.findMany({ where: { storage_location_id: null }, orderBy: [{ status: 'asc' }, { equipment_code: 'asc' }], select: { equipment_code: true, equipment_name: true, equipment_type: true, status: true, unit_price: true, purchase_date: true, description: true, department: { select: { name: true } } } }) process.stdout.write(JSON.stringify(rows)) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` const buildOwnerManualRows = (records, duplicateCodeSet) => records .filter( (record) => record.bucket === 'import-without-owner' && record.statusKind === 'assigned' && normalizeText(record.assetCodeRaw) && !duplicateCodeSet.has(normalizeText(record.assetCodeRaw).toUpperCase()) ) .map((record) => withEditableFields( { source_row: record.sourceRow, legacy_asset_code: normalizeText(record.assetCodeRaw).toUpperCase(), asset_name: normalizeText(record.assetNameRaw), source_status: normalizeText(record.currentStatusRaw), raw_owner_name: normalizeText(record.ownerRaw), raw_department: normalizeText(record.departmentRaw), normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), raw_location: normalizeText(record.addressRaw), pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '', suggested_action: '资产已导入,但当前使用人还没落到系统里;请行政核对后补登记使用人' }, { 行政填写_目标员工: '', 行政填写_备注: '' } ) ) const buildOwnerAliasSummaryRows = (records, duplicateCodeSet) => { const grouped = new Map() for (const record of records) { const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() const ownerName = normalizeText(record.ownerRaw) if ( record.bucket !== 'import-without-owner' || record.statusKind !== 'assigned' || !legacyCode || duplicateCodeSet.has(legacyCode) || !ownerName ) { continue } const key = ownerName if (!grouped.has(key)) { grouped.set(key, { owner_name: ownerName, asset_count: 0, departments: new Set(), pending_reasons: new Set(), asset_codes: [] }) } const current = grouped.get(key) current.asset_count += 1 current.departments.add(normalizeText(record.departmentRaw)) for (const reason of Array.isArray(record.pendingReasons) ? record.pendingReasons : []) { current.pending_reasons.add(reason) } current.asset_codes.push(legacyCode) } return [...grouped.values()] .map((item) => withEditableFields( { owner_name: item.owner_name, asset_count: item.asset_count, departments: [...item.departments].filter(Boolean).join('、'), pending_reasons: [...item.pending_reasons].filter(Boolean).join('|'), sample_asset_codes: item.asset_codes.slice(0, 10).join('、'), suggested_action: '先核对该花名对应的当前在职员工,再统一补挂名下资产' }, { 行政填写_目标员工: '', 行政填写_备注: '' } ) ) .sort((left, right) => right.asset_count - left.asset_count || left.owner_name.localeCompare(right.owner_name, 'zh-CN')) } const buildNullLocationRows = (rows) => rows.map((row) => { const metadata = parseDescription(row.description) return withEditableFields( { source_row: metadata.source_row || '', legacy_asset_code: normalizeText(row.equipment_code), asset_name: normalizeText(row.equipment_name), equipment_type: normalizeText(row.equipment_type), current_status: normalizeText(row.status), current_department: normalizeText(row.department?.name || ''), raw_department: metadata.raw_department || '', raw_owner: metadata.raw_owner || '', raw_location: metadata.raw_location || '', purchase_date: row.purchase_date ? String(row.purchase_date).slice(0, 10) : '', unit_price: normalizeText(row.unit_price), suggested_action: '资产已导入,但没有标准地址;请行政确认后补齐标准存放地点' }, { 行政填写_标准地址: '', 行政填写_备注: '' } ) }) const appendSheet = (workbook, name, rows) => { const safeRows = rows.length ? rows : [{ message: '当前没有数据' }] const sheet = XLSX.utils.json_to_sheet(safeRows) XLSX.utils.book_append_sheet(workbook, sheet, name) } const writeWorkbookWithFallback = (workbook, outputPath) => { try { XLSX.writeFile(workbook, outputPath) return outputPath } catch (error) { if (error && error.code === 'EBUSY') { const parsed = path.parse(outputPath) const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) const fallbackPath = path.join(parsed.dir, `${parsed.name}-${timestamp}${parsed.ext}`) XLSX.writeFile(workbook, fallbackPath) return fallbackPath } throw error } } const run = () => { const options = parseArgs() const inputDir = resolvePathArg(options.inputDir, 'migration_dry_run_') const importBackupDir = resolvePathArg(options.importBackupDir, 'live_import_backup_') const outputPath = path.isAbsolute(options.output) ? options.output : path.resolve(process.cwd(), options.output) const normalizedRecords = readJsonl(path.join(inputDir, 'normalized_assets.jsonl')) const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json')) const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json')) const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json')) const liveNullLocationRows = readNullLocationRows(options.nullLocationJson, options) const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode)) const pendingRows = buildPendingRows(normalizedRecords) const ownerManualRows = buildOwnerManualRows(normalizedRecords, duplicateCodeSet) const ownerAliasSummaryRows = buildOwnerAliasSummaryRows(normalizedRecords, duplicateCodeSet) const flattenedDuplicateRows = flattenDuplicateRows(duplicateCodes.duplicateDetails) const nullLocationRows = buildNullLocationRows(liveNullLocationRows) const workbook = XLSX.utils.book_new() appendSheet(workbook, '概览', [ { 项目: 'dry-run 待人工确认', 数量: pendingRows.length }, { 项目: '缺少资产编码', 数量: missingCode.excludedMissingCodeCount || 0 }, { 项目: '重复资产编码(编码数)', 数量: duplicateCodes.excludedDuplicateCodeCount || 0 }, { 项目: '重复资产编码(记录数)', 数量: duplicateCodes.excludedDuplicateRecordCount || 0 }, { 项目: '已导入待补使用人', 数量: ownerManualRows.length }, { 项目: '待核对历史花名', 数量: ownerAliasSummaryRows.length }, { 项目: 'live 待补标准地址', 数量: nullLocationRows.length }, { 项目: '本轮已导入总数', 数量: plannedSummary.finalImportCount || 0 }, { 项目: '其中已导入不带使用人', 数量: plannedSummary.finalImportWithoutOwnerCount || 0 } ]) appendSheet(workbook, '填写说明', [ { 工作表: '待确认部门', 说明: '只在“行政填写_目标部门 / 目标员工 / 标准地址 / 资产状态”里填写需要修正的值。' }, { 工作表: '待补使用人', 说明: '在“行政填写_目标员工”里填写当前在职员工姓名或花名。' }, { 工作表: '花名核对', 说明: '同一个历史花名如果能统一对应到当前员工,优先在这里填写。' }, { 工作表: '待补地址', 说明: '在“行政填写_标准地址”里填写系统里的标准地址名。' }, { 工作表: '输出用途', 说明: '填写完成后可生成 migration_manual_overrides.json,再重新 dry-run / 导入。' } ]) appendSheet(workbook, '待确认部门', pendingRows) appendSheet(workbook, '缺少编码', missingCode.rows || []) appendSheet(workbook, '重复编码', flattenedDuplicateRows) appendSheet(workbook, '待补使用人', ownerManualRows) appendSheet(workbook, '花名核对', ownerAliasSummaryRows) appendSheet(workbook, '待补地址', nullLocationRows) const finalOutputPath = writeWorkbookWithFallback(workbook, outputPath) console.log( JSON.stringify( { requestedOutputPath: outputPath, outputPath: finalOutputPath, summary: { pendingReview: pendingRows.length, missingCode: missingCode.excludedMissingCodeCount || 0, duplicateCodeCount: duplicateCodes.excludedDuplicateCodeCount || 0, duplicateRecordCount: duplicateCodes.excludedDuplicateRecordCount || 0, ownerManual: ownerManualRows.length, ownerAliasSummary: ownerAliasSummaryRows.length, nullLocation: nullLocationRows.length } }, null, 2 ) ) } if (require.main === module) { try { run() } catch (error) { console.error(error && error.stack ? error.stack : String(error)) process.exit(1) } } module.exports = { parseArgs, resolveRemoteConnection }