734 lines
23 KiB
JavaScript
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)
|
|
})
|