GuDingZiChan/backend/scripts/prepareSheet2SupplementDryRun.js
WuYongJie 0dc3c09315 feat(资产与打印): 提交近期借还盘点与打印助手源码
- 提交借用单缺失异常处理、扫码借还搜索与站内消息快捷处理源码
- 提交盘点范围、多终端打印、组织同步与认证模式相关源码
- 提交 .NET/Node 打印助手及版本清单源码
2026-03-27 20:18:31 +08:00

551 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 ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { resolveStorageLocationMatch } = require('./locationNormalization')
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 PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || 'jsjj8888'
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 SHEET2_LOCATION_OVERRIDES = {
山东: '济南',
运营中心: '常州-天安',
客服中心: '常州-天安',
人力行政中心: '常州-天安',
庙西宿舍: '常州-仓库',
财务中心: '常州-天安',
天安17楼: '常州-天安',
运营中心B: '常州-天安',
供应链中心: '常州-天安',
'2楼': '常州-天安',
湖塘摄影棚: '影棚',
睢宁仓: '睢宁仓库',
横林二仓仓库: '常州-仓库',
横山桥: '常州-仓库',
客服二部: '常州-天安',
苏北仓: '苏北仓库',
仓储中心: '常州-仓库',
横林二仓二楼会议室: '常州-仓库',
横林二仓二楼张总办公室: '常州-仓库',
红星美凯龙: '常州-美凯龙',
天安三楼: '常州-天安',
'1楼 客服四部': '常州-天安',
'3楼 智管信息中心': '常州-天安',
常州运c: '常州-天安',
二楼: '常州-天安',
分销外贸: '常州-天安',
佛山顺德仓: '佛山-顺德仓库',
河北仓: '河北-仓库',
横林二仓采购办公室: '常州-仓库',
横林二仓仓库办公室: '常州-仓库',
横林二仓二楼顾总办公室: '常州-仓库',
横林二仓二楼贾总办公室: '常州-仓库',
横林二仓二楼朱总办公室: '常州-仓库',
横林二仓一楼仓储办公室: '常州-仓库',
客服六部: '常州-天安',
三楼: '常州-天安',
山东曹县: '山东-曹县',
事业一部: '常州-天安',
天安10号楼三楼机房: '常州-天安',
天安10号楼四楼厨房: '常州-天安',
天安10号楼一楼机房: '常州-天安',
天安四楼会议室: '常州-天安',
运营八部: '常州-天安',
运营十部: '常州-天安',
总经理室: '常州-天安'
}
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const buildTimestampLabel = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const parseArgs = () => {
const 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)
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--source-workbook') {
options.sourceWorkbook = args[index + 1] || options.sourceWorkbook
index += 1
} else if (arg === '--source-sheet') {
options.sourceSheet = args[index + 1] || options.sourceSheet
index += 1
} else if (arg === '--candidate-workbook') {
options.candidateWorkbook = args[index + 1] || options.candidateWorkbook
index += 1
} else if (arg === '--output-workbook') {
options.outputWorkbook = args[index + 1] || options.outputWorkbook
index += 1
}
}
return options
}
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 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 = () => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 SSH 密码,无法读取线上固定资产基础数据')
}
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(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`读取线上基础数据失败: ${result.stderr || result.stdout}`)
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('线上基础数据返回为空')
}
return JSON.parse(stdout)
}
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 resolveSheet2LocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
const override = SHEET2_LOCATION_OVERRIDES[normalized]
if (override) {
const match = locationByName.get(normalizeLookupKey(override)) || null
return {
rawValue: normalized,
normalizedValue: override,
match,
rule: 'sheet2-confirmed-override'
}
}
return resolveStorageLocationMatch(rawValue, locationByName)
}
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 classifyRows = (candidateRows, sourceRowMap, 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])
)
const readyRows = []
const pendingRows = []
for (const rawRow of candidateRows) {
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 || '(空)'}`)
}
if (!reasons.length) {
readyRows.push(baseRow)
continue
}
pendingRows.push({
...baseRow,
待核对原因: reasons.join(';'),
建议处理: reasons.some((item) => item.startsWith('地址未匹配'))
? '先确认应归并到哪个系统地址,再导入'
: '先确认状态或部门后再导入'
})
}
return { readyRows, pendingRows }
}
const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs }) => [
{ 项目: '候选总数', 说明: total },
{ 项目: '可直接补录', 说明: ready },
{ 项目: '待人工核对', 说明: pending },
{ 项目: '其中地址待核对', 说明: addressPending },
{ 项目: '其中其他待核对', 说明: otherPending },
{ 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' },
{ 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' },
{ 项目: '线上部门数', 说明: liveRefs.departments.length },
{ 项目: '线上地址数', 说明: liveRefs.locations.length },
{ 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length }
]
const run = async () => {
const options = parseArgs()
const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook)
const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook)
if (!fs.existsSync(sourceWorkbookPath)) {
throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`)
}
if (!fs.existsSync(candidateWorkbookPath)) {
throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`)
}
const outputDir = ensureOutputDir()
const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook)
const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet)
const candidateRows = loadCandidateRows(candidateWorkbookPath)
const liveRefs = fetchLiveRefs()
const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs)
const addressPendingRows = pendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = pendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const readyPayload = buildReadyImportPayload(readyRows)
const manifest = {
generatedAt: new Date().toISOString(),
sourceWorkbook: sourceWorkbookPath,
sourceSheet: options.sourceSheet,
candidateWorkbook: candidateWorkbookPath,
totalCandidates: candidateRows.length,
readyCount: readyRows.length,
pendingCount: pendingRows.length,
addressPendingCount: addressPendingRows.length,
otherPendingCount: otherPendingRows.length,
rulesVersion: 'sheet2-public-supplement-v1',
assumptions: [
'当前使用员工为空的记录按公共设备处理',
'在用/闲置统一映射为系统可用',
'地址不能 100% 确认时不自动导入'
]
}
await writeWorkbook(workbookPath, [
{
name: '汇总说明',
rows: buildSummaryRows({
total: candidateRows.length,
ready: readyRows.length,
pending: pendingRows.length,
addressPending: addressPendingRows.length,
otherPending: otherPendingRows.length,
liveRefs
})
},
{ name: '可直接补录', rows: readyRows },
{ name: '待人工核对', rows: pendingRows },
{ 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: pendingRows.length,
rows: pendingRows
})
writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs)
console.log(
JSON.stringify(
{
outputDir,
workbookPath,
manifest
},
null,
2
)
)
}
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})