完善父子资产迁移与挂载管理
This commit is contained in:
parent
247c3107b5
commit
9519c10b78
231
backend/scripts/exportManualReviewCandidates.js
Normal file
231
backend/scripts/exportManualReviewCandidates.js
Normal 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)
|
||||
})
|
||||
534
backend/scripts/hardwareQuantitySplit.js
Normal file
534
backend/scripts/hardwareQuantitySplit.js
Normal 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
|
||||
}
|
||||
854
backend/scripts/importParentChildMigrationToLive.js
Normal file
854
backend/scripts/importParentChildMigrationToLive.js
Normal 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)
|
||||
})
|
||||
733
backend/scripts/prepareParentChildMigrationDryRun.js
Normal file
733
backend/scripts/prepareParentChildMigrationDryRun.js
Normal 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)
|
||||
})
|
||||
893
backend/scripts/repairMultiUnitMountedChildren.js
Normal file
893
backend/scripts/repairMultiUnitMountedChildren.js
Normal 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)
|
||||
})
|
||||
@ -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 } }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 || '-' }}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -519,6 +519,8 @@ export interface DashboardStats {
|
||||
total_quantity: number
|
||||
available_quantity: number
|
||||
borrowed_quantity: number
|
||||
mountable_available_quantity: number
|
||||
is_child_type: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user