855 lines
27 KiB
JavaScript
855 lines
27 KiB
JavaScript
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)
|
|
})
|