GuDingZiChan/backend/scripts/backfillImportedEquipmentLocations.js

398 lines
11 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 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 ensureOutputDir = () => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `location_backfill_${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 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: '',
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 === '--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 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 buildPayload = (records) => {
const importableRecords = records.filter(
(record) => record.bucket === 'import-ready' || record.bucket === 'import-without-owner'
)
const grouped = new Map()
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push(record)
}
const duplicateCodes = new Set(
[...grouped.entries()].filter(([, items]) => items.length > 1).map(([legacyCode]) => legacyCode)
)
const updates = []
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode || duplicateCodes.has(legacyCode)) {
continue
}
const resolvedLocationName = normalizeText(
record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue
)
if (!resolvedLocationName || !record.locationMatch) {
continue
}
updates.push({
legacyCode,
rawLocation: normalizeText(record.addressRaw),
resolvedLocationName,
sourceRow: record.sourceRow
})
}
return updates
}
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 buildRemoteScript = (payload, apply) => {
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 shouldApply = ${apply ? 'true' : 'false'}
;(async () => {
const locationNames = [...new Set(payload.map((item) => item.resolvedLocationName))]
const [locations, equipmentsBefore] = await Promise.all([
prisma.storageLocation.findMany({
where: { status: 'active', name: { in: locationNames } },
select: { id: true, name: true, code: true }
}),
prisma.equipment.findMany({
where: { equipment_code: { in: payload.map((item) => item.legacyCode) } },
select: {
id: true,
equipment_code: true,
equipment_name: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
])
const locationByName = new Map(locations.map((item) => [item.name, item]))
const equipmentByCode = new Map(equipmentsBefore.map((item) => [item.equipment_code, item]))
const skipped = []
const planned = []
for (const item of payload) {
const equipment = equipmentByCode.get(item.legacyCode)
if (!equipment) {
skipped.push({ ...item, reason: 'equipment_not_found' })
continue
}
if (equipment.storage_location_id) {
skipped.push({
...item,
reason: 'already_has_storage_location',
current_storage_location: equipment.storageLocation?.name || ''
})
continue
}
const location = locationByName.get(item.resolvedLocationName)
if (!location) {
skipped.push({ ...item, reason: 'storage_location_not_found' })
continue
}
planned.push({
equipmentId: equipment.id,
legacyCode: item.legacyCode,
equipmentName: equipment.equipment_name,
rawLocation: item.rawLocation,
resolvedLocationName: item.resolvedLocationName,
storageLocationId: location.id
})
}
const updated = []
if (shouldApply) {
for (const item of planned) {
const result = await prisma.equipment.update({
where: { id: item.equipmentId },
data: { storage_location_id: item.storageLocationId },
select: {
id: true,
equipment_code: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
updated.push(result)
}
}
const verification = {
nullStorageLocationCount: await prisma.equipment.count({ where: { storage_location_id: null } }),
updatedCount: updated.length
}
console.log(JSON.stringify({
applied: shouldApply,
payloadCount: payload.length,
plannedCount: planned.length,
updatedCount: updated.length,
skippedCount: skipped.length,
planned,
skipped,
verification
}))
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 payload = buildPayload(normalizedRecords)
if (!payload.length) {
throw new Error('没有可回填的地址记录')
}
const outputDir = ensureOutputDir()
writeJson(path.join(outputDir, 'planned_payload.json'), {
inputDir,
payloadCount: payload.length,
payload
})
const result = runRemoteNodeScript(buildRemoteScript(payload, options.apply), connection)
writeJson(path.join(outputDir, 'result.json'), result)
console.log(
JSON.stringify(
{
outputDir,
inputDir,
applied: options.apply,
payloadCount: payload.length,
result
},
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
}