完善父子资产迁移与挂载管理

This commit is contained in:
WuYongJie 2026-03-24 14:21:09 +08:00
parent 247c3107b5
commit 9519c10b78
13 changed files with 3525 additions and 109 deletions

View File

@ -0,0 +1,231 @@
const fs = require('fs')
const path = require('path')
const ExcelJS = require('exceljs')
const SOURCE_PATH = path.resolve(
__dirname,
'../../temp_migration_output/multi_unit_child_repair_2026-03-24_01-16-47/manual_review_candidates.csv'
)
const OUTPUT_PATH = path.resolve(
__dirname,
'../../行政手动处理清单-最终仅剩8条-2026-03-24.xlsx'
)
const OBSOLETE_FILES = [
path.resolve(__dirname, '../../父子资产补建-行政手动处理清单-2026-03-23.xlsx'),
path.resolve(__dirname, '../../行政手动补录清单-2026-03-23_当前待人工处理.xlsx'),
path.resolve(__dirname, '../../子资产多件拆分-行政手动处理清单-2026-03-23.xlsx')
]
const REASON_MAP = {
outer_capacity_not_equal_to_inner_times_quantity:
'表格总容量与单条容量乘数量对不上,系统不能在不猜测的前提下自动拆分。',
child_type_and_model_are_inconsistent: '子资产类型与型号内容不一致,系统不能自动处理。',
pattern_detected_but_no_safe_split_rule: '识别到多件格式,但现有信息不足以安全拆分。',
storage_plus_pattern_is_not_single_capacity_pair:
'硬盘型号中混有容量和具体型号,不能只按容量安全拆分。'
}
const SPECIAL_REASON_OVERRIDES = {
'JS-PC-2127':
'原表错行:该行硬件字段整体错位,不能按当前行内容自动落库,需行政按原始资料人工核对后录入。'
}
const normalize = (value) => String(value == null ? '' : value)
const extractMeta = (description, key) => {
const text = normalize(description)
const matched = text.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`))
return matched ? matched[1].trim() : ''
}
async function readCsvRows(csvPath) {
const workbook = new ExcelJS.Workbook()
const sheet = await workbook.csv.readFile(csvPath)
const headers = sheet.getRow(1).values.slice(1).map((item) => normalize(item))
const rows = []
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber)
if (row.cellCount === 0) {
continue
}
const record = {}
headers.forEach((header, index) => {
record[header] = normalize(row.getCell(index + 1).text)
})
if (Object.values(record).some((value) => value !== '')) {
rows.push(record)
}
}
return { headers, rows }
}
function styleHeaderRow(row) {
row.font = { bold: true }
row.alignment = { vertical: 'middle', horizontal: 'center' }
}
function resolveReasonDescription(row) {
return SPECIAL_REASON_OVERRIDES[row.parent_code] || REASON_MAP[row.reason] || row.reason
}
function buildTimestampedOutputPath() {
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
return path.resolve(__dirname, `../../行政手动处理清单-最终仅剩8条-${stamp}.xlsx`)
}
async function writeWorkbookWithFallback(workbook) {
try {
await workbook.xlsx.writeFile(OUTPUT_PATH)
return OUTPUT_PATH
} catch (error) {
if (error && error.code === 'EBUSY') {
const fallbackPath = buildTimestampedOutputPath()
await workbook.xlsx.writeFile(fallbackPath)
return fallbackPath
}
throw error
}
}
async function exportWorkbook() {
const { headers, rows } = await readCsvRows(SOURCE_PATH)
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
const taskSheet = workbook.addWorksheet('人工处理清单')
taskSheet.columns = [
{ header: '源表行号', key: 'source_row', width: 10 },
{ header: '父资产编码', key: 'parent_code', width: 16 },
{ header: '父资产名称', key: 'parent_name', width: 12 },
{ header: '配件字段', key: 'field_key', width: 12 },
{ header: '子资产类型', key: 'child_type', width: 12 },
{ header: '系统当前子资产编码', key: 'current_child_code', width: 18 },
{ header: '表格原始型号', key: 'source_child_model', width: 42 },
{ header: '表格部门', key: 'workbook_department', width: 18 },
{ header: '表格使用人', key: 'workbook_owner', width: 12 },
{ header: '表格位置', key: 'workbook_location', width: 18 },
{ header: '原因代码', key: 'reason', width: 44 },
{ header: '人工处理原因', key: 'reason_desc', width: 50 }
]
taskSheet.views = [{ state: 'frozen', ySplit: 1 }]
taskSheet.autoFilter = 'A1:L1'
styleHeaderRow(taskSheet.getRow(1))
rows.forEach((row) => {
taskSheet.addRow({
source_row: row.source_row,
parent_code: row.parent_code,
parent_name: row.parent_name,
field_key: row.field_key,
child_type: row.child_type,
current_child_code: row.current_child_code,
source_child_model: row.source_child_model,
workbook_department: extractMeta(row.planned_description, 'workbook_department'),
workbook_owner: extractMeta(row.planned_description, 'workbook_owner'),
workbook_location: extractMeta(row.planned_description, 'workbook_location'),
reason: row.reason,
reason_desc: resolveReasonDescription(row)
})
})
taskSheet.eachRow((row, rowNumber) => {
row.alignment = { vertical: 'top', wrapText: true }
if (rowNumber > 1) {
row.height = 22
}
})
const rawSheet = workbook.addWorksheet('原始数据')
rawSheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.max(14, header.length + 2)
}))
rawSheet.views = [{ state: 'frozen', ySplit: 1 }]
styleHeaderRow(rawSheet.getRow(1))
rows.forEach((row) => rawSheet.addRow(row))
rawSheet.eachRow((row) => {
row.alignment = { vertical: 'top', wrapText: true }
})
const actualOutputPath = await writeWorkbookWithFallback(workbook)
const verifyBook = new ExcelJS.Workbook()
await verifyBook.xlsx.readFile(actualOutputPath)
const verifyRawSheet = verifyBook.getWorksheet('原始数据')
const verifyTaskSheet = verifyBook.getWorksheet('人工处理清单')
const verifiedRows = []
for (let rowNumber = 2; rowNumber <= verifyRawSheet.rowCount; rowNumber += 1) {
const row = verifyRawSheet.getRow(rowNumber)
if (row.cellCount === 0) {
continue
}
const record = {}
headers.forEach((header, index) => {
record[header] = normalize(row.getCell(index + 1).text)
})
if (Object.values(record).some((value) => value !== '')) {
verifiedRows.push(record)
}
}
if (verifiedRows.length !== rows.length) {
throw new Error(`校验失败:原始数据行数不一致,source=${rows.length}, xlsx=${verifiedRows.length}`)
}
rows.forEach((row, index) => {
headers.forEach((header) => {
if (normalize(verifiedRows[index][header]) !== normalize(row[header])) {
throw new Error(`校验失败:第 ${index + 1} 行字段 ${header} 不一致`)
}
})
})
const taskRowCount = Math.max(verifyTaskSheet.rowCount - 1, 0)
if (taskRowCount !== rows.length) {
throw new Error(`校验失败:人工处理清单行数不一致,source=${rows.length}, xlsx=${taskRowCount}`)
}
const removedFiles = []
const skippedFiles = []
for (const filePath of OBSOLETE_FILES) {
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath)
removedFiles.push(filePath)
} catch (error) {
skippedFiles.push({
filePath,
code: error && error.code ? error.code : 'UNKNOWN'
})
}
}
}
return {
outputPath: actualOutputPath,
rowCount: rows.length,
removedFiles,
skippedFiles
}
}
exportWorkbook()
.then((result) => {
console.log(JSON.stringify({ verified: true, ...result }, null, 2))
})
.catch((error) => {
console.error(error)
process.exit(1)
})

View File

@ -0,0 +1,534 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const cleanModelText = (value) =>
normalizeText(value)
.replace(/\s+([,,/])/g, '$1')
.replace(/([((])\s+/g, '$1')
.replace(/\s+([))])/g, '$1')
.replace(/\s+/g, ' ')
.trim()
const MULTI_UNIT_HINT_PATTERNS = [
{ pattern: 'cpu_x_multiplier', regex: /[((]\s*[xX]\s*[2-9]\d*\s*[))]\s*$/ },
{ pattern: 'suffix_quantity_label', regex: /\*\s*[2-9]\d*\s*(个|条|根|块|张|颗)\s*$/ },
{ pattern: 'bracket_capacity_multiplier', regex: /[((][^()()]*\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*[^()()]*[))]/i },
{ pattern: 'inline_capacity_multiplier', regex: /(?:^|\s)\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*(?:\s|$)/i },
{ pattern: 'suffix_multiplier_with_paren_unit', regex: /\*\s*[2-9]\d*\s*[((]\s*\d+\s*[A-Za-z]{1,3}\s*[))]\s*$/i },
{ pattern: 'plus_capacity_pair', regex: /\d+(?:\.\d+)?\s*[A-Za-z]{0,3}\s*\+\s*\d+(?:\.\d+)?\s*[A-Za-z]{0,3}/i }
]
const CAPACITY_TOKEN_REGEX = /(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g
const normalizeCapacityUnit = (value) => {
const unit = String(value || '').trim().toUpperCase()
if (unit === 'GB') return 'G'
if (unit === 'TB') return 'T'
return unit
}
const parseCapacityToken = (value) => {
const matched = normalizeText(value).match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]{0,3})$/)
if (!matched) {
return null
}
return {
raw: matched[0],
value: Number(matched[1]),
unit: matched[2] || '',
normalizedUnit: normalizeCapacityUnit(matched[2] || '')
}
}
const formatCapacityToken = (value, unit) => {
const numeric = Number(value)
const rendered = Number.isInteger(numeric) ? String(numeric) : String(numeric)
return `${rendered}${unit || ''}`
}
const findLastCapacityTokenWithUnit = (value) => {
const text = String(value || '')
let matched = null
let current = null
while ((current = CAPACITY_TOKEN_REGEX.exec(text)) !== null) {
matched = {
raw: current[0],
value: Number(current[1]),
unit: current[2],
normalizedUnit: normalizeCapacityUnit(current[2]),
index: current.index,
length: current[0].length
}
}
CAPACITY_TOKEN_REGEX.lastIndex = 0
return matched
}
const findFirstCapacityTokenWithUnit = (value) => {
const text = String(value || '')
const matched = CAPACITY_TOKEN_REGEX.exec(text)
CAPACITY_TOKEN_REGEX.lastIndex = 0
if (!matched) {
return null
}
return {
raw: matched[0],
value: Number(matched[1]),
unit: matched[2],
normalizedUnit: normalizeCapacityUnit(matched[2]),
index: matched.index,
length: matched[0].length
}
}
const countCapacityTokensWithUnit = (value) => {
const text = String(value || '')
const matched = text.match(/(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g)
return matched ? matched.length : 0
}
const splitAroundFirstCapacityToken = (value) => {
const text = normalizeText(value)
const token = findFirstCapacityTokenWithUnit(text)
if (!token) {
return null
}
return {
prefix: normalizeText(text.slice(0, token.index)),
token,
suffix: normalizeText(text.slice(token.index + token.length))
}
}
const buildModelText = (prefix, token, suffix) =>
cleanModelText(`${prefix || ''}${formatCapacityToken(token.value, token.unit)}${suffix ? ` ${suffix}` : ''}`)
const isMemoryChildType = (childType) => normalizeText(childType) === '内存条'
const isStorageChildType = (childType) => ['固态硬盘', '机械硬盘'].includes(normalizeText(childType))
const canShareMemoryPrefix = (value) => {
const text = normalizeText(value)
return Boolean(text) && !/[+/]/.test(text) && !/\d/.test(text)
}
const canShareMemorySuffix = (value) => {
const text = normalizeText(value)
return Boolean(text) && !/[+/]/.test(text) && !/[\\/]/.test(text)
}
const replaceTokenByIndex = (value, token, replacement) =>
`${value.slice(0, token.index)}${replacement}${value.slice(token.index + token.length)}`
const removeBundleKeyword = (value) =>
normalizeText(value)
.replace(/^套装/i, '')
.replace(/套装/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
const detectMultiUnitHint = (value) => {
const text = normalizeText(value)
if (!text) {
return null
}
return MULTI_UNIT_HINT_PATTERNS.find((item) => item.regex.test(text))?.pattern || null
}
const resolveBracketCapacitySplit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)[((]\s*(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*[))](.*)$/i)
if (!matched) {
return null
}
const prefix = matched[1] || ''
const rawInner = matched[2] || ''
const quantity = Number(matched[3] || 0)
const suffix = matched[4] || ''
const inner = parseCapacityToken(rawInner)
const outer = findLastCapacityTokenWithUnit(prefix)
if (!inner || !outer || quantity < 2) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'missing_outer_or_inner_capacity_token',
pattern: 'bracket_capacity_multiplier',
originalModel: text
}
}
const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit)
if (outer.value !== inner.value * quantity) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'outer_capacity_not_equal_to_inner_times_quantity',
pattern: 'bracket_capacity_multiplier',
originalModel: text,
quantity
}
}
const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken)
const unitModel = cleanModelText(`${updatedPrefix}${removeBundleKeyword(suffix) ? ` ${removeBundleKeyword(suffix)}` : ''}`)
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel,
pattern: 'bracket_capacity_multiplier',
originalModel: text
}
}
const resolveInlineCapacitySplit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*$/i)
if (!matched) {
return null
}
const prefix = matched[1] || ''
const rawInner = matched[2] || ''
const quantity = Number(matched[3] || 0)
const inner = parseCapacityToken(rawInner)
const outer = findLastCapacityTokenWithUnit(prefix)
if (!inner || !outer || quantity < 2) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'missing_outer_or_inner_capacity_token',
pattern: 'inline_capacity_multiplier',
originalModel: text
}
}
const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit)
if (outer.value !== inner.value * quantity) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'outer_capacity_not_equal_to_inner_times_quantity',
pattern: 'inline_capacity_multiplier',
originalModel: text,
quantity
}
}
const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken)
const unitModel = cleanModelText(updatedPrefix)
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel,
pattern: 'inline_capacity_multiplier',
originalModel: text
}
}
const resolveSuffixMultiplierWithParenUnit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*)\*\s*([2-9]\d*)\s*[((]\s*(\d+\s*[A-Za-z]{1,3})\s*[))]\s*$/i)
if (!matched) {
return null
}
const prefix = normalizeText(matched[1] || '')
const quantity = Number(matched[2] || 0)
const rawInner = normalizeText(matched[3] || '')
if (quantity < 2 || !prefix || !rawInner) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'invalid_suffix_multiplier_with_paren_unit',
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text
}
}
if (!prefix.includes(rawInner)) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'paren_unit_not_present_in_prefix',
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text,
quantity
}
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel: cleanModelText(prefix),
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text
}
}
const resolveCpuXMultiplier = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)[((]\s*[xX]\s*([2-9]\d*)\s*[))]\s*$/)
if (!matched) {
return null
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: Number(matched[2] || 0),
unitModel: cleanModelText(matched[1]),
pattern: 'cpu_x_multiplier',
originalModel: text
}
}
const resolveSuffixQuantityLabel = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)\s*\*\s*([2-9]\d*)\s*(个|条|根|块|张|颗)\s*$/)
if (!matched) {
return null
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: Number(matched[2] || 0),
unitModel: cleanModelText(matched[1]),
pattern: 'suffix_quantity_label',
originalModel: text
}
}
const resolveMemoryPlusSplit = (childType, value) => {
if (!isMemoryChildType(childType)) {
return null
}
const text = normalizeText(value)
const parts = text.split('+')
if (parts.length !== 2) {
return null
}
const left = splitAroundFirstCapacityToken(parts[0])
const right = splitAroundFirstCapacityToken(parts[1])
if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'memory_plus_pattern_is_not_single_capacity_pair',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const leftPrefix = left.prefix || (canShareMemoryPrefix(right.prefix) ? right.prefix : '')
const rightPrefix = right.prefix || (canShareMemoryPrefix(left.prefix) ? left.prefix : '')
const leftSuffix = left.suffix || (canShareMemorySuffix(right.suffix) ? right.suffix : '')
const rightSuffix = right.suffix || (canShareMemorySuffix(left.suffix) ? left.suffix : '')
const models = [
buildModelText(leftPrefix, left.token, leftSuffix),
buildModelText(rightPrefix, right.token, rightSuffix)
]
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: models.length,
unitModel: models[0] === models[1] ? models[0] : '',
unitModels: models,
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const resolveStorageSimplePlusSplit = (childType, value) => {
if (!isStorageChildType(childType)) {
return null
}
const text = normalizeText(value)
const parts = text.split('+')
if (parts.length !== 2) {
return null
}
const left = splitAroundFirstCapacityToken(parts[0])
const right = splitAroundFirstCapacityToken(parts[1])
if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'storage_plus_pattern_is_not_single_capacity_pair',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
if (left.prefix || left.suffix || right.prefix || right.suffix) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'storage_plus_pattern_contains_extra_model_details',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const models = [
formatCapacityToken(left.token.value, left.token.unit),
formatCapacityToken(right.token.value, right.token.unit)
]
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: models.length,
unitModel: models[0] === models[1] ? models[0] : '',
unitModels: models,
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const isTypeAndModelConsistent = (childType, model) => {
const normalizedType = normalizeText(childType)
const normalizedModel = normalizeText(model).toUpperCase()
if (!normalizedType || !normalizedModel) {
return true
}
if ((normalizedType === '固态硬盘' || normalizedType === '机械硬盘') && /(DDR\d|MHZ|内存|套装)/i.test(normalizedModel)) {
return false
}
return true
}
const splitHardwareQuantity = (childType, value) => {
const text = normalizeText(value)
if (!text) {
return {
isMultiUnit: false,
isAutoSplittable: false,
quantity: 1,
unitModel: ''
}
}
const hintPattern = detectMultiUnitHint(text)
if (!hintPattern) {
return {
isMultiUnit: false,
isAutoSplittable: false,
quantity: 1,
unitModel: cleanModelText(text),
pattern: 'single_unit',
originalModel: text
}
}
const strategies = [
(type, text) => resolveCpuXMultiplier(text),
(type, text) => resolveSuffixQuantityLabel(text),
(type, text) => resolveBracketCapacitySplit(text),
(type, text) => resolveInlineCapacitySplit(text),
(type, text) => resolveSuffixMultiplierWithParenUnit(text),
resolveMemoryPlusSplit,
resolveStorageSimplePlusSplit
]
for (const strategy of strategies) {
const result = strategy(childType, text)
if (!result) {
continue
}
if (result.isAutoSplittable && !isTypeAndModelConsistent(childType, result.unitModel)) {
return {
...result,
isAutoSplittable: false,
reason: 'child_type_and_model_are_inconsistent'
}
}
return result
}
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'pattern_detected_but_no_safe_split_rule',
pattern: hintPattern,
originalModel: text
}
}
const expandHardwareModels = (childType, value) => {
const result = splitHardwareQuantity(childType, value)
if (!result.isMultiUnit) {
return {
...result,
models: result.unitModel ? [result.unitModel] : []
}
}
if (!result.isAutoSplittable || !result.unitModel || !result.quantity) {
if (Array.isArray(result.unitModels) && result.unitModels.length > 0) {
return {
...result,
models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean)
}
}
return {
...result,
models: []
}
}
if (Array.isArray(result.unitModels) && result.unitModels.length > 0) {
return {
...result,
models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean)
}
}
return {
...result,
models: Array.from({ length: result.quantity }, () => result.unitModel)
}
}
module.exports = {
normalizeText,
cleanModelText,
detectMultiUnitHint,
splitHardwareQuantity,
expandHardwareModels
}

View File

@ -0,0 +1,854 @@
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)
})

View File

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

View File

@ -0,0 +1,893 @@
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)
})

View File

@ -3,11 +3,23 @@ import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import {
calculateEquipmentValues,
calculateChildrenTotalValueFromRecords,
refreshParentTotalValueWithChildren
} from '../utils/assetValue'
const ensureMountableEquipment = async (
type MountLifecycleStatus = '可用' | '已借出' | '维护中' | '待报废' | '已报废'
const PARENT_MOUNTABLE_STATUSES = new Set<MountLifecycleStatus>(['可用', '已借出', '维护中'])
const DETACHED_CHILD_ALLOWED_STATUSES = new Set<MountLifecycleStatus>(['可用', '维护中', '待报废', '已报废'])
const ensureParentMountOperable = (equipment: { status: string }, label: string) => {
if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) {
throw new Error(`${label}当前状态不可挂载或更换配件`)
}
}
const ensureChildAttachable = async (
equipment: { id: number; status: string; remaining_quantity: number },
label: string
) => {
@ -18,6 +30,51 @@ const ensureMountableEquipment = async (
await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`)
}
const resolveDetachedChildStatus = (status?: string): MountLifecycleStatus => {
const normalized = (status || '').trim()
if (!normalized) {
return '可用'
}
if (!DETACHED_CHILD_ALLOWED_STATUSES.has(normalized as MountLifecycleStatus)) {
throw new Error('拆下配件状态不合法')
}
return normalized as MountLifecycleStatus
}
const buildEquipmentStateUpdate = (
equipment: { unit_price: unknown; total_quantity: number; status: string },
targetStatus: MountLifecycleStatus
) => {
const totalQuantity = Math.max(1, Number(equipment.total_quantity) || 1)
const remainingQuantity = targetStatus === '已借出' ? 0 : totalQuantity
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price) || 0,
totalQuantity,
remainingQuantity,
status: targetStatus
})
return {
status: targetStatus,
remaining_quantity: remainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
}
const buildMountedChildStateUpdate = (
parent: { status: string },
child: { unit_price: unknown; total_quantity: number; status: string }
) => {
const inheritedStatus = parent.status === '已借出' || parent.status === '维护中'
? (parent.status as MountLifecycleStatus)
: '可用'
return buildEquipmentStateUpdate(child, inheritedStatus)
}
const ensureNoMountCycle = async (parentId: number, childId: number) => {
if (parentId === childId) {
throw new Error('父资产和子资产不能是同一条记录')
@ -68,7 +125,7 @@ export const mountChild = async (req: Request, res: Response) => {
}
try {
await ensureMountableEquipment(parent, '父资产')
ensureParentMountOperable(parent, '父资产')
} catch (error: any) {
return errorResponse(res, error.message || '父资产状态校验失败', 422)
}
@ -85,7 +142,7 @@ export const mountChild = async (req: Request, res: Response) => {
}
try {
await ensureMountableEquipment(child, '子资产')
await ensureChildAttachable(child, '子资产')
} catch (error: any) {
return errorResponse(res, error.message || '子资产状态校验失败', 422)
}
@ -102,7 +159,8 @@ export const mountChild = async (req: Request, res: Response) => {
where: { id: child_id },
data: {
parent_id: numericParentId,
mount_status: 'mounted'
mount_status: 'mounted',
...buildMountedChildStateUpdate(parent, child)
}
})
@ -145,8 +203,13 @@ export const mountChild = async (req: Request, res: Response) => {
export const unmountChild = async (req: Request, res: Response) => {
try {
const numericParentId = Number(req.params.parentId)
const { child_id, notes } = req.body as { child_id: number; notes?: string }
const { child_id, notes, released_child_status } = req.body as {
child_id: number
notes?: string
released_child_status?: string
}
const userId = Number(res.locals.user?.id)
const detachedChildStatus = resolveDetachedChildStatus(released_child_status)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
@ -170,8 +233,7 @@ export const unmountChild = async (req: Request, res: Response) => {
}
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(child, '子资产')
ensureParentMountOperable(parent, '父资产')
} catch (error: any) {
return errorResponse(res, error.message || '卸载状态校验失败', 422)
}
@ -181,7 +243,8 @@ export const unmountChild = async (req: Request, res: Response) => {
where: { id: child_id },
data: {
parent_id: null,
mount_status: 'unmounted'
mount_status: 'unmounted',
...buildEquipmentStateUpdate(child, detachedChildStatus)
}
})
@ -207,12 +270,14 @@ export const unmountChild = async (req: Request, res: Response) => {
export const replaceChild = async (req: Request, res: Response) => {
try {
const numericParentId = Number(req.params.parentId)
const { old_child_id, new_child_id, notes } = req.body as {
const { old_child_id, new_child_id, notes, released_child_status } = req.body as {
old_child_id: number
new_child_id: number
notes?: string
released_child_status?: string
}
const userId = Number(res.locals.user?.id)
const detachedChildStatus = resolveDetachedChildStatus(released_child_status)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
@ -246,8 +311,7 @@ export const replaceChild = async (req: Request, res: Response) => {
}
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(oldChild, '旧配件')
ensureParentMountOperable(parent, '父资产')
} catch (error: any) {
return errorResponse(res, error.message || '挂载状态校验失败', 422)
}
@ -267,7 +331,7 @@ export const replaceChild = async (req: Request, res: Response) => {
}
try {
await ensureMountableEquipment(newChild, '新配件')
await ensureChildAttachable(newChild, '新配件')
} catch (error: any) {
return errorResponse(res, error.message || '新配件状态校验失败', 422)
}
@ -277,7 +341,8 @@ export const replaceChild = async (req: Request, res: Response) => {
where: { id: old_child_id },
data: {
parent_id: null,
mount_status: 'unmounted'
mount_status: 'unmounted',
...buildEquipmentStateUpdate(oldChild, detachedChildStatus)
}
})
@ -285,7 +350,8 @@ export const replaceChild = async (req: Request, res: Response) => {
where: { id: new_child_id },
data: {
parent_id: numericParentId,
mount_status: 'mounted'
mount_status: 'mounted',
...buildMountedChildStateUpdate(parent, newChild)
}
})
@ -438,10 +504,12 @@ export const getAvailableChildren = async (req: Request, res: Response) => {
}
if (keyword) {
const normalizedKeyword = String(keyword).trim()
where.OR = [
{ equipment_name: { contains: keyword as string } },
{ equipment_code: { contains: keyword as string } },
{ equipment_model: { contains: keyword as string } }
{ equipment_name: { contains: normalizedKeyword } },
{ equipment_code: { contains: normalizedKeyword } },
{ equipment_model: { contains: normalizedKeyword } },
{ equipment_type: { contains: normalizedKeyword } }
]
}

View File

@ -26,6 +26,15 @@ type DisplayValueSummary = {
maintenance_value: number
}
type InventoryByTypeRow = {
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
mountable_available_quantity: number
is_child_type: boolean
}
const toNumber = (value: unknown) => {
if (value === null || value === undefined) return 0
const parsed = Number(value)
@ -82,17 +91,28 @@ export const getDashboardStats = async (req: Request, res: Response) => {
const startOfToday = new Date(new Date().setHours(0, 0, 0, 0))
const [
childAssetTypes,
equipmentTotals,
availableInventoryTotals,
borrowableInventoryTotals,
maintenanceTotals,
inventoryByType,
mountableInventoryByType,
displayValueRows,
todayBorrowCount,
todayReturnCount,
todayBorrowRecords,
todayReturnRecords
] = await Promise.all([
prisma.assetType.findMany({
where: {
can_be_child: true,
status: 'active'
},
select: {
name: true
}
}),
prisma.equipment.aggregate({
_sum: {
total_quantity: true,
@ -134,6 +154,23 @@ export const getDashboardStats = async (req: Request, res: Response) => {
remaining_quantity: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
where: {
parent_id: null,
mount_status: { in: ['none', 'unmounted'] },
status: '可用',
remaining_quantity: { gt: 0 },
borrow_records: {
none: {
status: '已借出'
}
}
},
_sum: {
remaining_quantity: true
}
}),
prisma.equipment.findMany({
where: {
parent_id: null
@ -213,6 +250,11 @@ export const getDashboardStats = async (req: Request, res: Response) => {
const usageRate =
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
const childTypeNames = new Set(childAssetTypes.map((item) => item.name))
const mountableInventoryByTypeMap = mountableInventoryByType.reduce((map, item) => {
map.set(item.equipment_type || '其他', toNumber(item._sum.remaining_quantity))
return map
}, new Map<string, number>())
successResponse(res, {
total_equipment: totalEquipmentQuantity,
@ -232,11 +274,13 @@ export const getDashboardStats = async (req: Request, res: Response) => {
inventory_by_type: Array.from(
inventoryByType.reduce((map, item) => {
const equipmentType = item.equipment_type || '其他'
const current = map.get(equipmentType) || {
const current: InventoryByTypeRow = map.get(equipmentType) || {
equipment_type: equipmentType,
total_quantity: 0,
available_quantity: 0,
borrowed_quantity: 0
borrowed_quantity: 0,
mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0,
is_child_type: childTypeNames.has(equipmentType)
}
const totalQuantity = toNumber(item._sum.total_quantity)
const remainingQuantity = toNumber(item._sum.remaining_quantity)
@ -252,12 +296,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
map.set(equipmentType, current)
return map
}, new Map<string, {
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
}>()).values()
}, new Map<string, InventoryByTypeRow>()).values()
)
.sort((a, b) => b.total_quantity - a.total_quantity)
.filter((item) => item.total_quantity > 0)

View File

@ -1,5 +1,6 @@
import api from './index'
import type { ApiResponse } from '@/types'
import type { EquipmentStatus } from '@/types'
export interface MountChildRequest {
child_id: number
@ -9,12 +10,14 @@ export interface MountChildRequest {
export interface UnmountChildRequest {
child_id: number
notes?: string
released_child_status?: Extract<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
}
export interface ReplaceChildRequest {
old_child_id: number
new_child_id: number
notes?: string
released_child_status?: Extract<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
}
export interface ChildEquipment {

View File

@ -43,9 +43,6 @@
配件更换:<span class="font-medium text-text-primary">{{ record.child?.equipment_name }}</span>
</template>
</div>
<div v-if="record.notes" class="text-xs leading-5 text-text-tertiary">
备注:{{ record.notes }}
</div>
</div>
<div class="text-xs text-text-tertiary">
{{ record.operator?.name || record.operator?.username || '-' }}

View File

@ -17,7 +17,7 @@
<div v-else class="space-y-3">
<div
v-for="child in children"
v-for="child in sortedChildren"
:key="child.id"
class="panel"
>
@ -28,7 +28,10 @@
<span class="badge badge-neutral">{{ child.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ child.equipment_code }} · ¥{{ formatPrice(child.unit_price) }}
{{ child.equipment_code }} · 型号:{{ child.equipment_model || '未填写型号' }}
</div>
<div class="text-xs text-text-tertiary">
单价:¥{{ formatPrice(child.unit_price) }}
</div>
</div>
@ -62,7 +65,7 @@
<div class="sheet-panel max-w-3xl">
<div class="form-panel__header">
<h3 class="form-panel__title">选择要挂载的配件</h3>
<p class="form-panel__desc">只能挂载当前可用且没有未归还记录的配件资产。</p>
<p class="form-panel__desc">这里只显示空闲可挂载的独立配件:未挂载、当前可用、且没有未归还记录。已经挂在主机上的配件不会出现在这里。挂载后会继承父资产当前状态。</p>
</div>
<div class="field-stack">
<label class="field-label">搜索配件</label>
@ -70,16 +73,16 @@
v-model="searchKeyword"
class="input"
type="text"
placeholder="搜索配件名称或编码"
placeholder="搜索名称、编码、型号或类型"
@input="fetchAvailableChildren"
/>
</div>
<div v-if="availableChildren.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
没有可挂载的配件
{{ availableChildrenEmptyMessage }}
</div>
<div v-else class="mobile-card-list max-h-80 overflow-y-auto">
<div
v-for="item in availableChildren"
v-for="item in sortedAvailableChildren"
:key="item.id"
class="mobile-card cursor-pointer transition hover:border-accent-primary/40 hover:bg-bg-hover"
@click="selectChild(item)"
@ -90,7 +93,10 @@
<span class="badge badge-neutral">{{ item.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }}
{{ item.equipment_code }} · 型号:{{ item.equipment_model || '未填写型号' }}
</div>
<div class="text-xs text-text-tertiary">
单价:¥{{ formatPrice(item.unit_price) }}
</div>
</div>
</div>
@ -105,7 +111,7 @@
<div class="sheet-panel max-w-3xl">
<div class="form-panel__header">
<h3 class="form-panel__title">更换配件:{{ replacingChild?.equipment_name }}</h3>
<p class="form-panel__desc">新配件需要同样处于可用状态,且不能已经挂载到别的父资产上。</p>
<p class="form-panel__desc">这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换时会同步让新配件继承父资产状态,并为旧配件选择拆下后的状态。</p>
</div>
<div class="field-stack">
<label class="field-label">搜索新配件</label>
@ -113,16 +119,16 @@
v-model="searchKeyword"
class="input"
type="text"
placeholder="搜索新配件"
placeholder="搜索名称、编码、型号或类型"
@input="fetchAvailableChildren"
/>
</div>
<div v-if="availableChildren.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
没有可用的配件
{{ availableChildrenEmptyMessage }}
</div>
<div v-else class="mobile-card-list max-h-80 overflow-y-auto">
<div
v-for="item in availableChildren"
v-for="item in sortedAvailableChildren"
:key="item.id"
class="mobile-card cursor-pointer transition hover:border-accent-primary/40 hover:bg-bg-hover"
@click="confirmReplace(item)"
@ -133,7 +139,10 @@
<span class="badge badge-neutral">{{ item.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }}
{{ item.equipment_code }} · 型号:{{ item.equipment_model || '未填写型号' }}
</div>
<div class="text-xs text-text-tertiary">
单价:¥{{ formatPrice(item.unit_price) }}
</div>
</div>
</div>
@ -147,9 +156,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { getChildren, getAvailableChildren, mountChild, unmountChild, replaceChild } from '@/api/mount'
import type { ChildEquipment } from '@/api/mount'
import type { EquipmentStatus } from '@/types'
const props = defineProps<{
parentId: number
@ -168,11 +178,49 @@ const showReplaceModal = ref(false)
const searchKeyword = ref('')
const replacingChild = ref<ChildEquipment | null>(null)
type ReleasedChildStatus = Extract<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
const releasedChildStatusOptions: ReleasedChildStatus[] = ['可用', '维护中', '待报废', '已报废']
const childTypeSortOrder: Record<string, number> = {
cpu: 1,
主板: 2,
内存条: 3,
固态硬盘: 4,
机械硬盘: 5,
显卡: 6
}
const formatPrice = (value: number | string | null | undefined) => {
const amount = Number(value || 0)
return amount.toFixed(2)
}
const compareChildEquipment = (left: ChildEquipment, right: ChildEquipment) => {
const typeOrderDiff =
(childTypeSortOrder[left.equipment_type] || 999) - (childTypeSortOrder[right.equipment_type] || 999)
if (typeOrderDiff !== 0) {
return typeOrderDiff
}
const modelDiff = (left.equipment_model || '').localeCompare(right.equipment_model || '', 'zh-CN')
if (modelDiff !== 0) {
return modelDiff
}
return (left.equipment_code || '').localeCompare(right.equipment_code || '', 'zh-CN')
}
const sortedChildren = computed(() => [...children.value].sort(compareChildEquipment))
const sortedAvailableChildren = computed(() => [...availableChildren.value].sort(compareChildEquipment))
const availableChildrenEmptyMessage = computed(() => {
const keyword = searchKeyword.value.trim()
if (keyword) {
return `没有符合“${keyword}”的空闲配件。这里只显示还没有挂到任何主机上的独立配件。`
}
return '当前没有空闲可挂载的独立配件。首页里看到的“可用”数量,可能已经挂在主机里,所以这里不会出现。'
})
const fetchChildren = async () => {
try {
const response = await getChildren(props.parentId)
@ -203,6 +251,25 @@ const openMountModal = async () => {
await fetchAvailableChildren()
}
const promptReleasedChildStatus = (childName: string, actionLabel: string): ReleasedChildStatus | null => {
const input = window.prompt(
`${actionLabel}后,"${childName}" 要设置成什么状态?请输入:${releasedChildStatusOptions.join(' / ')}`,
'可用'
)
if (input == null) {
return null
}
const normalized = input.trim() as ReleasedChildStatus
if (!releasedChildStatusOptions.includes(normalized)) {
alert(`请输入以下状态之一:${releasedChildStatusOptions.join(' / ')}`)
return null
}
return normalized
}
const selectChild = async (child: ChildEquipment) => {
if (!confirm(`确定要挂载 "${child.equipment_name}" 吗?`)) return
try {
@ -217,8 +284,13 @@ const selectChild = async (child: ChildEquipment) => {
const handleUnmount = async (child: ChildEquipment) => {
if (!confirm(`确定要卸载 "${child.equipment_name}" 吗?`)) return
const releasedChildStatus = promptReleasedChildStatus(child.equipment_name, '卸载')
if (!releasedChildStatus) return
try {
await unmountChild(props.parentId, { child_id: child.id })
await unmountChild(props.parentId, {
child_id: child.id,
released_child_status: releasedChildStatus
})
await fetchChildren()
emit('updated')
} catch (error: any) {
@ -236,10 +308,13 @@ const handleReplace = (child: ChildEquipment) => {
const confirmReplace = async (newChild: ChildEquipment) => {
if (!replacingChild.value) return
if (!confirm(`确定要用 "${newChild.equipment_name}" 替换 "${replacingChild.value.equipment_name}" 吗?`)) return
const releasedChildStatus = promptReleasedChildStatus(replacingChild.value.equipment_name, '更换')
if (!releasedChildStatus) return
try {
await replaceChild(props.parentId, {
old_child_id: replacingChild.value.id,
new_child_id: newChild.id
new_child_id: newChild.id,
released_child_status: releasedChildStatus
})
showReplaceModal.value = false
replacingChild.value = null

View File

@ -519,6 +519,8 @@ export interface DashboardStats {
total_quantity: number
available_quantity: number
borrowed_quantity: number
mountable_available_quantity: number
is_child_type: boolean
}>
}

View File

@ -103,7 +103,12 @@
<div>
<p class="text-sm font-medium text-text-primary">{{ item.equipment_type }}</p>
<p class="text-xs text-text-tertiary">
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
<template v-if="item.is_child_type">
可用 {{ formatNumber(item.available_quantity) }} · 空闲可挂载 {{ formatNumber(item.mountable_available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
<template v-else>
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
</p>
</div>
<span class="text-lg font-semibold text-text-primary">{{ formatNumber(item.total_quantity) }}</span>

View File

@ -155,38 +155,20 @@
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button v-if="hasChildren(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
{{ expandedIds.has(equipment.id) ? '收起' : `配件(${equipment.children?.length || 0})` }}
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }}
</button>
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
</div>
<!-- 子资产展开 -->
<div v-if="expandedIds.has(equipment.id) && equipment.children?.length" class="mt-3 space-y-2 border-t border-border pt-3">
<div v-for="child in equipment.children" :key="`mobile-child-${child.id}`" class="rounded-lg border border-border bg-bg-elevated/50 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ child.equipment_name }}</p>
<p class="text-xs font-mono text-text-tertiary">{{ child.equipment_code }}</p>
</div>
<span class="badge badge-neutral">配件</span>
</div>
<div class="mt-2 mobile-card__meta">
<div>
<p class="mobile-card__label">类型</p>
<p class="mobile-card__value">{{ child.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">使用人</p>
<p class="mobile-card__value">{{ getCurrentBorrowerLabel(child) }}</p>
</div>
<div>
<p class="mobile-card__label">单价</p>
<p class="mobile-card__value">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</p>
</div>
</div>
<div v-if="expandedIds.has(equipment.id) && canManageMountAsset(equipment)" class="mt-3 space-y-3 border-t border-border pt-3">
<div class="rounded-lg border border-border bg-bg-elevated/30 p-3">
<MountManager :parent-id="equipment.id" @updated="handleMountUpdated(equipment.id)" />
</div>
<div class="rounded-lg border border-border bg-bg-elevated/30 p-3">
<MountHistory :key="`mobile-history-${equipment.id}-${getMountPanelVersion(equipment.id)}`" :equipment-id="equipment.id" />
</div>
</div>
</article>
@ -211,11 +193,11 @@
</thead>
<tbody>
<template v-for="equipment in displayList" :key="equipment.id">
<tr class="text-text-primary text-sm transition" :class="{ 'bg-status-success/5': expandedIds.has(equipment.id), 'cursor-pointer': hasChildren(equipment) }" @click="hasChildren(equipment) && toggleExpand(equipment.id)">
<tr class="text-text-primary text-sm transition" :class="{ 'bg-status-success/5': expandedIds.has(equipment.id), 'cursor-pointer': canManageMountAsset(equipment) }" @click="canManageMountAsset(equipment) && toggleExpand(equipment.id)">
<td class="align-top">
<div class="flex items-start gap-3 pt-1">
<input type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" @click.stop class="rounded" />
<button v-if="hasChildren(equipment)" type="button" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated text-text-secondary transition hover:bg-bg-hover hover:text-text-primary">
<button v-if="canManageMountAsset(equipment)" type="button" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated text-text-secondary transition hover:bg-bg-hover hover:text-text-primary">
<ChevronRightIcon class="h-4 w-4 transition-transform" :class="{ 'rotate-90': expandedIds.has(equipment.id) }" />
</button>
<div v-else class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated text-xs text-text-tertiary">
@ -227,9 +209,9 @@
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate text-sm font-semibold text-text-primary">{{ equipment.equipment_name }}</p>
<span v-if="isParentAsset(equipment)" class="badge badge-success">父资产</span>
<span v-if="canManageMountAsset(equipment)" class="badge badge-success">父资产</span>
<span v-else class="badge badge-neutral">独立资产</span>
<span v-if="hasChildren(equipment)" class="badge badge-neutral">{{ equipment.children?.length || 0 }} 个配件</span>
<span v-if="hasMountedChildren(equipment)" class="badge badge-neutral">{{ equipment.children?.length || 0 }} 个配件</span>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-tertiary">
<span class="font-mono">{{ equipment.equipment_code }}</span>
@ -257,6 +239,9 @@
</td>
<td class="align-top">
<div class="table-action-group justify-end pt-1">
<button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary">
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
</button>
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
@ -264,40 +249,14 @@
</td>
</tr>
<!-- 子资产展开行 -->
<tr v-if="expandedIds.has(equipment.id) && equipment.children?.length" class="bg-bg-tertiary/40">
<tr v-if="expandedIds.has(equipment.id) && canManageMountAsset(equipment)" class="bg-bg-tertiary/40">
<td colspan="5" class="px-3 py-3">
<div class="rounded-lg border border-border bg-bg-elevated/40 p-3">
<div class="flex items-center justify-between gap-4">
<h4 class="section-title">已挂载配件</h4>
<span class="badge badge-neutral">{{ equipment.children.length }} 项</span>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<div class="rounded-lg border border-border bg-bg-elevated/30 p-4">
<MountManager :parent-id="equipment.id" @updated="handleMountUpdated(equipment.id)" />
</div>
<div class="mt-3 grid gap-2 xl:grid-cols-2">
<article v-for="child in equipment.children" :key="'child-' + child.id" class="panel">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<input type="checkbox" :checked="selectedIds.includes(child.id)" @change="toggleSelect(child.id)" class="mt-1 rounded" />
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate text-sm font-semibold text-text-primary">{{ child.equipment_name }}</p>
<span class="badge badge-neutral">子资产</span>
<span v-if="child.status" class="badge" :class="getStatusBadgeClass(child.status)">{{ child.status }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-tertiary">
<span class="font-mono">{{ child.equipment_code }}</span>
<span>{{ child.equipment_type || '未设置类型' }}</span>
<span>使用人:{{ getCurrentBorrowerLabel(child) }}</span>
</div>
</div>
</div>
<p class="text-sm font-semibold text-text-primary">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</p>
</div>
<div class="mt-3 table-action-group justify-end">
<button @click="handleEdit(child.id)" class="table-action table-action--primary">编辑</button>
<button @click="handlePrintBarcode(child.id)" class="table-action table-action--highlight">标签</button>
<button @click="handleDelete(child.id)" class="table-action table-action--danger">删除</button>
</div>
</article>
<div class="rounded-lg border border-border bg-bg-elevated/30 p-4">
<MountHistory :key="`desktop-history-${equipment.id}-${getMountPanelVersion(equipment.id)}`" :equipment-id="equipment.id" />
</div>
</div>
</td>
@ -426,6 +385,8 @@ import { batchImportEquipments } from '@/api/batchImport'
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
import BarcodePrint from '@/components/BarcodePrint.vue'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import MountHistory from '@/components/MountHistory.vue'
import MountManager from '@/components/MountManager.vue'
import type { Equipment, Person, Department, AssetType, StorageLocation } from '@/types'
const router = useRouter()
@ -441,6 +402,7 @@ const persons = ref<Person[]>([])
const departments = ref<Department[]>([])
const assetTypes = ref<AssetType[]>([])
const storageLocations = ref<StorageLocation[]>([])
const mountPanelVersions = ref<Record<number, number>>({})
const filters = ref({
keyword: '',
@ -454,7 +416,7 @@ const filters = ref({
const pageSummary = computed(() => {
const available = list.value.filter(e => e.status === '可用').length
const borrowed = list.value.filter(e => e.status === '已借出').length
const parentAssets = list.value.filter(e => e.children && e.children.length > 0).length
const parentAssets = list.value.filter(canManageMountAsset).length
const childAssets = list.value.reduce((sum, e) => sum + (e.children?.length || 0), 0)
return { available, borrowed, parentAssets, childAssets }
})
@ -670,8 +632,17 @@ const submitBatchTransfer = async () => {
fetchData()
}
const isParentAsset = (e: Equipment) => e.children && e.children.length > 0
const hasChildren = (e: Equipment) => e.children && e.children.length > 0
function canManageMountAsset(equipment: Equipment) {
return Boolean(
assetTypes.value.some((assetType) => assetType.can_be_parent && assetType.name === equipment.equipment_type) ||
equipment.children?.length
)
}
function hasMountedChildren(equipment: Equipment) {
return Boolean(equipment.children && equipment.children.length > 0)
}
const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => {
const currentBorrow = equipment.current_borrow
if (!currentBorrow) {
@ -706,6 +677,17 @@ const toggleExpand = (id: number) => {
else expandedIds.value.add(id)
}
const getMountPanelVersion = (equipmentId: number) => mountPanelVersions.value[equipmentId] || 0
const handleMountUpdated = async (equipmentId: number) => {
mountPanelVersions.value = {
...mountPanelVersions.value,
[equipmentId]: getMountPanelVersion(equipmentId) + 1
}
await fetchData()
expandedIds.value.add(equipmentId)
}
const getStatusBadgeClass = (status: string) => {
const map: Record<string, string> = {
'可用': 'badge-success',