const fs = require('fs') const path = require('path') const ExcelJS = require('exceljs') const SOURCE_PATH = path.resolve( __dirname, '../../temp_migration_output/multi_unit_child_repair_2026-03-24_01-16-47/manual_review_candidates.csv' ) const OUTPUT_PATH = path.resolve( __dirname, '../../行政手动处理清单-最终仅剩8条-2026-03-24.xlsx' ) const OBSOLETE_FILES = [ path.resolve(__dirname, '../../父子资产补建-行政手动处理清单-2026-03-23.xlsx'), path.resolve(__dirname, '../../行政手动补录清单-2026-03-23_当前待人工处理.xlsx'), path.resolve(__dirname, '../../子资产多件拆分-行政手动处理清单-2026-03-23.xlsx') ] const REASON_MAP = { outer_capacity_not_equal_to_inner_times_quantity: '表格总容量与单条容量乘数量对不上,系统不能在不猜测的前提下自动拆分。', child_type_and_model_are_inconsistent: '子资产类型与型号内容不一致,系统不能自动处理。', pattern_detected_but_no_safe_split_rule: '识别到多件格式,但现有信息不足以安全拆分。', storage_plus_pattern_is_not_single_capacity_pair: '硬盘型号中混有容量和具体型号,不能只按容量安全拆分。' } const SPECIAL_REASON_OVERRIDES = { 'JS-PC-2127': '原表错行:该行硬件字段整体错位,不能按当前行内容自动落库,需行政按原始资料人工核对后录入。' } const normalize = (value) => String(value == null ? '' : value) const extractMeta = (description, key) => { const text = normalize(description) const matched = text.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`)) return matched ? matched[1].trim() : '' } async function readCsvRows(csvPath) { const workbook = new ExcelJS.Workbook() const sheet = await workbook.csv.readFile(csvPath) const headers = sheet.getRow(1).values.slice(1).map((item) => normalize(item)) const rows = [] for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { const row = sheet.getRow(rowNumber) if (row.cellCount === 0) { continue } const record = {} headers.forEach((header, index) => { record[header] = normalize(row.getCell(index + 1).text) }) if (Object.values(record).some((value) => value !== '')) { rows.push(record) } } return { headers, rows } } function styleHeaderRow(row) { row.font = { bold: true } row.alignment = { vertical: 'middle', horizontal: 'center' } } function resolveReasonDescription(row) { return SPECIAL_REASON_OVERRIDES[row.parent_code] || REASON_MAP[row.reason] || row.reason } function buildTimestampedOutputPath() { const now = new Date() const pad = (value) => String(value).padStart(2, '0') const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}` return path.resolve(__dirname, `../../行政手动处理清单-最终仅剩8条-${stamp}.xlsx`) } async function writeWorkbookWithFallback(workbook) { try { await workbook.xlsx.writeFile(OUTPUT_PATH) return OUTPUT_PATH } catch (error) { if (error && error.code === 'EBUSY') { const fallbackPath = buildTimestampedOutputPath() await workbook.xlsx.writeFile(fallbackPath) return fallbackPath } throw error } } async function exportWorkbook() { const { headers, rows } = await readCsvRows(SOURCE_PATH) const workbook = new ExcelJS.Workbook() workbook.creator = 'Codex' workbook.created = new Date() const taskSheet = workbook.addWorksheet('人工处理清单') taskSheet.columns = [ { header: '源表行号', key: 'source_row', width: 10 }, { header: '父资产编码', key: 'parent_code', width: 16 }, { header: '父资产名称', key: 'parent_name', width: 12 }, { header: '配件字段', key: 'field_key', width: 12 }, { header: '子资产类型', key: 'child_type', width: 12 }, { header: '系统当前子资产编码', key: 'current_child_code', width: 18 }, { header: '表格原始型号', key: 'source_child_model', width: 42 }, { header: '表格部门', key: 'workbook_department', width: 18 }, { header: '表格使用人', key: 'workbook_owner', width: 12 }, { header: '表格位置', key: 'workbook_location', width: 18 }, { header: '原因代码', key: 'reason', width: 44 }, { header: '人工处理原因', key: 'reason_desc', width: 50 } ] taskSheet.views = [{ state: 'frozen', ySplit: 1 }] taskSheet.autoFilter = 'A1:L1' styleHeaderRow(taskSheet.getRow(1)) rows.forEach((row) => { taskSheet.addRow({ source_row: row.source_row, parent_code: row.parent_code, parent_name: row.parent_name, field_key: row.field_key, child_type: row.child_type, current_child_code: row.current_child_code, source_child_model: row.source_child_model, workbook_department: extractMeta(row.planned_description, 'workbook_department'), workbook_owner: extractMeta(row.planned_description, 'workbook_owner'), workbook_location: extractMeta(row.planned_description, 'workbook_location'), reason: row.reason, reason_desc: resolveReasonDescription(row) }) }) taskSheet.eachRow((row, rowNumber) => { row.alignment = { vertical: 'top', wrapText: true } if (rowNumber > 1) { row.height = 22 } }) const rawSheet = workbook.addWorksheet('原始数据') rawSheet.columns = headers.map((header) => ({ header, key: header, width: Math.max(14, header.length + 2) })) rawSheet.views = [{ state: 'frozen', ySplit: 1 }] styleHeaderRow(rawSheet.getRow(1)) rows.forEach((row) => rawSheet.addRow(row)) rawSheet.eachRow((row) => { row.alignment = { vertical: 'top', wrapText: true } }) const actualOutputPath = await writeWorkbookWithFallback(workbook) const verifyBook = new ExcelJS.Workbook() await verifyBook.xlsx.readFile(actualOutputPath) const verifyRawSheet = verifyBook.getWorksheet('原始数据') const verifyTaskSheet = verifyBook.getWorksheet('人工处理清单') const verifiedRows = [] for (let rowNumber = 2; rowNumber <= verifyRawSheet.rowCount; rowNumber += 1) { const row = verifyRawSheet.getRow(rowNumber) if (row.cellCount === 0) { continue } const record = {} headers.forEach((header, index) => { record[header] = normalize(row.getCell(index + 1).text) }) if (Object.values(record).some((value) => value !== '')) { verifiedRows.push(record) } } if (verifiedRows.length !== rows.length) { throw new Error(`校验失败:原始数据行数不一致,source=${rows.length}, xlsx=${verifiedRows.length}`) } rows.forEach((row, index) => { headers.forEach((header) => { if (normalize(verifiedRows[index][header]) !== normalize(row[header])) { throw new Error(`校验失败:第 ${index + 1} 行字段 ${header} 不一致`) } }) }) const taskRowCount = Math.max(verifyTaskSheet.rowCount - 1, 0) if (taskRowCount !== rows.length) { throw new Error(`校验失败:人工处理清单行数不一致,source=${rows.length}, xlsx=${taskRowCount}`) } const removedFiles = [] const skippedFiles = [] for (const filePath of OBSOLETE_FILES) { if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath) removedFiles.push(filePath) } catch (error) { skippedFiles.push({ filePath, code: error && error.code ? error.code : 'UNKNOWN' }) } } } return { outputPath: actualOutputPath, rowCount: rows.length, removedFiles, skippedFiles } } exportWorkbook() .then((result) => { console.log(JSON.stringify({ verified: true, ...result }, null, 2)) }) .catch((error) => { console.error(error) process.exit(1) })