const fs = require('fs') const path = require('path') const { spawnSync } = require('child_process') const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) const { mergeCandidateRowsWithSourceCodes, normalizeLookupKey, normalizeText, resolveSheet2LocationMatch } = require('./sheet2PublicSupplementUtils') const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx') const DEFAULT_SOURCE_SHEET = 'Sheet2' const DEFAULT_CANDIDATE_WORKBOOK = path.resolve( __dirname, '../../docs/总表待导入-Sheet2_严格可安全补导候选-2026-03-27.xlsx' ) const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const DOCS_ROOT = path.resolve(__dirname, '../../docs') const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx' const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node' const DEPARTMENT_ALIASES = { '3D视觉(广东)': '视觉中心', '3D视觉(常州)': '视觉中心', 事业一部: '运营中心A', 事业二部: '运营中心A', 事业三部: '运营中心A', 事业四部: '运营中心A', 策划一部: '品牌策划中心', 策划二部: '品牌策划中心', 策划三部: '品牌策划中心', 策划四部: '品牌策划中心', 策划五部: '品牌策划中心', 策划六部: '品牌策划中心', 客服二部: '客服中心', 客服三部: '客服中心', 客服四部: '客服中心', 客服五部: '客服中心', 客服六部: '客服中心', 品牌营销策划中心: '品牌策划中心', 品牌创意视觉中心: '视觉中心', 品牌供应链中心: '供应链中心', 智管信息中心: '效率部(懂业务的技术部)', 总账组: '财务中心', 结算组: '财务中心', 视觉一部: '视觉中心', 视觉二部: '视觉中心', 视觉三部: '视觉中心', 视觉四部: '视觉中心', 视觉五部: '视觉中心', 视觉六部: '视觉中心', '基本盘 采购部': '供应链中心', '基本盘 核价组': '供应链中心', 产品视觉中心: '视觉中心', 分销外贸: '外贸部' } const PUBLIC_STATUS_MAP = { 在用: '可用', 闲置: '可用' } const buildTimestampLabel = () => new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) 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 = { sourceWorkbook: DEFAULT_SOURCE_WORKBOOK, sourceSheet: DEFAULT_SOURCE_SHEET, candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK, outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME), assetCodes: [], plinkPath: '', sshHost: '', sshHostkey: '', sshPassword: '' } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--source-workbook') { options.sourceWorkbook = requireArgValue(args, index, arg) index += 1 } else if (arg === '--source-sheet') { options.sourceSheet = requireArgValue(args, index, arg) index += 1 } else if (arg === '--candidate-workbook') { options.candidateWorkbook = requireArgValue(args, index, arg) index += 1 } else if (arg === '--output-workbook') { options.outputWorkbook = requireArgValue(args, index, arg) index += 1 } else if (arg === '--asset-codes') { options.assetCodes = String(requireArgValue(args, index, arg)) .split(/[,\s]+/) .map((item) => normalizeText(item).toUpperCase()) .filter(Boolean) 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 resolvePathArg = (value) => { const normalized = normalizeText(value) if (!normalized) { return '' } return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized) } const ensureOutputDir = () => { const timestamp = buildTimestampLabel() const outputDir = path.join(OUTPUT_ROOT, `sheet2_public_dry_run_${timestamp}`) fs.mkdirSync(outputDir, { recursive: true }) return outputDir } const writeJson = (filePath, value) => { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) } const writeWorkbook = async (filePath, sheets) => { const workbook = new ExcelJS.Workbook() workbook.creator = 'Codex' workbook.created = new Date() for (const sheet of sheets) { const worksheet = workbook.addWorksheet(sheet.name) const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] worksheet.columns = headers.map((header) => ({ header, key: header, width: Math.min(Math.max(header.length + 6, 14), 40) })) rows.forEach((row) => worksheet.addRow(row)) worksheet.getRow(1).font = { bold: true, name: 'Arial' } worksheet.views = [{ state: 'frozen', ySplit: 1 }] worksheet.eachRow((row) => { row.font = { name: 'Arial' } row.alignment = { vertical: 'middle', wrapText: true } }) } await workbook.xlsx.writeFile(filePath) } const resolveAvailableWorkbookPath = (preferredPath) => { const resolvedPreferredPath = resolvePathArg(preferredPath) if (!resolvedPreferredPath) { throw new Error('输出工作簿路径不能为空') } const workbookDir = path.dirname(resolvedPreferredPath) const extension = path.extname(resolvedPreferredPath) || '.xlsx' const baseName = path.basename(resolvedPreferredPath, extension) const timestampedPath = path.join(workbookDir, `${baseName}-${buildTimestampLabel()}${extension}`) try { const fd = fs.openSync(resolvedPreferredPath, 'a') fs.closeSync(fd) return resolvedPreferredPath } catch (error) { if (error && (error.code === 'EBUSY' || error.code === 'EPERM')) { return timestampedPath } throw error } } const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => { const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false }) const sheet = workbook.Sheets[sourceSheetName] if (!sheet) { throw new Error(`未找到源工作表: ${sourceSheetName}`) } const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' }) if (!matrix.length) { return new Map() } const headerIndex = new Map() matrix[0].forEach((header, index) => { headerIndex.set(normalizeText(header), index) }) const codeColumnIndex = headerIndex.get('资产编码') if (codeColumnIndex === undefined) { throw new Error('源工作表缺少“资产编码”列') } const rowMap = new Map() for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) { const row = matrix[rowIndex] const code = normalizeText(row[codeColumnIndex]).toUpperCase() if (code && !rowMap.has(code)) { rowMap.set(code, rowIndex + 1) } } return rowMap } const loadSourceRows = (sourceWorkbookPath, sourceSheetName) => { const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false }) const sheet = workbook.Sheets[sourceSheetName] if (!sheet) { throw new Error(`未找到源工作表: ${sourceSheetName}`) } return XLSX.utils.sheet_to_json(sheet, { defval: '' }).map((row) => ({ ...row, 来源工作表: normalizeText(row.来源工作表) || sourceSheetName })) } const loadCandidateRows = (candidateWorkbookPath) => { const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false }) const sheet = workbook.Sheets[workbook.SheetNames[0]] return XLSX.utils.sheet_to_json(sheet, { defval: '' }) } const fetchLiveRefs = (connection) => { const script = ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() ;(async () => { const [assetTypes, departments, locations] = await Promise.all([ prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true, short_name: true }, orderBy: { name: 'asc' } }), prisma.department.findMany({ where: { status: 'active', source_system: 'ding_hr_business' }, select: { id: true, name: true }, orderBy: { name: 'asc' } }), prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true }, orderBy: { name: 'asc' } }) ]) console.log(JSON.stringify({ assetTypes, departments, locations })) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` 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 buildImportDescription = (row, sourceRow) => { const parts = [ ['source_workbook', 'docs/总表待导入.xlsx'], ['source_sheet', 'Sheet2'], ['source_row', sourceRow || ''], ['source_status', normalizeText(row['当前状态'])], ['raw_department', normalizeText(row['部门'])], ['raw_location', normalizeText(row['地址'])], ['raw_asset_category', normalizeText(row['资产分类'])] ] return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ') } const buildReadyImportPayload = (rows) => rows.map((row) => ({ legacyCode: row.资产编码, equipmentName: row.资产名称, equipmentModel: row.规格型号 || null, equipmentType: row.资产名称, departmentName: row.系统归并部门 || '', storageLocationName: row.系统归并地址 || '', systemStatus: row.系统建议状态, purchaseDate: null, supplier: '', unitPrice: 0, description: row.导入备注 })) const createRowContext = (liveRefs) => { const departmentByName = new Map( liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item]) ) const locationByName = new Map( liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item]) ) return { departmentByName, locationByName } } const classifySingleRow = (rawRow, sourceRowMap, rowContext) => { const { departmentByName, locationByName } = rowContext const assetCode = normalizeText(rawRow['资产编码']).toUpperCase() const assetName = normalizeText(rawRow['资产名称']) const rawDepartment = normalizeText(rawRow['部门']) const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment const departmentMatch = resolvedDepartment ? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null : null const rawLocation = normalizeText(rawRow['地址']) const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName) const rawStatus = normalizeText(rawRow['当前状态']) const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || '' const sourceRow = sourceRowMap.get(assetCode) || '' const baseRow = { 来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2', 源数据行号: sourceRow, 资产编码: assetCode, 资产名称: assetName, 当前状态: rawStatus, 系统建议状态: systemStatus, 资产分类: normalizeText(rawRow['资产分类']), 部门: rawDepartment, 系统归并部门: departmentMatch?.name || resolvedDepartment, 地址: rawLocation, 系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue, 地址归并规则: locationMatch.rule || '', 规格型号: normalizeText(rawRow['规格型号']), 导入备注: buildImportDescription(rawRow, sourceRow) } const reasons = [] if (!departmentMatch) { reasons.push(`部门未匹配:${rawDepartment || '(空)'}`) } if (!locationMatch.match) { reasons.push(`地址未匹配:${rawLocation || '(空)'}`) } if (!systemStatus) { reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`) } return { baseRow, reasons } } const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { const rowContext = createRowContext(liveRefs) const readyRows = [] const pendingRows = [] for (const rawRow of candidateRows) { const { baseRow, reasons } = classifySingleRow(rawRow, sourceRowMap, rowContext) if (!reasons.length) { readyRows.push(baseRow) continue } pendingRows.push({ ...baseRow, 待核对原因: reasons.join(';'), 建议处理: reasons.some((item) => item.startsWith('地址未匹配')) ? '先确认应归并到哪个系统地址,再导入' : '先确认状态或部门后再导入' }) } return { readyRows, pendingRows } } const buildDuplicatePendingRows = (duplicateSourceCodes, sourceRowMap, liveRefs) => { const rowContext = createRowContext(liveRefs) return duplicateSourceCodes.map((item) => { const firstRow = item.rows[0] || {} const { baseRow } = classifySingleRow(firstRow, sourceRowMap, rowContext) return { ...baseRow, 待核对原因: '同一资产编码在 Sheet2 内对应了不同设备信息,不能自动判断。', 建议处理: '同一编码对应了多条不同设备信息,先确认哪条才是真实记录' } }) } const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs, explicitCodeCount, explicitMissingCodeCount, duplicatePendingCount }) => [ { 项目: '候选总数', 说明: total }, { 项目: '可直接补录', 说明: ready }, { 项目: '待人工核对', 说明: pending }, { 项目: '其中地址待核对', 说明: addressPending }, { 项目: '其中其他待核对', 说明: otherPending }, { 项目: '本次显式补回编码数', 说明: explicitCodeCount }, { 项目: '显式编码仍未在源表找到', 说明: explicitMissingCodeCount }, { 项目: '显式编码重复冲突数', 说明: duplicatePendingCount }, { 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' }, { 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' }, { 项目: '线上部门数', 说明: liveRefs.departments.length }, { 项目: '线上地址数', 说明: liveRefs.locations.length }, { 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length } ] const run = async () => { const options = parseArgs() const connection = resolveRemoteConnection(options) const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook) const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook) if (!fs.existsSync(sourceWorkbookPath)) { throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`) } if (!fs.existsSync(candidateWorkbookPath) && options.assetCodes.length === 0) { throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`) } const outputDir = ensureOutputDir() const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook) const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet) const sourceRows = loadSourceRows(sourceWorkbookPath, options.sourceSheet) const baseCandidateRows = fs.existsSync(candidateWorkbookPath) ? loadCandidateRows(candidateWorkbookPath) : [] const { rows: candidateRows, missingCodes: missingSourceCodes, duplicateSourceCodes } = mergeCandidateRowsWithSourceCodes(baseCandidateRows, sourceRows, options.assetCodes) const liveRefs = fetchLiveRefs(connection) const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs) const duplicatePendingRows = buildDuplicatePendingRows(duplicateSourceCodes, sourceRowMap, liveRefs) const allPendingRows = [...pendingRows, ...duplicatePendingRows] const addressPendingRows = allPendingRows.filter((row) => row.待核对原因.includes('地址未匹配')) const otherPendingRows = allPendingRows.filter((row) => !row.待核对原因.includes('地址未匹配')) const readyPayload = buildReadyImportPayload(readyRows) const manifest = { generatedAt: new Date().toISOString(), sourceWorkbook: sourceWorkbookPath, sourceSheet: options.sourceSheet, candidateWorkbook: candidateWorkbookPath, explicitAssetCodes: options.assetCodes, explicitMissingCodes: missingSourceCodes, duplicateSourceCodes: duplicateSourceCodes.map((item) => item.code), totalCandidates: candidateRows.length + duplicatePendingRows.length, readyCount: readyRows.length, pendingCount: allPendingRows.length, addressPendingCount: addressPendingRows.length, otherPendingCount: otherPendingRows.length, rulesVersion: 'sheet2-public-supplement-v1', assumptions: [ '当前使用员工为空的记录按公共设备处理', '在用/闲置统一映射为系统可用', '地址不能 100% 确认时不自动导入' ] } await writeWorkbook(workbookPath, [ { name: '汇总说明', rows: buildSummaryRows({ total: candidateRows.length + duplicatePendingRows.length, ready: readyRows.length, pending: allPendingRows.length, addressPending: addressPendingRows.length, otherPending: otherPendingRows.length, liveRefs, explicitCodeCount: options.assetCodes.length, explicitMissingCodeCount: missingSourceCodes.length, duplicatePendingCount: duplicatePendingRows.length }) }, { name: '可直接补录', rows: readyRows }, { name: '待人工核对', rows: allPendingRows }, { name: '地址待核对', rows: addressPendingRows }, { name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] } ]) writeJson(path.join(outputDir, 'sheet2_public_manifest.json'), manifest) writeJson(path.join(outputDir, 'sheet2_public_ready_payload.json'), { payloadCount: readyPayload.length, payload: readyPayload }) writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), { pendingCount: allPendingRows.length, rows: allPendingRows }) writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs) console.log( JSON.stringify( { outputDir, workbookPath, manifest }, null, 2 ) ) } if (require.main === module) { run().catch((error) => { console.error(error && error.stack ? error.stack : String(error)) process.exit(1) }) } module.exports = { parseArgs, resolveRemoteConnection }