551 lines
18 KiB
JavaScript
551 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 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)
|
||
})
|