GuDingZiChan/backend/scripts/exportAdminManualReviewWorkbook.js

515 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}