const fs = require('fs') const path = require('path') const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) const { splitHardwareQuantity, expandHardwareModels, normalizeText } = require('./hardwareQuantitySplit') const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api' const REPAIR_MARKER = 'multi_unit_split_20260323' const parseArgs = () => { const args = process.argv.slice(2) const options = { mode: 'preflight', sourcePath: '', 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 === '--source') { options.sourcePath = 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 normalizeLookupKey = (value) => normalizeText(value).toUpperCase() const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',') const findLatestApplyResultsPath = () => { if (!fs.existsSync(OUTPUT_ROOT)) { return '' } const candidates = fs .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_live_apply_')) .map((entry) => path.join(OUTPUT_ROOT, entry.name, 'apply_results.json')) .filter((filePath) => fs.existsSync(filePath)) .sort() return candidates[candidates.length - 1] || '' } const resolveSourcePath = (value) => { const normalized = normalizeText(value) if (normalized) { const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized) if (!fs.existsSync(resolved)) { throw new Error(`source 文件不存在: ${resolved}`) } return resolved } const latest = findLatestApplyResultsPath() if (!latest) { throw new Error(`未找到可用的 apply_results.json: ${OUTPUT_ROOT}`) } return latest } 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), 60) })) rows.forEach((row) => worksheet.addRow(row)) worksheet.getRow(1).font = { bold: true } worksheet.views = [{ state: 'frozen', ySplit: 1 }] } await workbook.xlsx.writeFile(filePath) } 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 unwrapPayload = (payload) => { if (Array.isArray(payload)) { return payload } if (payload?.data) { return payload.data } if (payload?.list) { return payload.list } return payload } 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 stripRepairTokens = (description) => normalizeText(description) .split(';') .map((item) => item.trim()) .filter( (item) => item && !/^repair_marker=/i.test(item) && !/^repair_source_row=/i.test(item) && !/^repair_parent_code=/i.test(item) && !/^repair_field_key=/i.test(item) && !/^repair_original_model=/i.test(item) && !/^repair_unit_model=/i.test(item) && !/^repair_unit_models=/i.test(item) && !/^repair_quantity=/i.test(item) && !/^repair_split_index=/i.test(item) ) .join('; ') const buildRepairDescription = (description, action, splitIndex, unitModel) => { const base = stripRepairTokens(description) const tokens = [ ['repair_marker', REPAIR_MARKER], ['repair_source_row', action.source_row], ['repair_parent_code', action.parent_code], ['repair_field_key', action.field_key], ['repair_original_model', action.source_child_model], ['repair_unit_model', unitModel], ['repair_unit_models', action.desired_models.join(' | ')], ['repair_quantity', action.quantity], ['repair_split_index', splitIndex] ].map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`) return [base, ...tokens].filter(Boolean).join('; ') } const buildRepairMountNote = (action, splitIndex, unitModel) => `多件拆分修正:根据8.xlsx第${action.source_row}行,将${sanitizeTokenValue(action.source_child_model)}拆为第${splitIndex}/${action.quantity}件,型号=${sanitizeTokenValue(unitModel)}` const loadSourceRows = (sourcePath, options) => { let rows = readJsonFile(sourcePath) if (!Array.isArray(rows)) { throw new Error(`source 文件结构不正确,应为数组: ${sourcePath}`) } if (normalizeText(options.parentCode)) { const parentCodeKey = normalizeLookupKey(options.parentCode) rows = rows.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey) } if (options.sourceRow > 0) { rows = rows.filter((item) => Number(item.source_row) === Number(options.sourceRow)) } if (options.limit > 0) { rows = rows.slice(0, options.limit) } return rows } const countModelKeys = (models) => { const counts = new Map() for (const model of models) { const key = normalizeLookupKey(model) if (!key) { continue } counts.set(key, { key, model, count: (counts.get(key)?.count || 0) + 1 }) } return counts } const findDesiredModelIndexes = (models, targetModel) => models .map((model, index) => ({ model, index: index + 1 })) .filter((item) => normalizeLookupKey(item.model) === normalizeLookupKey(targetModel)) .map((item) => item.index) const buildRepairCandidates = (rows) => { const autoFixable = [] const manualReview = [] for (const row of rows) { if (normalizeText(row.apply_status) && normalizeText(row.apply_status) !== 'success') { continue } const split = splitHardwareQuantity(row.child_type, row.child_model) const expanded = expandHardwareModels(row.child_type, row.child_model) if (!split.isMultiUnit) { continue } const base = { source_row: row.source_row, parent_code: row.parent_code, parent_name: row.parent_name, parent_type: row.parent_type, field_key: row.field_key, child_type: row.child_type, child_name: row.child_name, source_child_model: row.child_model, current_child_id: row.child_id, current_child_code: row.child_code, live_parent_id: row.live_parent_id, planned_description: row.planned_description || '', parent_storage_location_id: row.parent_storage_location_id || '', parent_department_id: row.parent_department_id || '' } if (!split.isAutoSplittable) { manualReview.push({ ...base, quantity: split.quantity || '', unit_model: '', desired_models: '', split_pattern: split.pattern || '', reason: split.reason || 'multi_unit_model_requires_manual_review' }) continue } if ((expanded.models || []).length !== 2) { manualReview.push({ ...base, quantity: split.quantity, unit_model: split.unitModel, desired_models: (expanded.models || []).join(' | '), split_pattern: split.pattern || '', reason: 'quantity_greater_than_two_not_auto_applied' }) continue } autoFixable.push({ ...base, quantity: expanded.models.length, unit_model: split.unitModel, desired_models: expanded.models, desired_models_json: JSON.stringify(expanded.models), split_pattern: split.pattern || '' }) } return { autoFixable, manualReview } } const analyzeCurrentState = async (session, candidates) => { const parentIds = [...new Set(candidates.map((item) => Number(item.live_parent_id)).filter((item) => item > 0))] const childIds = [...new Set(candidates.map((item) => Number(item.current_child_id)).filter((item) => item > 0))] const parentMap = new Map() const childMap = new Map() for (const parentId of parentIds) { const payload = await session.requestJson(`/equipment/${parentId}`) parentMap.set(parentId, unwrapPayload(payload)) } for (const childId of childIds) { const payload = await session.requestJson(`/equipment/${childId}`) childMap.set(childId, unwrapPayload(payload)) } const actionable = [] const blocked = [] const satisfied = [] for (const candidate of candidates) { const desiredModels = Array.isArray(candidate.desired_models) ? candidate.desired_models : JSON.parse(candidate.desired_models_json || '[]') const desiredModelCounts = countModelKeys(desiredModels) const parent = parentMap.get(Number(candidate.live_parent_id)) const currentChild = childMap.get(Number(candidate.current_child_id)) const base = { ...candidate, desired_models: desiredModels, current_child_status: currentChild?.status || '', current_child_model: currentChild?.equipment_model || '', current_child_parent_id: currentChild?.parent_id || '' } if (!parent) { blocked.push({ ...base, reason: 'live_parent_not_found' }) continue } if (!currentChild) { blocked.push({ ...base, reason: 'current_child_not_found' }) continue } if (Number(currentChild.parent_id) !== Number(candidate.live_parent_id)) { blocked.push({ ...base, reason: 'current_child_not_mounted_to_expected_parent' }) continue } const sameTypeChildren = (Array.isArray(parent.children) ? parent.children : []).filter( (child) => normalizeLookupKey(child.equipment_type) === normalizeLookupKey(candidate.child_type) ) const currentModelKey = normalizeLookupKey(currentChild.equipment_model) const originalModelKey = normalizeLookupKey(candidate.source_child_model) const siblingDesiredCounts = new Map() for (const child of sameTypeChildren) { if (Number(child.id) === Number(candidate.current_child_id)) { continue } const key = normalizeLookupKey(child.equipment_model) const desired = desiredModelCounts.get(key) if (!desired) { continue } siblingDesiredCounts.set(key, { key, model: desired.model, count: (siblingDesiredCounts.get(key)?.count || 0) + 1 }) } const totalSiblingDesiredCount = [...siblingDesiredCounts.values()].reduce((sum, item) => sum + item.count, 0) const missingModelsForAssignment = (assignedModel) => { const assignedKey = normalizeLookupKey(assignedModel) const countsAfterAssign = new Map(siblingDesiredCounts) if (desiredModelCounts.has(assignedKey)) { countsAfterAssign.set(assignedKey, { key: assignedKey, model: desiredModelCounts.get(assignedKey).model, count: (countsAfterAssign.get(assignedKey)?.count || 0) + 1 }) } const totalDesiredAfterAssign = [...countsAfterAssign.values()].reduce((sum, item) => sum + item.count, 0) if (totalDesiredAfterAssign > desiredModels.length) { return null } const missing = [] for (const desired of desiredModelCounts.values()) { const currentCount = countsAfterAssign.get(desired.key)?.count || 0 for (let index = currentCount; index < desired.count; index += 1) { missing.push(desired.model) } } return missing } const assignmentOptions = [] if (desiredModelCounts.has(currentModelKey)) { assignmentOptions.push({ targetModel: currentChild.equipment_model, updateExisting: false }) } if (currentModelKey === originalModelKey) { for (const desiredModel of desiredModels) { if (!assignmentOptions.some((item) => normalizeLookupKey(item.targetModel) === normalizeLookupKey(desiredModel))) { assignmentOptions.push({ targetModel: desiredModel, updateExisting: true }) } } } if (assignmentOptions.length === 0) { blocked.push({ ...base, reason: 'current_child_model_is_not_repairable' }) continue } const candidatePlans = assignmentOptions .map((option) => { const missingModels = missingModelsForAssignment(option.targetModel) if (!missingModels || missingModels.length > 1) { return null } return { ...option, missingModels, desiredMatchingSiblingCount: totalSiblingDesiredCount } }) .filter(Boolean) .sort((left, right) => { if (left.missingModels.length !== right.missingModels.length) { return left.missingModels.length - right.missingModels.length } if (left.updateExisting !== right.updateExisting) { return left.updateExisting ? -1 : 1 } return desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(left.targetModel)) - desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(right.targetModel)) }) const selectedPlan = candidatePlans[0] if (!selectedPlan) { blocked.push({ ...base, reason: 'current_state_requires_manual_review' }) continue } if (!selectedPlan.updateExisting && selectedPlan.missingModels.length === 0) { satisfied.push({ ...base, reason: 'already_split_to_expected_quantity' }) continue } const currentTargetIndexes = findDesiredModelIndexes(desiredModels, selectedPlan.targetModel) const createIndexes = selectedPlan.missingModels[0] ? findDesiredModelIndexes(desiredModels, selectedPlan.missingModels[0]) : [] const currentTargetIndex = currentTargetIndexes[0] || 1 const createIndex = selectedPlan.missingModels[0] ? ( normalizeLookupKey(selectedPlan.missingModels[0]) === normalizeLookupKey(selectedPlan.targetModel) && createIndexes.length > 1 ? createIndexes.find((item) => item !== currentTargetIndex) || createIndexes[0] : createIndexes[0] ) : '' actionable.push({ ...base, action_type: selectedPlan.updateExisting && selectedPlan.missingModels.length === 1 ? 'update_existing_and_create_one' : selectedPlan.updateExisting ? 'update_existing_only' : 'create_one_missing_unit_child', current_target_model: selectedPlan.targetModel, create_model: selectedPlan.missingModels[0] || '', current_target_index: currentTargetIndex, create_index: createIndex }) } return { parentMap, childMap, actionable, blocked, satisfied } } const buildCreateChildPayload = (action, parent, sourceDescription, splitIndex, unitModel) => ({ entry_mode: 'child', equipment_name: action.child_name, equipment_model: unitModel, equipment_type: action.child_type, code_generation_mode: 'auto', unit_price: 0, total_quantity: 1, remaining_quantity: 1, status: '可用', storage_location_id: parent.storage_location_id, department_id: parent.department_id || null, description: buildRepairDescription(sourceDescription, action, splitIndex, unitModel) }) const applyRepairs = async (session, analysis) => { const results = [] const errors = [] for (let index = 0; index < analysis.actionable.length; index += 1) { const action = analysis.actionable[index] const progress = `${index + 1}/${analysis.actionable.length}` console.log(`[${progress}] repair ${action.parent_code} ${action.child_type} ${action.source_child_model}`) let createdChildId = null let createdChildCode = '' let existingChildUpdated = false let originalDescription = '' let originalModel = '' try { const parentPayload = await session.requestJson(`/equipment/${action.live_parent_id}`) const parent = unwrapPayload(parentPayload) const childPayload = await session.requestJson(`/equipment/${action.current_child_id}`) const currentChild = unwrapPayload(childPayload) if (!parent || !currentChild) { throw new Error('父资产或当前子资产不存在') } originalDescription = currentChild.description || '' originalModel = currentChild.equipment_model || '' if (action.action_type === 'update_existing_and_create_one' || action.action_type === 'update_existing_only') { await session.requestJson(`/equipment/${action.current_child_id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ equipment_model: action.current_target_model, description: buildRepairDescription( originalDescription, action, Number(action.current_target_index) || 1, action.current_target_model ) }) }) existingChildUpdated = true } if (action.create_model) { const createResult = await session.requestJson('/equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( buildCreateChildPayload( action, parent, originalDescription, Number(action.create_index) || 2, action.create_model ) ) }) const createdChild = unwrapPayload(createResult) createdChildId = Number(createdChild?.id) || null createdChildCode = normalizeText(createdChild?.equipment_code) if (!createdChildId || !createdChildCode) { throw new Error('新建补充子资产返回结果不完整') } await session.requestJson(`/mount/${action.live_parent_id}/mount`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ child_id: createdChildId, notes: buildRepairMountNote( action, Number(action.create_index) || 2, action.create_model ) }) }) } results.push({ ...action, apply_status: 'success', created_child_id: createdChildId || '', created_child_code: createdChildCode || '' }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) if (createdChildId) { try { await session.requestJson(`/equipment/${createdChildId}`, { method: 'DELETE' }) } catch (cleanupError) { console.error(`[cleanup] 删除新建子资产失败 id=${createdChildId}`, cleanupError) } } if (existingChildUpdated) { try { await session.requestJson(`/equipment/${action.current_child_id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ equipment_model: originalModel, description: originalDescription }) }) } catch (rollbackError) { console.error(`[rollback] 回滚原子资产失败 id=${action.current_child_id}`, rollbackError) } } errors.push({ ...action, apply_status: 'error', error_message: errorMessage, created_child_id: createdChildId || '', created_child_code: createdChildCode || '' }) } } return { results, errors } } const run = async () => { const options = parseArgs() const sourcePath = resolveSourcePath(options.sourcePath) const sourceRows = loadSourceRows(sourcePath, options) const outputDir = ensureOutputDir('multi_unit_child_repair') const sourceCandidates = buildRepairCandidates(sourceRows) const summaryBase = { generated_at: new Date().toISOString(), mode: options.mode, source_path: sourcePath, api_base: options.apiBase, source_total_rows: sourceRows.length, detected_multi_unit_rows: sourceCandidates.autoFixable.length + sourceCandidates.manualReview.length, auto_fixable_candidate_count: sourceCandidates.autoFixable.length, manual_review_candidate_count: sourceCandidates.manualReview.length } let analysis = { actionable: [], blocked: [], satisfied: [] } let applyResult = { results: [], errors: [] } if (sourceCandidates.autoFixable.length > 0) { const session = new ApiSession({ apiBase: options.apiBase, username: options.username, password: options.password }) analysis = await analyzeCurrentState(session, sourceCandidates.autoFixable) if (normalizeText(options.mode).toLowerCase() === 'apply' && analysis.actionable.length > 0) { applyResult = await applyRepairs(session, analysis) } } const summary = { ...summaryBase, actionable_count: analysis.actionable.length, blocked_count: analysis.blocked.length, satisfied_count: analysis.satisfied.length, manual_review_reason_counts: summarizeByReason(sourceCandidates.manualReview), blocked_reason_counts: summarizeByReason(analysis.blocked), satisfied_reason_counts: summarizeByReason(analysis.satisfied), apply_success_count: applyResult.results.length, apply_error_count: applyResult.errors.length } const report = { summary, autoFixableCandidates: sourceCandidates.autoFixable, manualReviewCandidates: sourceCandidates.manualReview, actionable: analysis.actionable, blocked: analysis.blocked, satisfied: analysis.satisfied, applyResults: applyResult.results, applyErrors: applyResult.errors } writeJson(path.join(outputDir, 'summary.json'), summary) writeJson(path.join(outputDir, 'report.json'), report) writeCsv( path.join(outputDir, 'manual_review_candidates.csv'), Object.keys(sourceCandidates.manualReview[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), sourceCandidates.manualReview ) writeCsv( path.join(outputDir, 'actionable.csv'), Object.keys(analysis.actionable[0] || { parent_code: '', child_type: '', source_child_model: '', unit_model: '', action_type: '' }), analysis.actionable ) writeCsv( path.join(outputDir, 'blocked.csv'), Object.keys(analysis.blocked[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), analysis.blocked ) writeCsv( path.join(outputDir, 'satisfied.csv'), Object.keys(analysis.satisfied[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }), analysis.satisfied ) writeCsv( path.join(outputDir, 'apply_results.csv'), Object.keys(applyResult.results[0] || { parent_code: '', child_type: '', source_child_model: '', created_child_code: '', apply_status: '' }), applyResult.results ) writeCsv( path.join(outputDir, 'apply_errors.csv'), Object.keys(applyResult.errors[0] || { parent_code: '', child_type: '', source_child_model: '', error_message: '' }), applyResult.errors ) await writeWorkbook(path.join(outputDir, 'multi_unit_child_repair.xlsx'), [ { name: 'Summary', rows: buildSummaryRows(summary) }, { name: 'ManualReview', rows: sourceCandidates.manualReview }, { name: 'Actionable', rows: analysis.actionable }, { name: 'Blocked', rows: analysis.blocked }, { name: 'Satisfied', rows: analysis.satisfied }, { name: 'ApplyResults', rows: applyResult.results }, { name: 'ApplyErrors', rows: applyResult.errors } ]) console.log( JSON.stringify( { outputDir, summary }, null, 2 ) ) } run().catch((error) => { console.error(error) process.exit(1) })