GuDingZiChan/backend/scripts/prepareParentChildMigrationDryRun.js
2026-03-24 14:21:09 +08:00

734 lines
23 KiB
JavaScript

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)
})