859 lines
29 KiB
JavaScript
859 lines
29 KiB
JavaScript
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
|
||
}
|