612 lines
20 KiB
JavaScript
612 lines
20 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 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
|
||
}
|