const fs = require('fs') const path = require('path') const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) const { resolveStorageLocationMatch } = require('./locationNormalization') const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api' const MIGRATION_MARKER = 'parent_child_excel_20260323' const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中']) const parseArgs = () => { const args = process.argv.slice(2) const options = { mode: 'preflight', planDir: '', apiBase: DEFAULT_API_BASE, username: '', password: '', limit: 0, parentCode: '', sourceRow: 0 } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--mode') { options.mode = args[index + 1] || options.mode index += 1 } else if (arg === '--plan-dir') { options.planDir = args[index + 1] || '' index += 1 } else if (arg === '--api-base') { options.apiBase = args[index + 1] || DEFAULT_API_BASE index += 1 } else if (arg === '--username') { options.username = args[index + 1] || '' index += 1 } else if (arg === '--password') { options.password = args[index + 1] || '' index += 1 } else if (arg === '--limit') { options.limit = Number(args[index + 1] || 0) || 0 index += 1 } else if (arg === '--parent-code') { options.parentCode = args[index + 1] || '' index += 1 } else if (arg === '--source-row') { options.sourceRow = Number(args[index + 1] || 0) || 0 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 normalizeMode = (value) => { const mode = normalizeText(value).toLowerCase() if (mode === 'apply') { return 'apply' } return 'preflight' } const resolvePlanDir = (value) => { if (normalizeText(value)) { const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value) if (!fs.existsSync(resolved)) { throw new Error(`计划目录不存在: ${resolved}`) } return resolved } const candidates = fs .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_dry_run_')) .map((entry) => entry.name) .sort() if (!candidates.length) { throw new Error(`未找到 parent_child_dry_run 输出目录: ${OUTPUT_ROOT}`) } return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) } const ensureOutputDir = (prefix) => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) fs.mkdirSync(outputDir, { recursive: true }) return outputDir } const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) 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 buildSummaryRows = (summary) => Object.entries(summary).map(([key, value]) => ({ key, value: Array.isArray(value) || (value && typeof value === 'object') ? JSON.stringify(value) : value })) 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), 48) })) rows.forEach((row) => worksheet.addRow(row)) worksheet.getRow(1).font = { bold: true } worksheet.views = [{ state: 'frozen', ySplit: 1 }] } await workbook.xlsx.writeFile(filePath) } const loadPlan = (planDir, options) => { const reportPath = path.join(planDir, 'report.json') const summaryPath = path.join(planDir, 'summary.json') if (!fs.existsSync(reportPath)) { throw new Error(`未找到 report.json: ${reportPath}`) } const report = readJsonFile(reportPath) const summary = fs.existsSync(summaryPath) ? readJsonFile(summaryPath) : report.summary || {} let plannedChildren = Array.isArray(report.plannedChildren) ? report.plannedChildren : [] if (normalizeText(options.parentCode)) { const parentCodeKey = normalizeLookupKey(options.parentCode) plannedChildren = plannedChildren.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey) } if (options.sourceRow > 0) { plannedChildren = plannedChildren.filter((item) => Number(item.source_row) === Number(options.sourceRow)) } if (options.limit > 0) { plannedChildren = plannedChildren.slice(0, options.limit) } return { planDir, report, summary, plannedChildren } } const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',') const buildActionKey = (item) => [ normalizeLookupKey(item.parent_code), normalizeLookupKey(item.field_key), Number(item.source_row) || 0, Number(item.split_index) || 1 ].join('||') const buildChildTypeModelKey = (type, model) => `${normalizeLookupKey(type)}||${normalizeLookupKey(model)}` const buildMigrationTokens = (item) => ({ migration_marker: MIGRATION_MARKER, source_row: String(Number(item.source_row) || 0), parent_code: sanitizeTokenValue(item.parent_code), field_key: sanitizeTokenValue(item.field_key), split_index: String(Number(item.split_index) || 1) }) const buildChildDescription = (item) => { const tokens = [ ['migration_marker', MIGRATION_MARKER], ['source_row', Number(item.source_row) || 0], ['parent_code', item.parent_code], ['field_key', item.field_key], ['child_type', item.child_type], ['child_model', item.child_model], ['source_child_model', item.source_child_model || item.child_model], ['split_index', Number(item.split_index) || 1], ['split_quantity', Number(item.split_quantity) || 1], ['split_pattern', item.split_pattern || 'single_unit'], ['parent_name', item.parent_name], ['parent_type', item.parent_type], ['workbook_department', item.workbook_department], ['workbook_owner', item.workbook_owner], ['workbook_location', item.workbook_location] ] return tokens.map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`).join('; ') } const buildMountNote = (item) => Number(item.split_quantity) > 1 ? `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type}第${item.split_index}/${item.split_quantity}件,字段=${item.field_key},原始型号=${sanitizeTokenValue(item.source_child_model || item.child_model)},拆分后型号=${sanitizeTokenValue(item.child_model)}` : `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type},字段=${item.field_key},型号=${sanitizeTokenValue(item.child_model)}` const descriptionMatchesPlan = (description, item) => { const normalizedDescription = normalizeText(description) if (!normalizedDescription) { return false } const tokens = buildMigrationTokens(item) return Object.entries(tokens).every(([key, value]) => normalizedDescription.includes(`${key}=${sanitizeTokenValue(value)}`) ) } 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] : '' } class ApiSession { constructor({ apiBase, username, password }) { this.apiBase = normalizeText(apiBase).replace(/\/$/, '') this.username = normalizeText(username) this.password = String(password == null ? '' : password) this.cookieHeader = '' } async login() { if (!this.apiBase || !this.username || !this.password) { throw new Error('缺少 API 登录参数') } const response = await fetch(`${this.apiBase}/auth/local/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: this.username, password: this.password }) }) const text = await response.text() if (!response.ok) { throw new Error(`登录失败: status=${response.status} body=${text}`) } this.cookieHeader = buildCookieHeader(response) if (!this.cookieHeader) { throw new Error('登录成功但未拿到 session cookie') } return JSON.parse(text) } async requestJson(endpoint, options = {}, retry = true) { if (!this.cookieHeader) { await this.login() } const headers = { ...(options.headers || {}), Cookie: this.cookieHeader } const response = await fetch(`${this.apiBase}${endpoint}`, { ...options, headers }) const text = await response.text() if (response.status === 401 && retry) { await this.login() return this.requestJson(endpoint, options, false) } if (!response.ok) { throw new Error(`请求失败: ${endpoint} status=${response.status} body=${text}`) } return JSON.parse(text) } } 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('接口返回结构不符合预期,未找到 list') } const fetchLiveData = async (session, outputDir, tag) => { const [equipmentPayload, assetTypePayload, storageLocationPayload] = await Promise.all([ session.requestJson('/equipment/list?page=1&pageSize=5000'), session.requestJson('/asset-types/list?page=1&pageSize=200'), session.requestJson('/storage-locations/list?page=1&pageSize=200') ]) const equipmentSourcePath = path.join(outputDir, `live_equipment_${tag}.json`) const assetTypeSourcePath = path.join(outputDir, `live_asset_types_${tag}.json`) const storageLocationSourcePath = path.join(outputDir, `live_storage_locations_${tag}.json`) writeJson(equipmentSourcePath, equipmentPayload) writeJson(assetTypeSourcePath, assetTypePayload) writeJson(storageLocationSourcePath, storageLocationPayload) return { equipmentList: unwrapListPayload(equipmentPayload), assetTypes: unwrapListPayload(assetTypePayload), storageLocations: unwrapListPayload(storageLocationPayload), equipmentSourcePath, assetTypeSourcePath, storageLocationSourcePath } } const buildLiveRefs = (liveData) => { const topLevelByCode = new Map() const topLevelItems = Array.isArray(liveData.equipmentList) ? liveData.equipmentList : [] for (const item of topLevelItems) { const code = normalizeLookupKey(item.equipment_code) if (code) { topLevelByCode.set(code, item) } } const assetTypeByName = new Map() for (const item of liveData.assetTypes || []) { assetTypeByName.set(normalizeLookupKey(item.name), item) } const locationByName = new Map() for (const item of liveData.storageLocations || []) { locationByName.set(normalizeLookupKey(item.name), item) } return { topLevelItems, topLevelByCode, assetTypeByName, locationByName } } const summarizeByReason = (rows, reasonKey = 'reason') => { const counts = {} for (const row of rows) { const reason = normalizeText(row[reasonKey]) || 'unknown' counts[reason] = (counts[reason] || 0) + 1 } return counts } const analyzePlan = (plan, liveData) => { const refs = buildLiveRefs(liveData) const actions = [] const blocked = [] const satisfied = [] const mountedChildQueueCache = new Map() const getMountedChildQueue = (parent, key) => { const cacheKey = `${parent.id}||${key}` if (mountedChildQueueCache.has(cacheKey)) { return mountedChildQueueCache.get(cacheKey) } const queue = (Array.isArray(parent.children) ? parent.children : []).filter( (child) => buildChildTypeModelKey(child.equipment_type, child.equipment_model) === key ) mountedChildQueueCache.set(cacheKey, queue) return queue } for (const item of plan.plannedChildren) { const parentCodeKey = normalizeLookupKey(item.parent_code) const parent = refs.topLevelByCode.get(parentCodeKey) const actionBase = { action_key: buildActionKey(item), source_row: item.source_row, parent_code: item.parent_code, parent_name: item.parent_name, parent_type: item.parent_type, parent_status: item.parent_status, child_type: item.child_type, child_name: item.child_name, child_model: item.child_model, source_child_model: item.source_child_model || item.child_model, field_key: item.field_key, split_index: Number(item.split_index) || 1, split_quantity: Number(item.split_quantity) || 1, split_pattern: item.split_pattern || 'single_unit', notes: item.notes, workbook_department: item.workbook_department, workbook_owner: item.workbook_owner, workbook_location: item.workbook_location, planned_description: buildChildDescription(item), planned_mount_note: buildMountNote(item) } const block = (reason, extra = {}) => { blocked.push({ ...actionBase, reason, ...extra }) } if (!parent) { block('live_parent_not_found') continue } const liveParentType = refs.assetTypeByName.get(normalizeLookupKey(parent.equipment_type)) if (!liveParentType?.can_be_parent) { block('live_parent_type_not_parent_capable', { live_parent_type: parent.equipment_type }) continue } if (parent.parent_id != null) { block('live_parent_is_child', { live_parent_id: parent.parent_id }) continue } if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(parent.status))) { block('live_parent_status_not_buildable', { live_parent_status: parent.status }) continue } const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(item.child_type)) if (!childTypeConfig?.can_be_child) { block('child_asset_type_not_child_capable') continue } const expectedChildKey = buildChildTypeModelKey(item.child_type, item.child_model) const matchingMountedChildQueue = getMountedChildQueue(parent, expectedChildKey) const matchingMountedChild = matchingMountedChildQueue.shift() if (matchingMountedChild) { satisfied.push({ ...actionBase, reason: 'already_mounted_matching_type_model', live_parent_id: parent.id, live_child_id: matchingMountedChild.id, live_child_code: matchingMountedChild.equipment_code, live_child_status: matchingMountedChild.status }) continue } const orphanMatches = refs.topLevelItems.filter((equipment) => descriptionMatchesPlan(equipment.description, item)) if (orphanMatches.length > 1) { block('duplicate_script_created_orphans', { matched_orphan_codes: orphanMatches.map((equipment) => equipment.equipment_code).join(' | ') }) continue } if (orphanMatches.length === 1) { const orphan = orphanMatches[0] if (buildChildTypeModelKey(orphan.equipment_type, orphan.equipment_model) !== expectedChildKey) { block('script_created_orphan_model_mismatch', { matched_orphan_code: orphan.equipment_code, matched_orphan_type: orphan.equipment_type, matched_orphan_model: orphan.equipment_model }) continue } if (orphan.parent_id != null) { block('script_created_orphan_already_mounted_elsewhere', { matched_orphan_code: orphan.equipment_code, matched_orphan_parent_id: orphan.parent_id }) continue } if (normalizeText(orphan.status) !== '可用' || Number(orphan.remaining_quantity) <= 0) { block('script_created_orphan_not_mountable', { matched_orphan_code: orphan.equipment_code, matched_orphan_status: orphan.status, matched_orphan_remaining_quantity: orphan.remaining_quantity }) continue } if (orphan.current_borrow) { block('script_created_orphan_has_active_borrow', { matched_orphan_code: orphan.equipment_code }) continue } actions.push({ ...actionBase, action_type: 'mount_existing', live_parent_id: parent.id, existing_child_id: orphan.id, existing_child_code: orphan.equipment_code, parent_storage_location_id: parent.storage_location_id, parent_department_id: parent.department_id }) continue } const fallbackLocationResolution = !parent.storage_location_id ? resolveStorageLocationMatch(item.workbook_location, refs.locationByName) : { match: null, normalizedValue: '', rule: '' } const resolvedStorageLocationId = parent.storage_location_id || fallbackLocationResolution.match?.id || null const resolvedStorageLocationName = parent.storageLocation?.name || fallbackLocationResolution.match?.name || '' if (!resolvedStorageLocationId) { block('live_parent_missing_storage_location', { live_parent_id: parent.id, workbook_location_resolution: fallbackLocationResolution.normalizedValue || '', workbook_location_rule: fallbackLocationResolution.rule || '' }) continue } actions.push({ ...actionBase, action_type: 'create_and_mount', live_parent_id: parent.id, parent_storage_location_id: resolvedStorageLocationId, parent_storage_location_name: resolvedStorageLocationName, parent_storage_location_source: parent.storage_location_id ? 'live_parent' : 'workbook_fallback', parent_department_id: parent.department_id }) } const summary = { planned_child_count: plan.plannedChildren.length, actionable_count: actions.length, actionable_create_and_mount_count: actions.filter((item) => item.action_type === 'create_and_mount').length, actionable_mount_existing_count: actions.filter((item) => item.action_type === 'mount_existing').length, satisfied_count: satisfied.length, blocked_count: blocked.length, blocked_reason_counts: summarizeByReason(blocked), satisfied_reason_counts: summarizeByReason(satisfied) } return { summary, actions, blocked, satisfied } } const buildCreateChildPayload = (action) => ({ entry_mode: 'child', equipment_name: action.child_name, equipment_model: action.child_model, equipment_type: action.child_type, code_generation_mode: 'auto', unit_price: 0, total_quantity: 1, remaining_quantity: 1, status: '可用', storage_location_id: action.parent_storage_location_id, department_id: action.parent_department_id || null, description: action.planned_description }) const applyActions = async (session, analysis) => { const results = [] const errors = [] for (let index = 0; index < analysis.actions.length; index += 1) { const action = analysis.actions[index] const progress = `${index + 1}/${analysis.actions.length}` console.log(`[${progress}] ${action.action_type} ${action.parent_code} ${action.child_type} ${action.child_model}`) let createdChild = null let stage = 'mount' try { let childId = action.existing_child_id || null let childCode = action.existing_child_code || '' if (action.action_type === 'create_and_mount') { stage = 'create' const createResult = await session.requestJson('/equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildCreateChildPayload(action)) }) createdChild = createResult?.data || null childId = createdChild?.id || null childCode = normalizeText(createdChild?.equipment_code) if (!childId || !childCode) { throw new Error('创建子资产返回的数据不完整') } } stage = 'mount' await session.requestJson(`/mount/${action.live_parent_id}/mount`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ child_id: childId, notes: action.planned_mount_note }) }) results.push({ ...action, apply_status: 'success', child_id: childId, child_code: childCode, created_new_child: action.action_type === 'create_and_mount' ? 'yes' : 'no' }) } catch (error) { let cleanupStatus = 'not_needed' let cleanupMessage = '' if (action.action_type === 'create_and_mount' && createdChild?.id) { cleanupStatus = 'pending' try { await session.requestJson(`/equipment/${createdChild.id}`, { method: 'DELETE' }) cleanupStatus = 'deleted' } catch (cleanupError) { cleanupStatus = 'delete_failed' cleanupMessage = cleanupError?.message || String(cleanupError) } } errors.push({ ...action, apply_status: 'failed', failed_stage: stage, error_message: error?.message || String(error), created_child_id: createdChild?.id || '', created_child_code: createdChild?.equipment_code || '', cleanup_status: cleanupStatus, cleanup_message: cleanupMessage }) break } } return { results, errors, stopped_early: errors.length > 0 } } const writeAnalysisOutputs = async ({ outputDir, plan, liveData, analysis, execution = null, postVerify = null, mode }) => { const manifest = { generated_at: new Date().toISOString(), mode, plan_dir: plan.planDir, source_plan_summary: plan.summary, selected_planned_child_count: plan.plannedChildren.length, live_total_equipment: liveData.equipmentList.length, live_total_asset_types: liveData.assetTypes.length, live_total_storage_locations: (liveData.storageLocations || []).length, analysis_summary: analysis.summary, execution_summary: execution ? { success_count: execution.results.length, error_count: execution.errors.length, stopped_early: execution.stopped_early } : null, post_verify_summary: postVerify?.summary || null } writeJson(path.join(outputDir, 'manifest.json'), manifest) writeJson(path.join(outputDir, 'analysis_summary.json'), analysis.summary) writeJson(path.join(outputDir, 'blocked.json'), analysis.blocked) writeJson(path.join(outputDir, 'satisfied.json'), analysis.satisfied) writeJson(path.join(outputDir, 'actions.json'), analysis.actions) writeCsv( path.join(outputDir, 'actions.csv'), Object.keys(analysis.actions[0] || { action_key: '', action_type: '', parent_code: '', child_type: '', child_model: '' }), analysis.actions ) writeCsv( path.join(outputDir, 'blocked.csv'), Object.keys(analysis.blocked[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }), analysis.blocked ) writeCsv( path.join(outputDir, 'satisfied.csv'), Object.keys(analysis.satisfied[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }), analysis.satisfied ) const workbookSheets = [ { name: 'Summary', rows: buildSummaryRows(manifest) }, { name: 'Actions', rows: analysis.actions }, { name: 'Blocked', rows: analysis.blocked }, { name: 'Satisfied', rows: analysis.satisfied }, { name: 'OriginalSkippedRows', rows: plan.report.skippedRows || [] } ] if (execution) { writeJson(path.join(outputDir, 'apply_results.json'), execution.results) writeJson(path.join(outputDir, 'apply_errors.json'), execution.errors) writeCsv( path.join(outputDir, 'apply_results.csv'), Object.keys(execution.results[0] || { action_key: '', apply_status: '', child_code: '' }), execution.results ) writeCsv( path.join(outputDir, 'apply_errors.csv'), Object.keys(execution.errors[0] || { action_key: '', apply_status: '', error_message: '' }), execution.errors ) workbookSheets.push({ name: 'ApplyResults', rows: execution.results }) workbookSheets.push({ name: 'ApplyErrors', rows: execution.errors }) } if (postVerify) { writeJson(path.join(outputDir, 'post_verify.json'), postVerify) workbookSheets.push({ name: 'PostVerifySummary', rows: buildSummaryRows(postVerify.summary || {}) }) workbookSheets.push({ name: 'PostVerifyBlocked', rows: postVerify.blocked || [] }) workbookSheets.push({ name: 'PostVerifyActions', rows: postVerify.actions || [] }) workbookSheets.push({ name: 'PostVerifySatisfied', rows: postVerify.satisfied || [] }) } await writeWorkbook(path.join(outputDir, 'parent_child_live_migration.xlsx'), workbookSheets) } const run = async () => { const options = parseArgs() const mode = normalizeMode(options.mode) const plan = loadPlan(resolvePlanDir(options.planDir), options) const outputDir = ensureOutputDir( mode === 'apply' ? 'parent_child_live_apply' : 'parent_child_live_preflight' ) if (!plan.plannedChildren.length) { throw new Error('筛选后没有可处理的 plannedChildren') } const session = new ApiSession({ apiBase: options.apiBase, username: options.username, password: options.password }) const liveBefore = await fetchLiveData(session, outputDir, 'before') const analysis = analyzePlan(plan, liveBefore) let execution = null let postVerify = null if (mode === 'apply' && analysis.actions.length > 0) { execution = await applyActions(session, analysis) const liveAfter = await fetchLiveData(session, outputDir, 'after') postVerify = analyzePlan(plan, liveAfter) } await writeAnalysisOutputs({ outputDir, plan, liveData: liveBefore, analysis, execution, postVerify, mode }) console.log( JSON.stringify( { outputDir, mode, selectedPlannedChildCount: plan.plannedChildren.length, analysisSummary: analysis.summary, executionSummary: execution ? { successCount: execution.results.length, errorCount: execution.errors.length, stoppedEarly: execution.stopped_early } : null, postVerifySummary: postVerify ? postVerify.summary : null }, null, 2 ) ) } run().catch((error) => { console.error(error?.stack ? error.stack : String(error)) process.exit(1) })