const fs = require('fs') const path = require('path') const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) const { expandHardwareModels } = require('./hardwareQuantitySplit') const WORKBOOK_PATH = path.resolve(__dirname, '../../8.xlsx') const SHEET_NAME = '📜固定资产明细' const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const HEADER_MAP = { assetCode: '资产编码', currentStatus: '当前状态', assetName: '资产名称', assetCategory: '资产分类', department: '部门', owner: '当前使用员工', address: '地址', cpu: 'cpu', motherboard: '主板', memory: '内存', ssd: '固态硬盘', hdd: '机械硬盘', gpu: '显卡', parentRecord: '父记录' } const CHILD_FIELD_SPECS = [ { fieldKey: 'cpu', childType: 'cpu', childName: 'CPU' }, { fieldKey: 'motherboard', childType: '主板', childName: '主板' }, { fieldKey: 'memory', childType: '内存条', childName: '内存条' }, { fieldKey: 'ssd', childType: '固态硬盘', childName: '固态硬盘' }, { fieldKey: 'hdd', childType: '机械硬盘', childName: '机械硬盘' }, { fieldKey: 'gpu', childType: '显卡', childName: '显卡' } ] const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中']) const ASSET_TYPE_ALIASES = { 笔记本电脑: '笔记本' } const parseArgs = () => { const args = process.argv.slice(2) const options = { workbookPath: WORKBOOK_PATH, equipmentJsonPath: '', assetTypesJsonPath: '', apiBase: '', username: '', password: '' } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--workbook') { options.workbookPath = args[index + 1] || WORKBOOK_PATH index += 1 } else if (arg === '--equipment-json') { options.equipmentJsonPath = args[index + 1] || '' index += 1 } else if (arg === '--asset-types-json') { options.assetTypesJsonPath = args[index + 1] || '' index += 1 } else if (arg === '--api-base') { options.apiBase = args[index + 1] || '' index += 1 } else if (arg === '--username') { options.username = args[index + 1] || '' index += 1 } else if (arg === '--password') { options.password = args[index + 1] || '' index += 1 } } return options } const normalizeText = (value) => String(value == null ? '' : value) .replace(/\r/g, ' ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() const ensureOutputDir = () => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) const outputDir = path.join(OUTPUT_ROOT, `parent_child_dry_run_${timestamp}`) fs.mkdirSync(outputDir, { recursive: true }) return outputDir } const resolvePathArg = (value, fallbackPath = '') => { const target = normalizeText(value || fallbackPath) if (!target) { return '' } return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) } const findLatestJsonByPrefix = (prefix) => { if (!fs.existsSync(OUTPUT_ROOT)) { return '' } const matches = fs .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.json')) .map((entry) => entry.name) .sort() if (!matches.length) { return '' } return path.join(OUTPUT_ROOT, matches[matches.length - 1]) } const writeJson = (filePath, value) => { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) } const escapeCsv = (value) => { const stringValue = String(value == null ? '' : value) if (/[,"\n]/.test(stringValue)) { return `"${stringValue.replace(/"/g, '""')}"` } return stringValue } const writeCsv = (filePath, headers, rows) => { const lines = [headers.map(escapeCsv).join(',')] for (const row of rows) { lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(',')) } fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`) } const writeWorkbook = async (filePath, sheets) => { const workbook = new ExcelJS.Workbook() workbook.creator = 'Codex' workbook.created = new Date() for (const sheet of sheets) { const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] const worksheet = workbook.addWorksheet(sheet.name) worksheet.columns = headers.map((header) => ({ header, key: header, width: Math.min(Math.max(header.length + 4, 16), 40) })) rows.forEach((row) => worksheet.addRow(row)) worksheet.getRow(1).font = { bold: true } worksheet.views = [{ state: 'frozen', ySplit: 1 }] } await workbook.xlsx.writeFile(filePath) } const normalizeAssetTypeName = (value) => { const normalized = normalizeText(value) return ASSET_TYPE_ALIASES[normalized] || normalized } const loadWorkbookRows = (workbookPath) => { const workbook = XLSX.readFile(workbookPath, { raw: false }) const worksheet = workbook.Sheets[SHEET_NAME] if (!worksheet) { throw new Error(`未找到工作表: ${SHEET_NAME}`) } const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, raw: false, defval: '' }) if (!rows.length) { return [] } const headerIndex = {} rows[0].forEach((header, index) => { headerIndex[normalizeText(header)] = index }) const records = [] for (let index = 1; index < rows.length; index += 1) { const row = rows[index] const sourceRow = index + 1 const record = { sourceRow, assetCodeRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCode]]), currentStatusRaw: normalizeText(row[headerIndex[HEADER_MAP.currentStatus]]), assetNameRaw: normalizeText(row[headerIndex[HEADER_MAP.assetName]]), assetCategoryRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCategory]]), departmentRaw: normalizeText(row[headerIndex[HEADER_MAP.department]]), ownerRaw: normalizeText(row[headerIndex[HEADER_MAP.owner]]), addressRaw: normalizeText(row[headerIndex[HEADER_MAP.address]]), cpuRaw: normalizeText(row[headerIndex[HEADER_MAP.cpu]]), motherboardRaw: normalizeText(row[headerIndex[HEADER_MAP.motherboard]]), memoryRaw: normalizeText(row[headerIndex[HEADER_MAP.memory]]), ssdRaw: normalizeText(row[headerIndex[HEADER_MAP.ssd]]), hddRaw: normalizeText(row[headerIndex[HEADER_MAP.hdd]]), gpuRaw: normalizeText(row[headerIndex[HEADER_MAP.gpu]]), parentRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.parentRecord]]) } const hasAnyHardware = CHILD_FIELD_SPECS.some((item) => normalizeText(record[`${item.fieldKey}Raw`])) if (!hasAnyHardware) { continue } records.push(record) } return records } const readJsonFile = (filePath) => { const raw = fs.readFileSync(filePath, 'utf8') return JSON.parse(raw) } const unwrapListPayload = (payload) => { if (Array.isArray(payload)) { return payload } if (payload && Array.isArray(payload.list)) { return payload.list } if (payload?.data && Array.isArray(payload.data.list)) { return payload.data.list } throw new Error('JSON 结构不符合预期,未找到 list') } const fetchJson = async (url, options = {}) => { const response = await fetch(url, options) const text = await response.text() if (!response.ok) { throw new Error(`请求失败: ${url} status=${response.status} body=${text}`) } return { response, data: JSON.parse(text) } } const buildCookieHeader = (response) => { if (typeof response.headers.getSetCookie === 'function') { return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ') } const setCookie = response.headers.get('set-cookie') return setCookie ? setCookie.split(';')[0] : '' } const fetchLiveData = async (options, outputDir) => { const apiBase = normalizeText(options.apiBase) if (!apiBase || !normalizeText(options.username) || !normalizeText(options.password)) { throw new Error('缺少 live API 登录参数') } const loginResult = await fetchJson(`${apiBase}/auth/local/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: options.username, password: options.password }) }) const cookieHeader = buildCookieHeader(loginResult.response) if (!cookieHeader) { throw new Error('登录成功但未获取到 session cookie') } const commonHeaders = { Cookie: cookieHeader } const [equipmentResult, assetTypeResult] = await Promise.all([ fetchJson(`${apiBase}/equipment/list?page=1&pageSize=5000`, { headers: commonHeaders }), fetchJson(`${apiBase}/asset-types/list?page=1&pageSize=200`, { headers: commonHeaders }) ]) const equipmentJsonPath = path.join(outputDir, 'live_equipment_source.json') const assetTypesJsonPath = path.join(outputDir, 'live_asset_types_source.json') writeJson(equipmentJsonPath, equipmentResult.data) writeJson(assetTypesJsonPath, assetTypeResult.data) return { equipmentList: unwrapListPayload(equipmentResult.data), assetTypes: unwrapListPayload(assetTypeResult.data), source: { mode: 'live-api', apiBase, equipmentJsonPath, assetTypesJsonPath } } } const loadLiveData = async (options, outputDir) => { if (normalizeText(options.apiBase)) { return fetchLiveData(options, outputDir) } const equipmentJsonPath = resolvePathArg( options.equipmentJsonPath, findLatestJsonByPrefix('live_equipment_list_parent_child_dry_run_') ) const assetTypesJsonPath = resolvePathArg( options.assetTypesJsonPath, findLatestJsonByPrefix('live_asset_types_parent_child_dry_run_') ) if (!equipmentJsonPath || !fs.existsSync(equipmentJsonPath)) { throw new Error('未找到 live 设备快照,请传入 --equipment-json 或使用 --api-base 登录拉取') } if (!assetTypesJsonPath || !fs.existsSync(assetTypesJsonPath)) { throw new Error('未找到 live 资产类型快照,请传入 --asset-types-json 或使用 --api-base 登录拉取') } return { equipmentList: unwrapListPayload(readJsonFile(equipmentJsonPath)), assetTypes: unwrapListPayload(readJsonFile(assetTypesJsonPath)), source: { mode: 'snapshot', equipmentJsonPath, assetTypesJsonPath } } } const buildReferenceMaps = (equipmentList, assetTypes) => { const liveByCode = new Map() for (const item of equipmentList) { const code = normalizeLookupKey(item.equipment_code) if (code) { liveByCode.set(code, item) } } const assetTypeByName = new Map() for (const item of assetTypes) { assetTypeByName.set(normalizeLookupKey(item.name), item) } return { liveByCode, assetTypeByName } } const buildDuplicateCodeSet = (rows) => { const counts = new Map() for (const row of rows) { const code = normalizeLookupKey(row.assetCodeRaw) if (!code) { continue } counts.set(code, (counts.get(code) || 0) + 1) } return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([code]) => code)) } const normalizeChildModel = (value) => normalizeText(value) const buildExistingChildKey = (child) => `${normalizeLookupKey(child.equipment_type)}||${normalizeLookupKey(child.equipment_model)}` const pushCount = (counter, key, increment = 1) => { counter[key] = (counter[key] || 0) + increment } const analyzeRows = (workbookRows, refs) => { const duplicateCodes = buildDuplicateCodeSet(workbookRows) const plannedParents = [] const plannedChildren = [] const skippedRows = [] const childSkips = [] const rowSkipReasonCounts = {} const childSkipReasonCounts = {} const plannedChildTypeCounts = {} const plannedParentStatusCounts = {} for (const row of workbookRows) { const workbookCode = normalizeLookupKey(row.assetCodeRaw) const workbookAssetType = normalizeAssetTypeName(row.assetNameRaw) const hardwareValues = [] const manualSplitChildren = [] for (const spec of CHILD_FIELD_SPECS) { const sourceModel = normalizeChildModel(row[`${spec.fieldKey}Raw`]) if (!sourceModel) { continue } const expanded = expandHardwareModels(spec.childType, sourceModel) if (expanded.isMultiUnit && !expanded.isAutoSplittable) { manualSplitChildren.push({ spec, sourceModel, reason: expanded.reason || 'child_model_requires_manual_split', splitPattern: expanded.pattern || '' }) continue } if (!expanded.models.length) { continue } expanded.models.forEach((model, index) => { hardwareValues.push({ spec, model, sourceModel, splitIndex: expanded.isMultiUnit ? index + 1 : 1, splitQuantity: expanded.isMultiUnit ? expanded.quantity : 1, splitPattern: expanded.pattern || 'single_unit' }) }) } const rowContext = { source_row: row.sourceRow, asset_code: row.assetCodeRaw, asset_name: row.assetNameRaw, mapped_asset_type: workbookAssetType, current_status_raw: row.currentStatusRaw, department: row.departmentRaw, owner: row.ownerRaw, address: row.addressRaw, hardware_fields: hardwareValues.map((item) => `${item.spec.childName}:${item.model}`).join(' | ') } const skipRow = (reason, extra = {}) => { pushCount(rowSkipReasonCounts, reason) skippedRows.push({ ...rowContext, reason, ...extra }) } if (!workbookCode) { skipRow('missing_workbook_asset_code') continue } if (duplicateCodes.has(workbookCode)) { skipRow('duplicate_workbook_asset_code') continue } const workbookTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(workbookAssetType)) if (!workbookTypeConfig) { skipRow('workbook_asset_type_not_found_in_live', { live_type_name: '' }) continue } if (!workbookTypeConfig.can_be_parent) { skipRow('workbook_asset_type_not_parent_capable', { live_type_name: workbookTypeConfig.name }) continue } const liveParent = refs.liveByCode.get(workbookCode) if (!liveParent) { skipRow('live_parent_not_found', { live_type_name: '' }) continue } if (normalizeLookupKey(liveParent.equipment_type) !== normalizeLookupKey(workbookTypeConfig.name)) { skipRow('live_parent_type_mismatch', { live_type_name: liveParent.equipment_type }) continue } const liveTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(liveParent.equipment_type)) if (!liveTypeConfig || !liveTypeConfig.can_be_parent) { skipRow('live_parent_type_not_parent_capable', { live_type_name: liveParent.equipment_type }) continue } if (liveParent.parent_id != null) { skipRow('live_parent_is_already_child', { live_type_name: liveParent.equipment_type }) continue } if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(liveParent.status))) { skipRow('live_parent_status_not_buildable', { live_type_name: liveParent.equipment_type, live_parent_status: liveParent.status }) continue } const existingChildren = Array.isArray(liveParent.children) ? liveParent.children : [] const existingChildQueues = new Map() for (const child of existingChildren) { const key = buildExistingChildKey(child) const queue = existingChildQueues.get(key) || [] queue.push(child) existingChildQueues.set(key, queue) } const rowPlannedChildren = [] for (const manualItem of manualSplitChildren) { pushCount(childSkipReasonCounts, manualItem.reason) childSkips.push({ ...rowContext, child_type: manualItem.spec.childType, child_name: manualItem.spec.childName, child_model: manualItem.sourceModel, source_child_model: manualItem.sourceModel, split_index: '', split_quantity: '', split_pattern: manualItem.splitPattern, reason: manualItem.reason }) } for (const { spec, model, sourceModel, splitIndex, splitQuantity, splitPattern } of hardwareValues) { const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(spec.childType)) if (!childTypeConfig || !childTypeConfig.can_be_child) { pushCount(childSkipReasonCounts, 'child_asset_type_not_child_capable') childSkips.push({ ...rowContext, child_type: spec.childType, child_name: spec.childName, child_model: model, source_child_model: sourceModel, split_index: splitIndex, split_quantity: splitQuantity, split_pattern: splitPattern, reason: 'child_asset_type_not_child_capable' }) continue } const existingKey = `${normalizeLookupKey(spec.childType)}||${normalizeLookupKey(model)}` const existingQueue = existingChildQueues.get(existingKey) || [] if (existingQueue.length > 0) { existingQueue.shift() existingChildQueues.set(existingKey, existingQueue) pushCount(childSkipReasonCounts, 'existing_child_same_type_and_model') childSkips.push({ ...rowContext, child_type: spec.childType, child_name: spec.childName, child_model: model, source_child_model: sourceModel, split_index: splitIndex, split_quantity: splitQuantity, split_pattern: splitPattern, reason: 'existing_child_same_type_and_model' }) continue } const plannedChild = { source_row: row.sourceRow, parent_code: liveParent.equipment_code, parent_id: liveParent.id, parent_name: liveParent.equipment_name, parent_type: liveParent.equipment_type, parent_status: liveParent.status, parent_department: liveParent.department?.name || '', parent_location: liveParent.storageLocation?.name || '', parent_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || '', workbook_department: row.departmentRaw, workbook_owner: row.ownerRaw, workbook_location: row.addressRaw, field_key: spec.fieldKey, child_type: spec.childType, child_name: spec.childName, child_model: model, source_child_model: sourceModel, split_index: splitIndex, split_quantity: splitQuantity, split_pattern: splitPattern, planned_unit_price: 0, planned_status: liveParent.status === '已借出' || liveParent.status === '维护中' ? liveParent.status : '可用', planned_code_generation_mode: 'auto', notes: `根据8.xlsx第${row.sourceRow}行补建` } rowPlannedChildren.push(plannedChild) plannedChildren.push(plannedChild) pushCount(plannedChildTypeCounts, spec.childType) } if (!rowPlannedChildren.length) { skipRow('no_new_children_after_dedup', { live_type_name: liveParent.equipment_type, live_parent_status: liveParent.status }) continue } plannedParents.push({ source_row: row.sourceRow, parent_code: liveParent.equipment_code, parent_id: liveParent.id, parent_name: liveParent.equipment_name, parent_type: liveParent.equipment_type, parent_status: liveParent.status, child_count: rowPlannedChildren.length, child_types: rowPlannedChildren.map((item) => item.child_type).join(' | '), workbook_department: row.departmentRaw, live_department: liveParent.department?.name || '', workbook_location: row.addressRaw, live_location: liveParent.storageLocation?.name || '', workbook_owner: row.ownerRaw, live_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || '' }) pushCount(plannedParentStatusCounts, liveParent.status) } return { plannedParents, plannedChildren, skippedRows, childSkips, rowSkipReasonCounts, childSkipReasonCounts, plannedChildTypeCounts, plannedParentStatusCounts } } const buildSummaryRows = (summary) => Object.entries(summary).map(([key, value]) => ({ key, value: Array.isArray(value) || typeof value === 'object' ? JSON.stringify(value) : value })) const run = async () => { const options = parseArgs() const outputDir = ensureOutputDir() const workbookPath = resolvePathArg(options.workbookPath, WORKBOOK_PATH) if (!fs.existsSync(workbookPath)) { throw new Error(`工作簿不存在: ${workbookPath}`) } const liveData = await loadLiveData(options, outputDir) const workbookRows = loadWorkbookRows(workbookPath) const refs = buildReferenceMaps(liveData.equipmentList, liveData.assetTypes) const analysis = analyzeRows(workbookRows, refs) const summary = { generated_at: new Date().toISOString(), workbook_path: workbookPath, workbook_sheet: SHEET_NAME, live_source_mode: liveData.source.mode, live_equipment_source: liveData.source.equipmentJsonPath || '', live_asset_types_source: liveData.source.assetTypesJsonPath || '', workbook_rows_with_hardware_fields: workbookRows.length, live_total_equipment: liveData.equipmentList.length, live_total_asset_types: liveData.assetTypes.length, planned_parent_count: analysis.plannedParents.length, planned_child_count: analysis.plannedChildren.length, skipped_row_count: analysis.skippedRows.length, skipped_child_count: analysis.childSkips.length, planned_parent_status_counts: analysis.plannedParentStatusCounts, planned_child_type_counts: analysis.plannedChildTypeCounts, skipped_row_reason_counts: analysis.rowSkipReasonCounts, skipped_child_reason_counts: analysis.childSkipReasonCounts, notes: [ 'dry-run 只生成报告,不写入 live', '子资产编码将在正式写入时自动生成,本次不预占编号', '子资产价值按当前业务口径统一规划为 0' ] } const report = { summary, plannedParents: analysis.plannedParents, plannedChildren: analysis.plannedChildren, skippedRows: analysis.skippedRows, skippedChildren: analysis.childSkips } writeJson(path.join(outputDir, 'summary.json'), summary) writeJson(path.join(outputDir, 'report.json'), report) writeCsv( path.join(outputDir, 'planned_parents.csv'), Object.keys(analysis.plannedParents[0] || { parent_code: '', child_count: '' }), analysis.plannedParents ) writeCsv( path.join(outputDir, 'planned_children.csv'), Object.keys(analysis.plannedChildren[0] || { parent_code: '', child_type: '', child_model: '' }), analysis.plannedChildren ) writeCsv( path.join(outputDir, 'skipped_rows.csv'), Object.keys(analysis.skippedRows[0] || { asset_code: '', reason: '' }), analysis.skippedRows ) writeCsv( path.join(outputDir, 'skipped_children.csv'), Object.keys(analysis.childSkips[0] || { parent_code: '', child_type: '', reason: '' }), analysis.childSkips ) await writeWorkbook(path.join(outputDir, 'parent_child_dry_run.xlsx'), [ { name: 'Summary', rows: buildSummaryRows(summary) }, { name: 'PlannedParents', rows: analysis.plannedParents }, { name: 'PlannedChildren', rows: analysis.plannedChildren }, { name: 'SkippedRows', rows: analysis.skippedRows }, { name: 'SkippedChildren', rows: analysis.childSkips } ]) console.log( JSON.stringify( { outputDir, summary }, null, 2 ) ) } run().catch((error) => { console.error(error) process.exit(1) })