894 lines
28 KiB
JavaScript
894 lines
28 KiB
JavaScript
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)
|
||
})
|