GuDingZiChan/backend/scripts/prepareSheet2SupplementDryRun.js

612 lines
20 KiB
JavaScript
Raw Permalink 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 ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const {
mergeCandidateRowsWithSourceCodes,
normalizeLookupKey,
normalizeText,
resolveSheet2LocationMatch
} = require('./sheet2PublicSupplementUtils')
const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx')
const DEFAULT_SOURCE_SHEET = 'Sheet2'
const DEFAULT_CANDIDATE_WORKBOOK = path.resolve(
__dirname,
'../../docs/总表待导入-Sheet2_严格可安全补导候选-2026-03-27.xlsx'
)
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DOCS_ROOT = path.resolve(__dirname, '../../docs')
const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx'
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEPARTMENT_ALIASES = {
'3D视觉(广东)': '视觉中心',
'3D视觉(常州)': '视觉中心',
事业一部: '运营中心A',
事业二部: '运营中心A',
事业三部: '运营中心A',
事业四部: '运营中心A',
策划一部: '品牌策划中心',
策划二部: '品牌策划中心',
策划三部: '品牌策划中心',
策划四部: '品牌策划中心',
策划五部: '品牌策划中心',
策划六部: '品牌策划中心',
客服二部: '客服中心',
客服三部: '客服中心',
客服四部: '客服中心',
客服五部: '客服中心',
客服六部: '客服中心',
品牌营销策划中心: '品牌策划中心',
品牌创意视觉中心: '视觉中心',
品牌供应链中心: '供应链中心',
智管信息中心: '效率部(懂业务的技术部)',
总账组: '财务中心',
结算组: '财务中心',
视觉一部: '视觉中心',
视觉二部: '视觉中心',
视觉三部: '视觉中心',
视觉四部: '视觉中心',
视觉五部: '视觉中心',
视觉六部: '视觉中心',
'基本盘 采购部': '供应链中心',
'基本盘 核价组': '供应链中心',
产品视觉中心: '视觉中心',
分销外贸: '外贸部'
}
const PUBLIC_STATUS_MAP = {
在用: '可用',
闲置: '可用'
}
const buildTimestampLabel = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
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 = {
sourceWorkbook: DEFAULT_SOURCE_WORKBOOK,
sourceSheet: DEFAULT_SOURCE_SHEET,
candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK,
outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME),
assetCodes: [],
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--source-workbook') {
options.sourceWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--source-sheet') {
options.sourceSheet = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--candidate-workbook') {
options.candidateWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--output-workbook') {
options.outputWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--asset-codes') {
options.assetCodes = String(requireArgValue(args, index, arg))
.split(/[,\s]+/)
.map((item) => normalizeText(item).toUpperCase())
.filter(Boolean)
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 resolvePathArg = (value) => {
const normalized = normalizeText(value)
if (!normalized) {
return ''
}
return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized)
}
const ensureOutputDir = () => {
const timestamp = buildTimestampLabel()
const outputDir = path.join(OUTPUT_ROOT, `sheet2_public_dry_run_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const worksheet = workbook.addWorksheet(sheet.name)
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 6, 14), 40)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true, name: 'Arial' }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.eachRow((row) => {
row.font = { name: 'Arial' }
row.alignment = { vertical: 'middle', wrapText: true }
})
}
await workbook.xlsx.writeFile(filePath)
}
const resolveAvailableWorkbookPath = (preferredPath) => {
const resolvedPreferredPath = resolvePathArg(preferredPath)
if (!resolvedPreferredPath) {
throw new Error('输出工作簿路径不能为空')
}
const workbookDir = path.dirname(resolvedPreferredPath)
const extension = path.extname(resolvedPreferredPath) || '.xlsx'
const baseName = path.basename(resolvedPreferredPath, extension)
const timestampedPath = path.join(workbookDir, `${baseName}-${buildTimestampLabel()}${extension}`)
try {
const fd = fs.openSync(resolvedPreferredPath, 'a')
fs.closeSync(fd)
return resolvedPreferredPath
} catch (error) {
if (error && (error.code === 'EBUSY' || error.code === 'EPERM')) {
return timestampedPath
}
throw error
}
}
const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' })
if (!matrix.length) {
return new Map()
}
const headerIndex = new Map()
matrix[0].forEach((header, index) => {
headerIndex.set(normalizeText(header), index)
})
const codeColumnIndex = headerIndex.get('资产编码')
if (codeColumnIndex === undefined) {
throw new Error('源工作表缺少“资产编码”列')
}
const rowMap = new Map()
for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) {
const row = matrix[rowIndex]
const code = normalizeText(row[codeColumnIndex]).toUpperCase()
if (code && !rowMap.has(code)) {
rowMap.set(code, rowIndex + 1)
}
}
return rowMap
}
const loadSourceRows = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
return XLSX.utils.sheet_to_json(sheet, { defval: '' }).map((row) => ({
...row,
来源工作表: normalizeText(row.来源工作表) || sourceSheetName
}))
}
const loadCandidateRows = (candidateWorkbookPath) => {
const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { defval: '' })
}
const fetchLiveRefs = (connection) => {
const script = `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({
where: { status: 'active' },
select: { id: true, name: true, short_name: true },
orderBy: { name: 'asc' }
}),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
}),
prisma.storageLocation.findMany({
where: { status: 'active' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
})
])
console.log(JSON.stringify({ assetTypes, departments, locations }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
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 buildImportDescription = (row, sourceRow) => {
const parts = [
['source_workbook', 'docs/总表待导入.xlsx'],
['source_sheet', 'Sheet2'],
['source_row', sourceRow || ''],
['source_status', normalizeText(row['当前状态'])],
['raw_department', normalizeText(row['部门'])],
['raw_location', normalizeText(row['地址'])],
['raw_asset_category', normalizeText(row['资产分类'])]
]
return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ')
}
const buildReadyImportPayload = (rows) =>
rows.map((row) => ({
legacyCode: row.资产编码,
equipmentName: row.资产名称,
equipmentModel: row.规格型号 || null,
equipmentType: row.资产名称,
departmentName: row.系统归并部门 || '',
storageLocationName: row.系统归并地址 || '',
systemStatus: row.系统建议状态,
purchaseDate: null,
supplier: '',
unitPrice: 0,
description: row.导入备注
}))
const createRowContext = (liveRefs) => {
const departmentByName = new Map(
liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item])
)
const locationByName = new Map(
liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item])
)
return { departmentByName, locationByName }
}
const classifySingleRow = (rawRow, sourceRowMap, rowContext) => {
const { departmentByName, locationByName } = rowContext
const assetCode = normalizeText(rawRow['资产编码']).toUpperCase()
const assetName = normalizeText(rawRow['资产名称'])
const rawDepartment = normalizeText(rawRow['部门'])
const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment
const departmentMatch = resolvedDepartment
? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null
: null
const rawLocation = normalizeText(rawRow['地址'])
const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName)
const rawStatus = normalizeText(rawRow['当前状态'])
const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || ''
const sourceRow = sourceRowMap.get(assetCode) || ''
const baseRow = {
来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2',
源数据行号: sourceRow,
资产编码: assetCode,
资产名称: assetName,
当前状态: rawStatus,
系统建议状态: systemStatus,
资产分类: normalizeText(rawRow['资产分类']),
部门: rawDepartment,
系统归并部门: departmentMatch?.name || resolvedDepartment,
地址: rawLocation,
系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue,
地址归并规则: locationMatch.rule || '',
规格型号: normalizeText(rawRow['规格型号']),
导入备注: buildImportDescription(rawRow, sourceRow)
}
const reasons = []
if (!departmentMatch) {
reasons.push(`部门未匹配:${rawDepartment || '(空)'}`)
}
if (!locationMatch.match) {
reasons.push(`地址未匹配:${rawLocation || '(空)'}`)
}
if (!systemStatus) {
reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`)
}
return { baseRow, reasons }
}
const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
const readyRows = []
const pendingRows = []
for (const rawRow of candidateRows) {
const { baseRow, reasons } = classifySingleRow(rawRow, sourceRowMap, rowContext)
if (!reasons.length) {
readyRows.push(baseRow)
continue
}
pendingRows.push({
...baseRow,
待核对原因: reasons.join(';'),
建议处理: reasons.some((item) => item.startsWith('地址未匹配'))
? '先确认应归并到哪个系统地址,再导入'
: '先确认状态或部门后再导入'
})
}
return { readyRows, pendingRows }
}
const buildDuplicatePendingRows = (duplicateSourceCodes, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
return duplicateSourceCodes.map((item) => {
const firstRow = item.rows[0] || {}
const { baseRow } = classifySingleRow(firstRow, sourceRowMap, rowContext)
return {
...baseRow,
待核对原因: '同一资产编码在 Sheet2 内对应了不同设备信息,不能自动判断。',
建议处理: '同一编码对应了多条不同设备信息,先确认哪条才是真实记录'
}
})
}
const buildSummaryRows = ({
total,
ready,
pending,
addressPending,
otherPending,
liveRefs,
explicitCodeCount,
explicitMissingCodeCount,
duplicatePendingCount
}) => [
{ 项目: '候选总数', 说明: total },
{ 项目: '可直接补录', 说明: ready },
{ 项目: '待人工核对', 说明: pending },
{ 项目: '其中地址待核对', 说明: addressPending },
{ 项目: '其中其他待核对', 说明: otherPending },
{ 项目: '本次显式补回编码数', 说明: explicitCodeCount },
{ 项目: '显式编码仍未在源表找到', 说明: explicitMissingCodeCount },
{ 项目: '显式编码重复冲突数', 说明: duplicatePendingCount },
{ 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' },
{ 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' },
{ 项目: '线上部门数', 说明: liveRefs.departments.length },
{ 项目: '线上地址数', 说明: liveRefs.locations.length },
{ 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length }
]
const run = async () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook)
const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook)
if (!fs.existsSync(sourceWorkbookPath)) {
throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`)
}
if (!fs.existsSync(candidateWorkbookPath) && options.assetCodes.length === 0) {
throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`)
}
const outputDir = ensureOutputDir()
const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook)
const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet)
const sourceRows = loadSourceRows(sourceWorkbookPath, options.sourceSheet)
const baseCandidateRows = fs.existsSync(candidateWorkbookPath)
? loadCandidateRows(candidateWorkbookPath)
: []
const {
rows: candidateRows,
missingCodes: missingSourceCodes,
duplicateSourceCodes
} = mergeCandidateRowsWithSourceCodes(baseCandidateRows, sourceRows, options.assetCodes)
const liveRefs = fetchLiveRefs(connection)
const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs)
const duplicatePendingRows = buildDuplicatePendingRows(duplicateSourceCodes, sourceRowMap, liveRefs)
const allPendingRows = [...pendingRows, ...duplicatePendingRows]
const addressPendingRows = allPendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = allPendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const readyPayload = buildReadyImportPayload(readyRows)
const manifest = {
generatedAt: new Date().toISOString(),
sourceWorkbook: sourceWorkbookPath,
sourceSheet: options.sourceSheet,
candidateWorkbook: candidateWorkbookPath,
explicitAssetCodes: options.assetCodes,
explicitMissingCodes: missingSourceCodes,
duplicateSourceCodes: duplicateSourceCodes.map((item) => item.code),
totalCandidates: candidateRows.length + duplicatePendingRows.length,
readyCount: readyRows.length,
pendingCount: allPendingRows.length,
addressPendingCount: addressPendingRows.length,
otherPendingCount: otherPendingRows.length,
rulesVersion: 'sheet2-public-supplement-v1',
assumptions: [
'当前使用员工为空的记录按公共设备处理',
'在用/闲置统一映射为系统可用',
'地址不能 100% 确认时不自动导入'
]
}
await writeWorkbook(workbookPath, [
{
name: '汇总说明',
rows: buildSummaryRows({
total: candidateRows.length + duplicatePendingRows.length,
ready: readyRows.length,
pending: allPendingRows.length,
addressPending: addressPendingRows.length,
otherPending: otherPendingRows.length,
liveRefs,
explicitCodeCount: options.assetCodes.length,
explicitMissingCodeCount: missingSourceCodes.length,
duplicatePendingCount: duplicatePendingRows.length
})
},
{ name: '可直接补录', rows: readyRows },
{ name: '待人工核对', rows: allPendingRows },
{ name: '地址待核对', rows: addressPendingRows },
{ name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] }
])
writeJson(path.join(outputDir, 'sheet2_public_manifest.json'), manifest)
writeJson(path.join(outputDir, 'sheet2_public_ready_payload.json'), {
payloadCount: readyPayload.length,
payload: readyPayload
})
writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), {
pendingCount: allPendingRows.length,
rows: allPendingRows
})
writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs)
console.log(
JSON.stringify(
{
outputDir,
workbookPath,
manifest
},
null,
2
)
)
}
if (require.main === module) {
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})
}
module.exports = {
parseArgs,
resolveRemoteConnection
}