GuDingZiChan/backend/scripts/importConfirmedEquipmentToLive.js

859 lines
29 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.

let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
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: '',
keepExisting: false,
includeOwnerless: false,
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
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 === '--keep-existing') {
options.keepExisting = true
} else if (arg === '--include-ownerless') {
options.includeOwnerless = true
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} 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}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
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 ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestMigrationDir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const parseDate = (value) => {
const normalized = normalizeText(value)
if (!normalized || normalized === '#REF!') {
return null
}
const directDate = new Date(normalized)
if (!Number.isNaN(directDate.getTime())) {
return directDate.toISOString()
}
return null
}
const buildDescription = (record) => {
const parts = [
['legacy_asset_code', record.assetCodeRaw || ''],
['source_row', record.sourceRow],
['source_status', record.currentStatusRaw || ''],
['raw_owner', record.ownerRaw || ''],
['raw_department', record.departmentRaw || ''],
['raw_location', record.addressRaw || ''],
['raw_category', record.assetCategoryRaw || '']
]
return parts
.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`)
.join('; ')
}
const collectDuplicateCodeDetails = (records) => {
const grouped = new Map()
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
resolvedType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
address: normalizeText(record.addressRaw),
purchaseDate: normalizeText(record.purchaseDateParsed),
unitPrice: normalizeText(record.unitPriceParsed),
sourceStatus: normalizeText(record.currentStatusRaw),
statusKind: normalizeText(record.statusKind),
pendingReasons: Array.isArray(record.pendingReasons) ? record.pendingReasons : []
})
}
return [...grouped.entries()]
.filter(([, items]) => items.length > 1)
.map(([legacyCode, items]) => ({ legacyCode, items }))
}
const partitionImportCandidates = (records) => {
const missingCodeRows = []
const validCodeRecords = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
missingCodeRows.push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw),
address: normalizeText(record.addressRaw)
})
continue
}
validCodeRecords.push(record)
}
const duplicateDetails = collectDuplicateCodeDetails(validCodeRecords)
const duplicateCodes = new Set(duplicateDetails.map((item) => item.legacyCode))
const filteredRecords = validCodeRecords.filter(
(record) => !duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase())
)
const duplicateRows = validCodeRecords
.filter((record) => duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase()))
.map((record) => ({
bucket: normalizeText(record.bucket),
legacyCode: normalizeText(record.assetCodeRaw).toUpperCase(),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw)
}))
return {
filteredRecords,
missingCodeRows,
duplicateRows,
duplicateDetails
}
}
const buildImportPayload = (records) => {
const payload = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
const importMode = record.bucket === 'import-without-owner' ? 'without_owner' : 'with_owner'
const statusKind = normalizeText(record.statusKind)
const pendingReasons = Array.isArray(record.pendingReasons) ? record.pendingReasons : []
const matchedPerson = record.ownerMatch?.matchedPerson || null
if (importMode === 'with_owner' && !matchedPerson?.id) {
throw new Error(`发现未匹配 owner 的 import-ready 记录,sourceRow=${record.sourceRow} code=${legacyCode}`)
}
if (importMode === 'without_owner' && !['assigned', 'unassigned'].includes(statusKind)) {
throw new Error(
`import-without-owner 记录状态异常,无法安全导入,sourceRow=${record.sourceRow} code=${legacyCode}`
)
}
const descriptionExtras = [`migration_bucket=${record.bucket}`]
if (importMode === 'without_owner') {
descriptionExtras.push(`owner_pending_reasons=${pendingReasons.join('|') || 'none'}`)
descriptionExtras.push(`owner_resolution=manual_followup_required`)
}
payload.push({
legacyCode,
importMode,
sourceStatusKind: statusKind,
unresolvedOwnerReasons: pendingReasons.join('|'),
rawOwnerName: normalizeText(record.ownerRaw),
equipmentName: normalizeText(record.assetNameRaw),
equipmentModel: normalizeText(record.equipment_model || record.modelRaw) || null,
equipmentType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
departmentName: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
storageLocationName: record.locationMatch
? normalizeText(record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue)
: '',
unitPrice: Number(record.unitPriceParsed || 0),
purchaseDate: normalizeText(record.purchaseDateParsed) || null,
supplier: '',
description: [buildDescription(record), ...descriptionExtras].join('; '),
ownerPersonId: matchedPerson?.id || null,
ownerNickname: normalizeText(matchedPerson?.nickname || ''),
ownerRealName: normalizeText(matchedPerson?.real_name || ''),
borrowTime: parseDate(record.claimedAtRaw) || parseDate(record.purchaseDateParsed) || null,
sourceRow: record.sourceRow
})
}
return payload
}
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: 64 * 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 buildRemoteBackupScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
] = await Promise.all([
prisma.equipment.findMany({ orderBy: { id: 'asc' } }),
prisma.borrowRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.maintenanceRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.disposalRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.mountHistory.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTask.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskParticipant.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskItem.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskAction.findMany({ orderBy: { id: 'asc' } })
])
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
counts: {
equipment: equipment.length,
borrowRecords: borrowRecords.length,
maintenanceRecords: maintenanceRecords.length,
disposalRecords: disposalRecords.length,
mountHistories: mountHistories.length,
inventoryTasks: inventoryTasks.length,
inventoryTaskParticipants: inventoryTaskParticipants.length,
inventoryTaskItems: inventoryTaskItems.length,
inventoryTaskActions: inventoryTaskActions.length
},
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteWipeScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const before = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTaskAction.deleteMany({})
await tx.inventoryTaskItem.deleteMany({})
await tx.inventoryTaskParticipant.deleteMany({})
await tx.inventoryTask.deleteMany({})
await tx.maintenanceRecord.deleteMany({})
await tx.disposalRecord.deleteMany({})
await tx.mountHistory.deleteMany({})
await tx.borrowRecord.deleteMany({})
await tx.equipment.deleteMany({})
})
const after = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
console.log(JSON.stringify({ before, after }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteImportScript = (payload, keepExisting) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const keepExisting = ${keepExisting ? 'true' : 'false'}
const normalizeText = (value) => String(value == null ? '' : value).replace(/\\r/g, ' ').replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim()
const toDateOrNull = (value) => {
const normalized = normalizeText(value)
if (!normalized) return null
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const calculateValues = (unitPrice, remainingQuantity) => {
const normalizedUnitPrice = Number(unitPrice || 0)
const totalValue = normalizedUnitPrice
const borrowedQuantity = Math.max(1 - Number(remainingQuantity || 0), 0)
return {
totalValue,
borrowedValue: normalizedUnitPrice * borrowedQuantity,
availableValue: normalizedUnitPrice * Number(remainingQuantity || 0)
}
}
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true } }),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true }
}),
prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true } })
])
const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item]))
const departmentMap = new Map(departments.map((item) => [item.name, item]))
const locationMap = new Map(locations.map((item) => [item.name, item]))
const duplicatePayloadCodes = new Set()
const payloadCodes = new Set()
for (const item of payload) {
if (payloadCodes.has(item.legacyCode)) {
duplicatePayloadCodes.add(item.legacyCode)
}
payloadCodes.add(item.legacyCode)
}
if (duplicatePayloadCodes.size > 0) {
throw new Error('导入 payload 中存在重复资产编码: ' + [...duplicatePayloadCodes].join(', '))
}
const existingCodes = await prisma.equipment.findMany({
where: { equipment_code: { in: [...payloadCodes] } },
select: { equipment_code: true }
})
if (!keepExisting && existingCodes.length > 0) {
throw new Error('线上已存在同名资产编码: ' + existingCodes.map((item) => item.equipment_code).join(', '))
}
const existingCodeSet = new Set(existingCodes.map((item) => item.equipment_code))
const results = []
const skippedExisting = []
for (const item of payload) {
if (existingCodeSet.has(item.legacyCode)) {
skippedExisting.push({
legacyCode: item.legacyCode,
importMode: item.importMode,
reason: 'already_exists'
})
continue
}
const assetType = assetTypeMap.get(item.equipmentType)
if (!assetType) {
throw new Error('资产类型不存在: ' + item.equipmentType + ' code=' + item.legacyCode)
}
const department = item.departmentName ? departmentMap.get(item.departmentName) || null : null
if (item.departmentName && !department) {
throw new Error('业务部门不存在: ' + item.departmentName + ' code=' + item.legacyCode)
}
const location = item.storageLocationName ? locationMap.get(item.storageLocationName) || null : null
if (item.storageLocationName && !location) {
throw new Error('存放地点不存在: ' + item.storageLocationName + ' code=' + item.legacyCode)
}
let person = null
if (item.importMode === 'with_owner') {
person = await prisma.person.findFirst({
where: {
id: item.ownerPersonId,
status: 'active',
source_system: 'ding_hr'
},
select: {
id: true,
real_name: true,
nickname: true,
id_number: true
}
})
if (!person) {
throw new Error('使用人不存在或不再是 active: personId=' + item.ownerPersonId + ' code=' + item.legacyCode)
}
}
const unitPrice = Number(item.unitPrice || 0)
const purchaseDate = toDateOrNull(item.purchaseDate)
const borrowTime = toDateOrNull(item.borrowTime) || new Date()
let equipmentStatus = '可用'
let remainingQuantity = 1
let borrowRecordNeeded = false
let borrowNotes = 'Excel迁移导入;在职长期'
if (item.importMode === 'with_owner') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowRecordNeeded = true
} else if (item.sourceStatusKind === 'assigned') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowNotes = 'Excel迁移导入;当前使用人待行政补录'
} else if (item.sourceStatusKind === 'unassigned') {
equipmentStatus = '可用'
remainingQuantity = 1
} else {
throw new Error('ownerless 资产状态异常: ' + item.sourceStatusKind + ' code=' + item.legacyCode)
}
const values = calculateValues(unitPrice, remainingQuantity)
const created = await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.create({
data: {
equipment_code: item.legacyCode,
equipment_name: item.equipmentName,
equipment_model: normalizeText(item.equipmentModel) || null,
equipment_type: assetType.name,
code_generation_mode: 'manual',
status: equipmentStatus,
total_quantity: 1,
remaining_quantity: remainingQuantity,
unit_price: unitPrice,
total_value: values.totalValue,
borrowed_value: values.borrowedValue,
available_value: values.availableValue,
total_value_with_children: values.totalValue,
description: normalizeText(item.description) || null,
purchase_date: purchaseDate,
supplier: normalizeText(item.supplier) || null,
storage_location_id: location?.id,
asset_type_id: assetType.id,
department_id: department?.id,
mount_status: 'none'
}
})
if (borrowRecordNeeded && person) {
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: borrowNotes
}
})
}
return {
id: equipment.id,
equipment_code: equipment.equipment_code,
importMode: item.importMode,
status: equipment.status,
person_id: person?.id || null,
person_nickname: person?.nickname || null
}
})
results.push(created)
}
const verification = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
activeBorrow: await prisma.borrowRecord.count({ where: { status: '已借出' } }),
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: [...payloadCodes] } } }),
importedWithOwnerCount: results.filter((item) => item.importMode === 'with_owner').length,
importedWithoutOwnerCount: results.filter((item) => item.importMode === 'without_owner').length,
skippedExistingCount: skippedExisting.length
}
console.log(JSON.stringify({
imported: results.length,
verification,
skippedExisting,
sample: results.slice(0, 10)
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const buildRemoteVerifyScript = (payload) => {
const payloadBase64 = Buffer.from(
JSON.stringify(payload.map((item) => item.legacyCode)),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
;(async () => {
const [equipment, borrowRecords, activeBorrow, importedEquipments] = await Promise.all([
prisma.equipment.count(),
prisma.borrowRecord.count(),
prisma.borrowRecord.count({ where: { status: '已借出' } }),
prisma.equipment.findMany({
where: { equipment_code: { in: codes } },
orderBy: { equipment_code: 'asc' },
take: 20,
select: {
equipment_code: true,
equipment_name: true,
equipment_type: true,
status: true,
remaining_quantity: true,
department: { select: { name: true } },
storageLocation: { select: { name: true } }
}
})
])
console.log(JSON.stringify({
equipment,
borrowRecords,
activeBorrow,
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: codes } } }),
sample: importedEquipments
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`)
}
const normalizedRecords = readJsonl(normalizedPath)
const importReadyRecords = normalizedRecords.filter((record) => record.bucket === 'import-ready')
const importWithoutOwnerRecords = options.includeOwnerless
? normalizedRecords.filter((record) => record.bucket === 'import-without-owner')
: []
const importCandidates = [...importReadyRecords, ...importWithoutOwnerRecords]
if (!importCandidates.length) {
throw new Error('没有可导入的确认记录')
}
const partitioned = partitionImportCandidates(importCandidates)
const payload = buildImportPayload(partitioned.filteredRecords)
const backupDir = ensureOutputDir(options.apply ? 'live_import_backup' : 'live_import_preview')
writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), {
excludedMissingCodeCount: partitioned.missingCodeRows.length,
rows: partitioned.missingCodeRows
})
writeJson(path.join(backupDir, 'excluded_duplicate_codes.json'), {
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
duplicateDetails: partitioned.duplicateDetails,
excludedRows: partitioned.duplicateRows
})
writeJson(path.join(backupDir, 'planned_import_summary.json'), {
inputDir,
importReadyCount: importReadyRecords.length,
importWithoutOwnerCount: importWithoutOwnerRecords.length,
selectedCandidateCount: importCandidates.length,
excludedMissingCodeCount: partitioned.missingCodeRows.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
finalImportCount: payload.length,
finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length,
finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length,
apply: options.apply,
keepExisting: options.keepExisting,
includeOwnerless: options.includeOwnerless
})
writeJson(path.join(backupDir, 'planned_payload.json'), {
payloadCount: payload.length,
payload
})
const backup = runRemoteNodeScript(buildRemoteBackupScript(), connection)
writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup)
if (!options.apply) {
console.log(
JSON.stringify(
{
backupDir,
inputDir,
dryRun: true,
plannedImportCount: payload.length,
message: '当前仅完成 preview,未执行清空或导入;如确认写入,请显式传入 --apply。'
},
null,
2
)
)
return
}
let wipeResult = { skipped: true }
if (!options.keepExisting) {
wipeResult = runRemoteNodeScript(buildRemoteWipeScript(), connection)
writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult)
}
const importResult = runRemoteNodeScript(
buildRemoteImportScript(payload, options.keepExisting),
connection
)
writeJson(path.join(backupDir, 'import_result.json'), importResult)
const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload), connection)
writeJson(path.join(backupDir, 'post_import_verification.json'), verification)
console.log(
JSON.stringify(
{
backupDir,
inputDir,
importedCount: importResult.imported,
wipeResult,
importResult,
verification
},
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
}