GuDingZiChan/backend/scripts/backfillImportedEquipmentLocations.js
2026-03-23 11:54:41 +08:00

327 lines
8.7 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 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 || ''
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 parseArgs = () => {
const args = process.argv.slice(2)
const options = {
inputDir: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
index += 1
}
}
return options
}
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) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 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 buildRemoteScript = (payload) => {
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'))
;(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 = []
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({
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 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))
writeJson(path.join(outputDir, 'result.json'), result)
console.log(
JSON.stringify(
{
outputDir,
inputDir,
payloadCount: payload.length,
result
},
null,
2
)
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}