515 lines
18 KiB
JavaScript
515 lines
18 KiB
JavaScript
const fs = require('fs')
|
||
const path = require('path')
|
||
const { spawnSync } = require('child_process')
|
||
|
||
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
|
||
|
||
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
|
||
const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx')
|
||
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
|
||
|
||
const normalizeText = (value) =>
|
||
String(value == null ? '' : value)
|
||
.replace(/\r/g, ' ')
|
||
.replace(/\n/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
|
||
const requireArgValue = (args, index, argumentName) => {
|
||
const value = args[index + 1]
|
||
if (!value || String(value).startsWith('--')) {
|
||
throw new Error(`${argumentName} 缺少参数值`)
|
||
}
|
||
return value
|
||
}
|
||
|
||
const parseArgs = (args = process.argv.slice(2)) => {
|
||
const options = {
|
||
inputDir: '',
|
||
importBackupDir: '',
|
||
output: DEFAULT_OUTPUT,
|
||
nullLocationJson: '',
|
||
plinkPath: '',
|
||
sshHost: '',
|
||
sshHostkey: '',
|
||
sshPassword: ''
|
||
}
|
||
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index]
|
||
if (arg === '--input-dir') {
|
||
options.inputDir = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--import-backup-dir') {
|
||
options.importBackupDir = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--output') {
|
||
options.output = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--null-location-json') {
|
||
options.nullLocationJson = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--plink-path') {
|
||
options.plinkPath = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--ssh-host') {
|
||
options.sshHost = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--ssh-hostkey') {
|
||
options.sshHostkey = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--ssh-password') {
|
||
options.sshPassword = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else {
|
||
throw new Error(`不支持的参数: ${arg}`)
|
||
}
|
||
}
|
||
|
||
return options
|
||
}
|
||
|
||
const resolveRemoteConnection = (options, env = process.env) => {
|
||
const connection = {
|
||
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
|
||
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
|
||
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
|
||
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
|
||
}
|
||
const missing = []
|
||
|
||
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
|
||
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
|
||
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
|
||
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
|
||
|
||
if (missing.length > 0) {
|
||
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
|
||
}
|
||
|
||
return connection
|
||
}
|
||
|
||
const resolveLatestDir = (prefix) => {
|
||
const candidates = fs
|
||
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix))
|
||
.map((entry) => entry.name)
|
||
.sort()
|
||
|
||
if (!candidates.length) {
|
||
throw new Error(`未找到目录前缀 ${prefix} 的输出结果`)
|
||
}
|
||
|
||
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
|
||
}
|
||
|
||
const resolvePathArg = (value, fallbackPrefix) => {
|
||
if (!value) {
|
||
return resolveLatestDir(fallbackPrefix)
|
||
}
|
||
|
||
const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value)
|
||
if (!fs.existsSync(resolved)) {
|
||
throw new Error(`路径不存在: ${resolved}`)
|
||
}
|
||
return resolved
|
||
}
|
||
|
||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||
|
||
const readJsonl = (filePath) =>
|
||
fs
|
||
.readFileSync(filePath, 'utf8')
|
||
.split(/\r?\n/)
|
||
.filter(Boolean)
|
||
.map((line) => JSON.parse(line))
|
||
|
||
const parseDescription = (description) => {
|
||
const result = {}
|
||
const normalized = normalizeText(description)
|
||
if (!normalized) {
|
||
return result
|
||
}
|
||
|
||
for (const part of normalized.split(';')) {
|
||
const trimmed = normalizeText(part)
|
||
if (!trimmed) {
|
||
continue
|
||
}
|
||
|
||
const splitIndex = trimmed.indexOf('=')
|
||
if (splitIndex === -1) {
|
||
continue
|
||
}
|
||
|
||
const key = normalizeText(trimmed.slice(0, splitIndex))
|
||
const value = normalizeText(trimmed.slice(splitIndex + 1))
|
||
if (key) {
|
||
result[key] = value
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
const withEditableFields = (row, fields) => ({
|
||
...row,
|
||
...fields
|
||
})
|
||
|
||
const buildPendingRows = (records) =>
|
||
records
|
||
.filter((record) => record.bucket === 'pending-review')
|
||
.map((record) =>
|
||
withEditableFields(
|
||
{
|
||
source_row: record.sourceRow,
|
||
legacy_asset_code: normalizeText(record.assetCodeRaw),
|
||
asset_name: normalizeText(record.assetNameRaw),
|
||
source_status: normalizeText(record.currentStatusRaw),
|
||
raw_owner_name: normalizeText(record.ownerRaw),
|
||
raw_department: normalizeText(record.departmentRaw),
|
||
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
|
||
raw_location: normalizeText(record.addressRaw),
|
||
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
|
||
suggested_action:
|
||
Array.isArray(record.pendingReasons) && record.pendingReasons.includes('unmapped_department')
|
||
? '先确认业务部门映射'
|
||
: '人工核对后再补录'
|
||
},
|
||
{
|
||
行政填写_目标部门: '',
|
||
行政填写_目标员工: '',
|
||
行政填写_标准地址: '',
|
||
行政填写_资产状态: '',
|
||
行政填写_备注: ''
|
||
}
|
||
)
|
||
)
|
||
|
||
const flattenDuplicateRows = (duplicateDetails) =>
|
||
duplicateDetails.flatMap((item) =>
|
||
item.items.map((detail, index) => ({
|
||
legacy_code: item.legacyCode,
|
||
conflict_index: index + 1,
|
||
bucket: detail.bucket,
|
||
source_row: detail.sourceRow,
|
||
asset_name: detail.assetName,
|
||
owner: detail.owner,
|
||
department: detail.department,
|
||
source_status: detail.sourceStatus,
|
||
address: detail.address,
|
||
unit_price: detail.unitPrice,
|
||
purchase_date: detail.purchaseDate,
|
||
suggested_action: '同一个资产编码对应了多条记录,先人工确认保留哪一条'
|
||
}))
|
||
)
|
||
|
||
const runRemoteNodeScript = (script, connection) => {
|
||
const result = spawnSync(
|
||
connection.plinkPath,
|
||
[
|
||
'-batch',
|
||
'-ssh',
|
||
'-hostkey',
|
||
connection.sshHostkey,
|
||
'-pw',
|
||
connection.sshPassword,
|
||
connection.sshHost,
|
||
REMOTE_NODE_COMMAND
|
||
],
|
||
{
|
||
encoding: 'utf8',
|
||
input: script,
|
||
maxBuffer: 32 * 1024 * 1024,
|
||
windowsHide: true
|
||
}
|
||
)
|
||
|
||
if (result.error) {
|
||
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
|
||
}
|
||
|
||
if (result.status !== 0) {
|
||
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
|
||
}
|
||
|
||
const stdout = normalizeText(result.stdout)
|
||
if (!stdout) {
|
||
throw new Error('远程脚本返回为空')
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(stdout)
|
||
} catch {
|
||
throw new Error('远程脚本返回格式无效')
|
||
}
|
||
}
|
||
|
||
const readNullLocationRows = (nullLocationJsonArg, options) => {
|
||
const resolvedPath = nullLocationJsonArg
|
||
? path.isAbsolute(nullLocationJsonArg)
|
||
? nullLocationJsonArg
|
||
: path.resolve(process.cwd(), nullLocationJsonArg)
|
||
: ''
|
||
|
||
if (resolvedPath) {
|
||
if (!fs.existsSync(resolvedPath)) {
|
||
throw new Error(`待补地址 JSON 不存在: ${resolvedPath}`)
|
||
}
|
||
|
||
return readJson(resolvedPath)
|
||
}
|
||
|
||
return runRemoteNodeScript(buildRemoteNullLocationScript(), resolveRemoteConnection(options))
|
||
}
|
||
|
||
const buildRemoteNullLocationScript = () => `
|
||
let PrismaClient
|
||
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
|
||
const prisma = new PrismaClient()
|
||
;(async () => {
|
||
const rows = await prisma.equipment.findMany({
|
||
where: { storage_location_id: null },
|
||
orderBy: [{ status: 'asc' }, { equipment_code: 'asc' }],
|
||
select: {
|
||
equipment_code: true,
|
||
equipment_name: true,
|
||
equipment_type: true,
|
||
status: true,
|
||
unit_price: true,
|
||
purchase_date: true,
|
||
description: true,
|
||
department: { select: { name: true } }
|
||
}
|
||
})
|
||
|
||
process.stdout.write(JSON.stringify(rows))
|
||
await prisma.$disconnect()
|
||
})().catch(async (error) => {
|
||
console.error(error && error.stack ? error.stack : String(error))
|
||
try { await prisma.$disconnect() } catch {}
|
||
process.exit(1)
|
||
})
|
||
`
|
||
|
||
const buildOwnerManualRows = (records, duplicateCodeSet) =>
|
||
records
|
||
.filter(
|
||
(record) =>
|
||
record.bucket === 'import-without-owner' &&
|
||
record.statusKind === 'assigned' &&
|
||
normalizeText(record.assetCodeRaw) &&
|
||
!duplicateCodeSet.has(normalizeText(record.assetCodeRaw).toUpperCase())
|
||
)
|
||
.map((record) =>
|
||
withEditableFields(
|
||
{
|
||
source_row: record.sourceRow,
|
||
legacy_asset_code: normalizeText(record.assetCodeRaw).toUpperCase(),
|
||
asset_name: normalizeText(record.assetNameRaw),
|
||
source_status: normalizeText(record.currentStatusRaw),
|
||
raw_owner_name: normalizeText(record.ownerRaw),
|
||
raw_department: normalizeText(record.departmentRaw),
|
||
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
|
||
raw_location: normalizeText(record.addressRaw),
|
||
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
|
||
suggested_action: '资产已导入,但当前使用人还没落到系统里;请行政核对后补登记使用人'
|
||
},
|
||
{
|
||
行政填写_目标员工: '',
|
||
行政填写_备注: ''
|
||
}
|
||
)
|
||
)
|
||
|
||
const buildOwnerAliasSummaryRows = (records, duplicateCodeSet) => {
|
||
const grouped = new Map()
|
||
|
||
for (const record of records) {
|
||
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
|
||
const ownerName = normalizeText(record.ownerRaw)
|
||
if (
|
||
record.bucket !== 'import-without-owner' ||
|
||
record.statusKind !== 'assigned' ||
|
||
!legacyCode ||
|
||
duplicateCodeSet.has(legacyCode) ||
|
||
!ownerName
|
||
) {
|
||
continue
|
||
}
|
||
|
||
const key = ownerName
|
||
if (!grouped.has(key)) {
|
||
grouped.set(key, {
|
||
owner_name: ownerName,
|
||
asset_count: 0,
|
||
departments: new Set(),
|
||
pending_reasons: new Set(),
|
||
asset_codes: []
|
||
})
|
||
}
|
||
|
||
const current = grouped.get(key)
|
||
current.asset_count += 1
|
||
current.departments.add(normalizeText(record.departmentRaw))
|
||
for (const reason of Array.isArray(record.pendingReasons) ? record.pendingReasons : []) {
|
||
current.pending_reasons.add(reason)
|
||
}
|
||
current.asset_codes.push(legacyCode)
|
||
}
|
||
|
||
return [...grouped.values()]
|
||
.map((item) =>
|
||
withEditableFields(
|
||
{
|
||
owner_name: item.owner_name,
|
||
asset_count: item.asset_count,
|
||
departments: [...item.departments].filter(Boolean).join('、'),
|
||
pending_reasons: [...item.pending_reasons].filter(Boolean).join('|'),
|
||
sample_asset_codes: item.asset_codes.slice(0, 10).join('、'),
|
||
suggested_action: '先核对该花名对应的当前在职员工,再统一补挂名下资产'
|
||
},
|
||
{
|
||
行政填写_目标员工: '',
|
||
行政填写_备注: ''
|
||
}
|
||
)
|
||
)
|
||
.sort((left, right) => right.asset_count - left.asset_count || left.owner_name.localeCompare(right.owner_name, 'zh-CN'))
|
||
}
|
||
|
||
const buildNullLocationRows = (rows) =>
|
||
rows.map((row) => {
|
||
const metadata = parseDescription(row.description)
|
||
return withEditableFields(
|
||
{
|
||
source_row: metadata.source_row || '',
|
||
legacy_asset_code: normalizeText(row.equipment_code),
|
||
asset_name: normalizeText(row.equipment_name),
|
||
equipment_type: normalizeText(row.equipment_type),
|
||
current_status: normalizeText(row.status),
|
||
current_department: normalizeText(row.department?.name || ''),
|
||
raw_department: metadata.raw_department || '',
|
||
raw_owner: metadata.raw_owner || '',
|
||
raw_location: metadata.raw_location || '',
|
||
purchase_date: row.purchase_date ? String(row.purchase_date).slice(0, 10) : '',
|
||
unit_price: normalizeText(row.unit_price),
|
||
suggested_action: '资产已导入,但没有标准地址;请行政确认后补齐标准存放地点'
|
||
},
|
||
{
|
||
行政填写_标准地址: '',
|
||
行政填写_备注: ''
|
||
}
|
||
)
|
||
})
|
||
|
||
const appendSheet = (workbook, name, rows) => {
|
||
const safeRows = rows.length ? rows : [{ message: '当前没有数据' }]
|
||
const sheet = XLSX.utils.json_to_sheet(safeRows)
|
||
XLSX.utils.book_append_sheet(workbook, sheet, name)
|
||
}
|
||
|
||
const writeWorkbookWithFallback = (workbook, outputPath) => {
|
||
try {
|
||
XLSX.writeFile(workbook, outputPath)
|
||
return outputPath
|
||
} catch (error) {
|
||
if (error && error.code === 'EBUSY') {
|
||
const parsed = path.parse(outputPath)
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
|
||
const fallbackPath = path.join(parsed.dir, `${parsed.name}-${timestamp}${parsed.ext}`)
|
||
XLSX.writeFile(workbook, fallbackPath)
|
||
return fallbackPath
|
||
}
|
||
|
||
throw error
|
||
}
|
||
}
|
||
|
||
const run = () => {
|
||
const options = parseArgs()
|
||
const inputDir = resolvePathArg(options.inputDir, 'migration_dry_run_')
|
||
const importBackupDir = resolvePathArg(options.importBackupDir, 'live_import_backup_')
|
||
const outputPath = path.isAbsolute(options.output)
|
||
? options.output
|
||
: path.resolve(process.cwd(), options.output)
|
||
|
||
const normalizedRecords = readJsonl(path.join(inputDir, 'normalized_assets.jsonl'))
|
||
const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json'))
|
||
const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json'))
|
||
const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json'))
|
||
const liveNullLocationRows = readNullLocationRows(options.nullLocationJson, options)
|
||
|
||
const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode))
|
||
const pendingRows = buildPendingRows(normalizedRecords)
|
||
const ownerManualRows = buildOwnerManualRows(normalizedRecords, duplicateCodeSet)
|
||
const ownerAliasSummaryRows = buildOwnerAliasSummaryRows(normalizedRecords, duplicateCodeSet)
|
||
const flattenedDuplicateRows = flattenDuplicateRows(duplicateCodes.duplicateDetails)
|
||
const nullLocationRows = buildNullLocationRows(liveNullLocationRows)
|
||
|
||
const workbook = XLSX.utils.book_new()
|
||
|
||
appendSheet(workbook, '概览', [
|
||
{ 项目: 'dry-run 待人工确认', 数量: pendingRows.length },
|
||
{ 项目: '缺少资产编码', 数量: missingCode.excludedMissingCodeCount || 0 },
|
||
{ 项目: '重复资产编码(编码数)', 数量: duplicateCodes.excludedDuplicateCodeCount || 0 },
|
||
{ 项目: '重复资产编码(记录数)', 数量: duplicateCodes.excludedDuplicateRecordCount || 0 },
|
||
{ 项目: '已导入待补使用人', 数量: ownerManualRows.length },
|
||
{ 项目: '待核对历史花名', 数量: ownerAliasSummaryRows.length },
|
||
{ 项目: 'live 待补标准地址', 数量: nullLocationRows.length },
|
||
{ 项目: '本轮已导入总数', 数量: plannedSummary.finalImportCount || 0 },
|
||
{ 项目: '其中已导入不带使用人', 数量: plannedSummary.finalImportWithoutOwnerCount || 0 }
|
||
])
|
||
appendSheet(workbook, '填写说明', [
|
||
{ 工作表: '待确认部门', 说明: '只在“行政填写_目标部门 / 目标员工 / 标准地址 / 资产状态”里填写需要修正的值。' },
|
||
{ 工作表: '待补使用人', 说明: '在“行政填写_目标员工”里填写当前在职员工姓名或花名。' },
|
||
{ 工作表: '花名核对', 说明: '同一个历史花名如果能统一对应到当前员工,优先在这里填写。' },
|
||
{ 工作表: '待补地址', 说明: '在“行政填写_标准地址”里填写系统里的标准地址名。' },
|
||
{ 工作表: '输出用途', 说明: '填写完成后可生成 migration_manual_overrides.json,再重新 dry-run / 导入。' }
|
||
])
|
||
appendSheet(workbook, '待确认部门', pendingRows)
|
||
appendSheet(workbook, '缺少编码', missingCode.rows || [])
|
||
appendSheet(workbook, '重复编码', flattenedDuplicateRows)
|
||
appendSheet(workbook, '待补使用人', ownerManualRows)
|
||
appendSheet(workbook, '花名核对', ownerAliasSummaryRows)
|
||
appendSheet(workbook, '待补地址', nullLocationRows)
|
||
|
||
const finalOutputPath = writeWorkbookWithFallback(workbook, outputPath)
|
||
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
requestedOutputPath: outputPath,
|
||
outputPath: finalOutputPath,
|
||
summary: {
|
||
pendingReview: pendingRows.length,
|
||
missingCode: missingCode.excludedMissingCodeCount || 0,
|
||
duplicateCodeCount: duplicateCodes.excludedDuplicateCodeCount || 0,
|
||
duplicateRecordCount: duplicateCodes.excludedDuplicateRecordCount || 0,
|
||
ownerManual: ownerManualRows.length,
|
||
ownerAliasSummary: ownerAliasSummaryRows.length,
|
||
nullLocation: nullLocationRows.length
|
||
}
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
)
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
run()
|
||
} catch (error) {
|
||
console.error(error && error.stack ? error.stack : String(error))
|
||
process.exit(1)
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
parseArgs,
|
||
resolveRemoteConnection
|
||
}
|