1279 lines
38 KiB
JavaScript
1279 lines
38 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 XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
|
||
const {
|
||
STORAGE_LOCATION_ALIASES,
|
||
resolveStorageLocationMatch
|
||
} = require('./locationNormalization')
|
||
|
||
const WORKBOOK_PATH = path.resolve(__dirname, '../../8.xlsx')
|
||
const SHEET_NAME = '📜固定资产明细'
|
||
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
|
||
const DEFAULT_MANUAL_OVERRIDES_PATH = path.resolve(__dirname, '../../migration_manual_overrides.json')
|
||
|
||
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
|
||
|
||
const OWNER_SEPARATORS = [',', ',', '、', ';', ';', '/', '|', '|']
|
||
const DEPARTMENT_SEPARATORS = [...OWNER_SEPARATORS]
|
||
const ASSET_TYPE_SEPARATORS = [...OWNER_SEPARATORS]
|
||
|
||
const ASSET_TYPE_ALIASES = {
|
||
笔记本电脑: '笔记本'
|
||
}
|
||
|
||
const DEPARTMENT_ALIASES = {
|
||
'3D视觉(广东)': '视觉中心',
|
||
'3D视觉(常州)': '视觉中心',
|
||
事业一部: '运营中心A',
|
||
事业二部: '运营中心A',
|
||
事业三部: '运营中心A',
|
||
事业四部: '运营中心A',
|
||
策划一部: '品牌策划中心',
|
||
策划二部: '品牌策划中心',
|
||
策划三部: '品牌策划中心',
|
||
策划四部: '品牌策划中心',
|
||
策划五部: '品牌策划中心',
|
||
策划六部: '品牌策划中心',
|
||
客服二部: '客服中心',
|
||
客服三部: '客服中心',
|
||
客服四部: '客服中心',
|
||
客服五部: '客服中心',
|
||
客服六部: '客服中心',
|
||
品牌营销策划中心: '品牌策划中心',
|
||
品牌创意视觉中心: '视觉中心',
|
||
品牌供应链中心: '供应链中心',
|
||
智管信息中心: '效率部(懂业务的技术部)',
|
||
总账组: '财务中心',
|
||
结算组: '财务中心',
|
||
视觉一部: '视觉中心',
|
||
视觉二部: '视觉中心',
|
||
视觉三部: '视觉中心',
|
||
视觉四部: '视觉中心',
|
||
视觉五部: '视觉中心',
|
||
视觉六部: '视觉中心',
|
||
'基本盘 采购部': '供应链中心',
|
||
'基本盘 核价组': '供应链中心'
|
||
}
|
||
|
||
const OWNER_ONLY_PENDING_REASONS = new Set([
|
||
'owner_not_in_active_synced_roster',
|
||
'owner_ambiguous',
|
||
'owner_ambiguous_even_after_department',
|
||
'multi_owner_cell',
|
||
'assigned_but_owner_blank',
|
||
'unassigned_but_owner_present'
|
||
])
|
||
|
||
const HEADER_MAP = {
|
||
assetCode: '资产编码',
|
||
currentStatus: '当前状态',
|
||
assetName: '资产名称',
|
||
assetCategory: '资产分类',
|
||
department: '部门',
|
||
owner: '当前使用员工',
|
||
address: '地址',
|
||
cpu: 'cpu',
|
||
motherboard: '主板',
|
||
memory: '内存',
|
||
ssd: '固态硬盘',
|
||
hdd: '机械硬盘',
|
||
gpu: '显卡',
|
||
arrivalDate: '到货时间',
|
||
unitPrice: '采购单价(元)',
|
||
model: '规格型号',
|
||
claimedAt: '领用时间',
|
||
maintenanceRecord: '维修记录',
|
||
assetStatusUpdate: '资产状态更新',
|
||
parentRecord: '父记录'
|
||
}
|
||
|
||
const CSV_IMPORT_HEADERS = [
|
||
'equipment_name',
|
||
'equipment_type',
|
||
'equipment_model',
|
||
'department',
|
||
'storage_location',
|
||
'unit_price',
|
||
'total_quantity',
|
||
'supplier',
|
||
'purchase_date',
|
||
'description'
|
||
]
|
||
|
||
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 = {
|
||
workbookPath: WORKBOOK_PATH,
|
||
overridesPath: '',
|
||
liveSnapshotPath: '',
|
||
plinkPath: '',
|
||
sshHost: '',
|
||
sshHostkey: '',
|
||
sshPassword: ''
|
||
}
|
||
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index]
|
||
if (arg === '--workbook') {
|
||
options.workbookPath = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--overrides') {
|
||
options.overridesPath = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} else if (arg === '--live-snapshot') {
|
||
options.liveSnapshotPath = requireArgValue(args, index, arg)
|
||
index += 1
|
||
} 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}`)
|
||
}
|
||
}
|
||
|
||
return options
|
||
}
|
||
|
||
const normalizeText = (value) =>
|
||
String(value == null ? '' : value)
|
||
.replace(/\r/g, ' ')
|
||
.replace(/\n/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
|
||
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
|
||
|
||
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 isBlank = (value) => normalizeText(value) === ''
|
||
|
||
const resolvePathArg = (value, fallbackPath = '') => {
|
||
const target = normalizeText(value || fallbackPath)
|
||
if (!target) {
|
||
return ''
|
||
}
|
||
|
||
return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target)
|
||
}
|
||
|
||
const normalizeAssetTypeName = (value) => {
|
||
const normalized = normalizeText(value)
|
||
return ASSET_TYPE_ALIASES[normalized] || normalized
|
||
}
|
||
|
||
const normalizeDepartmentName = (value) => {
|
||
const normalized = normalizeText(value)
|
||
return DEPARTMENT_ALIASES[normalized] || normalized
|
||
}
|
||
|
||
const splitBySeparators = (value, separators) => {
|
||
const normalized = normalizeText(value)
|
||
if (!normalized) {
|
||
return []
|
||
}
|
||
|
||
let tokens = [normalized]
|
||
for (const separator of separators) {
|
||
tokens = tokens.flatMap((token) => token.split(separator))
|
||
}
|
||
|
||
return [...new Set(tokens.map((token) => normalizeText(token)).filter(Boolean))]
|
||
}
|
||
|
||
const parseStatus = (value) => {
|
||
const normalized = normalizeText(value)
|
||
|
||
if (!normalized) {
|
||
return 'unknown'
|
||
}
|
||
|
||
if (normalized.includes('已领用')) {
|
||
return 'assigned'
|
||
}
|
||
|
||
if (normalized.includes('未领用')) {
|
||
return 'unassigned'
|
||
}
|
||
|
||
if (normalized.includes('报废')) {
|
||
return 'scrapped'
|
||
}
|
||
|
||
return 'unknown'
|
||
}
|
||
|
||
const isScrappedRow = (record) => {
|
||
const category = normalizeText(record.assetCategoryRaw)
|
||
const statusRaw = normalizeText(record.currentStatusRaw)
|
||
return category === '已报废' || statusRaw.includes('报废')
|
||
}
|
||
|
||
const hasMultiOwner = (owner) => {
|
||
const normalized = normalizeText(owner)
|
||
if (!normalized) {
|
||
return false
|
||
}
|
||
|
||
return OWNER_SEPARATORS.some((separator) => normalized.includes(separator))
|
||
}
|
||
|
||
const parseUnitPrice = (value) => {
|
||
const normalized = normalizeText(value).replace(/,/g, '')
|
||
if (!normalized || normalized === '#REF!') {
|
||
return ''
|
||
}
|
||
|
||
const parsed = Number(normalized)
|
||
if (!Number.isFinite(parsed)) {
|
||
return ''
|
||
}
|
||
|
||
return parsed % 1 === 0 ? String(parsed) : parsed.toFixed(2).replace(/\.00$/, '')
|
||
}
|
||
|
||
const formatDate = (value) => {
|
||
const normalized = normalizeText(value)
|
||
if (!normalized || normalized === '#REF!') {
|
||
return ''
|
||
}
|
||
|
||
const directDate = new Date(normalized)
|
||
if (!Number.isNaN(directDate.getTime())) {
|
||
return directDate.toISOString().slice(0, 10)
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
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 escapeCsv = (value) => {
|
||
const stringValue = String(value == null ? '' : value)
|
||
if (/[,"\n]/.test(stringValue)) {
|
||
return `"${stringValue.replace(/"/g, '""')}"`
|
||
}
|
||
return stringValue
|
||
}
|
||
|
||
const writeCsv = (filePath, headers, rows) => {
|
||
const lines = [headers.map(escapeCsv).join(',')]
|
||
|
||
for (const row of rows) {
|
||
lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(','))
|
||
}
|
||
|
||
fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`)
|
||
}
|
||
|
||
const writeJson = (filePath, value) => {
|
||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||
}
|
||
|
||
const writeJsonl = (filePath, rows) => {
|
||
const content = rows.map((row) => JSON.stringify(row)).join('\n')
|
||
fs.writeFileSync(filePath, `${content}${content ? '\n' : ''}`)
|
||
}
|
||
|
||
const buildEmptyManualOverrides = () => ({
|
||
departmentAliases: {},
|
||
locationAliases: {},
|
||
ownerAliases: {},
|
||
assetOverrides: {}
|
||
})
|
||
|
||
const normalizeOverrideMap = (value) => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return {}
|
||
}
|
||
|
||
const result = {}
|
||
for (const [key, raw] of Object.entries(value)) {
|
||
const normalizedKey = normalizeText(key)
|
||
if (!normalizedKey) {
|
||
continue
|
||
}
|
||
|
||
result[normalizedKey] = normalizeText(raw)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
const normalizeAssetOverrideMap = (value) => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return {}
|
||
}
|
||
|
||
const result = {}
|
||
for (const [key, rawOverride] of Object.entries(value)) {
|
||
const normalizedKey = normalizeText(key).toUpperCase()
|
||
if (!normalizedKey || !rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) {
|
||
continue
|
||
}
|
||
|
||
const normalizedOverride = {}
|
||
for (const [fieldKey, fieldValue] of Object.entries(rawOverride)) {
|
||
normalizedOverride[fieldKey] = normalizeText(fieldValue)
|
||
}
|
||
|
||
result[normalizedKey] = normalizedOverride
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
const loadManualOverrides = (overridesPathArg) => {
|
||
const resolvedPath = resolvePathArg(overridesPathArg, DEFAULT_MANUAL_OVERRIDES_PATH)
|
||
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
|
||
return {
|
||
path: '',
|
||
data: buildEmptyManualOverrides(),
|
||
summary: {
|
||
departmentAliases: 0,
|
||
locationAliases: 0,
|
||
ownerAliases: 0,
|
||
assetOverrides: 0
|
||
}
|
||
}
|
||
}
|
||
|
||
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'))
|
||
const data = {
|
||
departmentAliases: normalizeOverrideMap(parsed.departmentAliases),
|
||
locationAliases: normalizeOverrideMap(parsed.locationAliases),
|
||
ownerAliases: normalizeOverrideMap(parsed.ownerAliases),
|
||
assetOverrides: normalizeAssetOverrideMap(parsed.assetOverrides)
|
||
}
|
||
|
||
return {
|
||
path: resolvedPath,
|
||
data,
|
||
summary: {
|
||
departmentAliases: Object.keys(data.departmentAliases).length,
|
||
locationAliases: Object.keys(data.locationAliases).length,
|
||
ownerAliases: Object.keys(data.ownerAliases).length,
|
||
assetOverrides: Object.keys(data.assetOverrides).length
|
||
}
|
||
}
|
||
}
|
||
|
||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key)
|
||
|
||
const resolveOverrideValue = (assetOverride, key, fallbackValue) =>
|
||
hasOwn(assetOverride, key) ? normalizeText(assetOverride[key]) : fallbackValue
|
||
|
||
const applyManualOverridesToRecord = (record, manualOverridesData) => {
|
||
const assetCode = normalizeText(record.assetCodeRaw).toUpperCase()
|
||
const assetOverride = manualOverridesData.assetOverrides[assetCode] || {}
|
||
const ownerAliasValue =
|
||
manualOverridesData.ownerAliases[normalizeText(record.ownerRaw)] ?? record.ownerRaw
|
||
const departmentAliasValue =
|
||
manualOverridesData.departmentAliases[normalizeText(record.departmentRaw)] ?? record.departmentRaw
|
||
const locationAliasValue =
|
||
manualOverridesData.locationAliases[normalizeText(record.addressRaw)] ?? record.addressRaw
|
||
|
||
return {
|
||
...record,
|
||
currentStatusRaw: resolveOverrideValue(
|
||
assetOverride,
|
||
'currentStatus',
|
||
resolveOverrideValue(assetOverride, 'sourceStatus', record.currentStatusRaw)
|
||
),
|
||
assetNameRaw: resolveOverrideValue(assetOverride, 'assetName', record.assetNameRaw),
|
||
assetCategoryRaw: resolveOverrideValue(assetOverride, 'assetCategory', record.assetCategoryRaw),
|
||
departmentRaw: resolveOverrideValue(assetOverride, 'department', departmentAliasValue),
|
||
ownerRaw: resolveOverrideValue(assetOverride, 'owner', ownerAliasValue),
|
||
addressRaw: resolveOverrideValue(assetOverride, 'location', locationAliasValue),
|
||
arrivalDateRaw: resolveOverrideValue(
|
||
assetOverride,
|
||
'arrivalDate',
|
||
resolveOverrideValue(assetOverride, 'purchaseDate', record.arrivalDateRaw)
|
||
),
|
||
unitPriceRaw: resolveOverrideValue(assetOverride, 'unitPrice', record.unitPriceRaw),
|
||
modelRaw: resolveOverrideValue(assetOverride, 'model', record.modelRaw),
|
||
claimedAtRaw: resolveOverrideValue(assetOverride, 'claimedAt', record.claimedAtRaw)
|
||
}
|
||
}
|
||
|
||
const ensureOutputDir = () => {
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
|
||
const outputDir = path.join(OUTPUT_ROOT, `migration_dry_run_${timestamp}`)
|
||
fs.mkdirSync(outputDir, { recursive: true })
|
||
return outputDir
|
||
}
|
||
|
||
const loadWorkbookRows = (workbookPath, manualOverridesData) => {
|
||
const workbook = XLSX.readFile(workbookPath, { raw: false })
|
||
const sheet = workbook.Sheets[SHEET_NAME]
|
||
|
||
if (!sheet) {
|
||
throw new Error(`未找到工作表: ${SHEET_NAME}`)
|
||
}
|
||
|
||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' })
|
||
if (!rows.length) {
|
||
throw new Error('工作表为空')
|
||
}
|
||
|
||
const headerRow = rows[0].map((cell) => normalizeText(cell))
|
||
const headerIndex = {}
|
||
headerRow.forEach((header, index) => {
|
||
headerIndex[header] = index
|
||
})
|
||
|
||
const missingHeaders = Object.values(HEADER_MAP).filter((header) => headerIndex[header] == null)
|
||
if (missingHeaders.length) {
|
||
throw new Error(`工作表缺少列: ${missingHeaders.join(', ')}`)
|
||
}
|
||
|
||
const normalizedRows = []
|
||
|
||
for (let index = 1; index < rows.length; index += 1) {
|
||
const row = rows[index]
|
||
const sourceRow = index + 1
|
||
const sourceRecord = {
|
||
sourceRow,
|
||
assetCodeRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCode]]),
|
||
currentStatusRaw: normalizeText(row[headerIndex[HEADER_MAP.currentStatus]]),
|
||
assetNameRaw: normalizeText(row[headerIndex[HEADER_MAP.assetName]]),
|
||
assetCategoryRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCategory]]),
|
||
departmentRaw: normalizeText(row[headerIndex[HEADER_MAP.department]]),
|
||
ownerRaw: normalizeText(row[headerIndex[HEADER_MAP.owner]]),
|
||
addressRaw: normalizeText(row[headerIndex[HEADER_MAP.address]]),
|
||
cpuRaw: normalizeText(row[headerIndex[HEADER_MAP.cpu]]),
|
||
motherboardRaw: normalizeText(row[headerIndex[HEADER_MAP.motherboard]]),
|
||
memoryRaw: normalizeText(row[headerIndex[HEADER_MAP.memory]]),
|
||
ssdRaw: normalizeText(row[headerIndex[HEADER_MAP.ssd]]),
|
||
hddRaw: normalizeText(row[headerIndex[HEADER_MAP.hdd]]),
|
||
gpuRaw: normalizeText(row[headerIndex[HEADER_MAP.gpu]]),
|
||
arrivalDateRaw: normalizeText(row[headerIndex[HEADER_MAP.arrivalDate]]),
|
||
unitPriceRaw: normalizeText(row[headerIndex[HEADER_MAP.unitPrice]]),
|
||
modelRaw: normalizeText(row[headerIndex[HEADER_MAP.model]]),
|
||
claimedAtRaw: normalizeText(row[headerIndex[HEADER_MAP.claimedAt]]),
|
||
maintenanceRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.maintenanceRecord]]),
|
||
assetStatusUpdateRaw: normalizeText(row[headerIndex[HEADER_MAP.assetStatusUpdate]]),
|
||
parentRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.parentRecord]])
|
||
}
|
||
|
||
const significantValues = Object.values(sourceRecord).filter(
|
||
(value) => !isBlank(value) && value !== sourceRow
|
||
)
|
||
if (!significantValues.length) {
|
||
continue
|
||
}
|
||
|
||
const record = applyManualOverridesToRecord(sourceRecord, manualOverridesData)
|
||
|
||
normalizedRows.push({
|
||
...record,
|
||
statusKind: parseStatus(record.currentStatusRaw),
|
||
assetTypeName: normalizeAssetTypeName(record.assetNameRaw),
|
||
unitPriceParsed: parseUnitPrice(record.unitPriceRaw),
|
||
purchaseDateParsed: formatDate(record.arrivalDateRaw),
|
||
ownerHasMultipleNames: hasMultiOwner(record.ownerRaw)
|
||
})
|
||
}
|
||
|
||
return {
|
||
sheetName: SHEET_NAME,
|
||
workbookPath,
|
||
headerRow,
|
||
rows: normalizedRows
|
||
}
|
||
}
|
||
|
||
const buildRemoteSnapshotScript = () => `
|
||
let PrismaClient
|
||
|
||
try {
|
||
;({ PrismaClient } = require('@prisma/client'))
|
||
} catch {
|
||
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
|
||
}
|
||
|
||
const prisma = new PrismaClient()
|
||
|
||
;(async () => {
|
||
const [assetTypes, departments, storageLocations, persons] = await Promise.all([
|
||
prisma.assetType.findMany({
|
||
where: { status: 'active' },
|
||
orderBy: [{ name: 'asc' }],
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
short_name: true,
|
||
status: true,
|
||
_count: { select: { equipments: true } }
|
||
}
|
||
}),
|
||
prisma.department.findMany({
|
||
where: { status: 'active', source_system: 'ding_hr_business' },
|
||
orderBy: [{ name: 'asc' }],
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
source_group_id: true,
|
||
source_system: true,
|
||
status: true
|
||
}
|
||
}),
|
||
prisma.storageLocation.findMany({
|
||
where: { status: 'active' },
|
||
orderBy: [{ name: 'asc' }],
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
code: true,
|
||
status: true
|
||
}
|
||
}),
|
||
prisma.person.findMany({
|
||
where: { status: 'active', source_system: 'ding_hr' },
|
||
orderBy: [{ id: 'asc' }],
|
||
select: {
|
||
id: true,
|
||
nickname: true,
|
||
real_name: true,
|
||
department: true,
|
||
source_system: true,
|
||
source_userid: true,
|
||
mainDepartment: {
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
},
|
||
personDepartments: {
|
||
select: {
|
||
department: {
|
||
select: {
|
||
id: true,
|
||
name: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
])
|
||
|
||
process.stdout.write(JSON.stringify({ assetTypes, departments, storageLocations, persons }))
|
||
await prisma.$disconnect()
|
||
})().catch(async (error) => {
|
||
console.error(error && error.stack ? error.stack : String(error))
|
||
try {
|
||
await prisma.$disconnect()
|
||
} catch {}
|
||
process.exit(1)
|
||
})
|
||
`
|
||
|
||
const readLiveSnapshot = (snapshotPathArg = '', options = {}) => {
|
||
const snapshotPath = resolvePathArg(snapshotPathArg)
|
||
if (snapshotPath) {
|
||
if (!fs.existsSync(snapshotPath)) {
|
||
throw new Error(`live 快照文件不存在: ${snapshotPath}`)
|
||
}
|
||
|
||
return {
|
||
source: 'file',
|
||
path: snapshotPath,
|
||
data: JSON.parse(fs.readFileSync(snapshotPath, 'utf8'))
|
||
}
|
||
}
|
||
|
||
const connection = resolveRemoteConnection(options)
|
||
const remoteScript = buildRemoteSnapshotScript()
|
||
const result = spawnSync(
|
||
connection.plinkPath,
|
||
[
|
||
'-batch',
|
||
'-ssh',
|
||
'-hostkey',
|
||
connection.sshHostkey,
|
||
'-pw',
|
||
connection.sshPassword,
|
||
connection.sshHost,
|
||
REMOTE_NODE_COMMAND
|
||
],
|
||
{
|
||
encoding: 'utf8',
|
||
input: remoteScript,
|
||
maxBuffer: 20 * 1024 * 1024,
|
||
windowsHide: true
|
||
}
|
||
)
|
||
|
||
if (result.error) {
|
||
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
|
||
}
|
||
|
||
if (result.status !== 0) {
|
||
throw new Error('读取 live 快照失败,请检查连接参数、远程服务状态和服务器日志。')
|
||
}
|
||
|
||
const stdout = (result.stdout || '').trim()
|
||
if (!stdout) {
|
||
throw new Error('live 快照返回为空')
|
||
}
|
||
|
||
try {
|
||
return {
|
||
source: 'remote',
|
||
path: '',
|
||
data: JSON.parse(stdout)
|
||
}
|
||
} catch {
|
||
throw new Error('live 快照返回格式无效')
|
||
}
|
||
}
|
||
|
||
const buildLookupMap = (items, keyGetter) => {
|
||
const map = new Map()
|
||
for (const item of items) {
|
||
const key = normalizeLookupKey(keyGetter(item))
|
||
if (!key) {
|
||
continue
|
||
}
|
||
if (!map.has(key)) {
|
||
map.set(key, [])
|
||
}
|
||
map.get(key).push(item)
|
||
}
|
||
return map
|
||
}
|
||
|
||
const resolveReferenceMatch = ({ rawValue, referenceMap, normalizeValue, separators = [] }) => {
|
||
const normalizedValue = normalizeValue(rawValue)
|
||
const directMatch = referenceMap.get(normalizeLookupKey(normalizedValue)) || null
|
||
|
||
if (directMatch) {
|
||
return {
|
||
normalizedValue: directMatch.name,
|
||
match: directMatch,
|
||
rule: normalizedValue === normalizeText(rawValue) ? 'direct' : 'alias'
|
||
}
|
||
}
|
||
|
||
const parts = splitBySeparators(rawValue, separators)
|
||
if (!parts.length) {
|
||
return {
|
||
normalizedValue,
|
||
match: null,
|
||
rule: ''
|
||
}
|
||
}
|
||
|
||
const uniqueMatches = new Map()
|
||
for (const part of parts) {
|
||
const normalizedPart = normalizeValue(part)
|
||
const match = referenceMap.get(normalizeLookupKey(normalizedPart)) || null
|
||
if (match) {
|
||
uniqueMatches.set(normalizeLookupKey(match.name), match)
|
||
}
|
||
}
|
||
|
||
if (uniqueMatches.size === 1) {
|
||
const match = [...uniqueMatches.values()][0]
|
||
return {
|
||
normalizedValue: match.name,
|
||
match,
|
||
rule: 'composite_parts'
|
||
}
|
||
}
|
||
|
||
return {
|
||
normalizedValue,
|
||
match: null,
|
||
rule: ''
|
||
}
|
||
}
|
||
|
||
const collectPersonDepartments = (person) => {
|
||
const values = new Set()
|
||
|
||
if (!isBlank(person.department)) {
|
||
values.add(normalizeText(person.department))
|
||
}
|
||
|
||
if (person.mainDepartment?.name) {
|
||
values.add(normalizeText(person.mainDepartment.name))
|
||
}
|
||
|
||
for (const relation of person.personDepartments || []) {
|
||
if (relation.department?.name) {
|
||
values.add(normalizeText(relation.department.name))
|
||
}
|
||
}
|
||
|
||
return [...values]
|
||
}
|
||
|
||
const buildPersonIndexes = (persons) => {
|
||
const byNickname = buildLookupMap(persons, (person) => person.nickname)
|
||
const byRealName = buildLookupMap(persons, (person) => person.real_name)
|
||
const enriched = persons.map((person) => ({
|
||
...person,
|
||
departmentNames: collectPersonDepartments(person)
|
||
}))
|
||
const byId = new Map(enriched.map((person) => [person.id, person]))
|
||
|
||
return {
|
||
persons: enriched,
|
||
byId,
|
||
byNickname,
|
||
byRealName
|
||
}
|
||
}
|
||
|
||
const buildUniqueCandidates = (personIndexes, ownerName) => {
|
||
const nicknameMatches = personIndexes.byNickname.get(normalizeLookupKey(ownerName)) || []
|
||
const realNameMatches = personIndexes.byRealName.get(normalizeLookupKey(ownerName)) || []
|
||
const merged = [...nicknameMatches, ...realNameMatches]
|
||
const uniqueIds = new Set()
|
||
const unique = []
|
||
|
||
for (const candidate of merged) {
|
||
if (uniqueIds.has(candidate.id)) {
|
||
continue
|
||
}
|
||
uniqueIds.add(candidate.id)
|
||
unique.push(personIndexes.byId.get(candidate.id) || candidate)
|
||
}
|
||
|
||
return unique
|
||
}
|
||
|
||
const matchOwner = (record, personIndexes) => {
|
||
if (record.ownerHasMultipleNames) {
|
||
return {
|
||
status: 'pending',
|
||
reason: 'multi_owner_cell',
|
||
candidates: [],
|
||
matchedPerson: null,
|
||
matchRule: '',
|
||
confidence: 'low'
|
||
}
|
||
}
|
||
|
||
if (isBlank(record.ownerRaw)) {
|
||
return {
|
||
status: 'blank',
|
||
reason: '',
|
||
candidates: [],
|
||
matchedPerson: null,
|
||
matchRule: '',
|
||
confidence: ''
|
||
}
|
||
}
|
||
|
||
const candidates = buildUniqueCandidates(personIndexes, record.ownerRaw)
|
||
if (!candidates.length) {
|
||
return {
|
||
status: 'pending',
|
||
reason: 'owner_not_in_active_synced_roster',
|
||
candidates: [],
|
||
matchedPerson: null,
|
||
matchRule: '',
|
||
confidence: 'low'
|
||
}
|
||
}
|
||
|
||
if (candidates.length === 1) {
|
||
return {
|
||
status: 'matched',
|
||
reason: '',
|
||
candidates,
|
||
matchedPerson: candidates[0],
|
||
matchRule: normalizeLookupKey(candidates[0].nickname) === normalizeLookupKey(record.ownerRaw) ? 'nickname_exact' : 'real_name_exact',
|
||
confidence: 'high'
|
||
}
|
||
}
|
||
|
||
const departmentKey = normalizeLookupKey(normalizeDepartmentName(record.departmentNormalized || record.departmentRaw))
|
||
if (departmentKey) {
|
||
const departmentScoped = candidates.filter((candidate) =>
|
||
candidate.departmentNames.some((name) => normalizeLookupKey(name) === departmentKey)
|
||
)
|
||
|
||
if (departmentScoped.length === 1) {
|
||
return {
|
||
status: 'matched',
|
||
reason: '',
|
||
candidates: departmentScoped,
|
||
matchedPerson: departmentScoped[0],
|
||
matchRule: 'name_plus_department',
|
||
confidence: 'medium'
|
||
}
|
||
}
|
||
|
||
if (departmentScoped.length > 1) {
|
||
return {
|
||
status: 'pending',
|
||
reason: 'owner_ambiguous_even_after_department',
|
||
candidates: departmentScoped,
|
||
matchedPerson: null,
|
||
matchRule: '',
|
||
confidence: 'low'
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: 'pending',
|
||
reason: 'owner_ambiguous',
|
||
candidates,
|
||
matchedPerson: null,
|
||
matchRule: '',
|
||
confidence: 'low'
|
||
}
|
||
}
|
||
|
||
const resolveDepartmentMatch = (record, refs, ownerMatch) => {
|
||
const directResolution = resolveReferenceMatch({
|
||
rawValue: record.departmentRaw,
|
||
referenceMap: refs.departmentByName,
|
||
normalizeValue: normalizeDepartmentName,
|
||
separators: DEPARTMENT_SEPARATORS
|
||
})
|
||
|
||
if (directResolution.match) {
|
||
return directResolution
|
||
}
|
||
|
||
if (ownerMatch.status === 'matched' && ownerMatch.matchedPerson) {
|
||
const uniqueMatches = new Map()
|
||
|
||
for (const departmentName of ownerMatch.matchedPerson.departmentNames || []) {
|
||
const normalizedDepartmentName = normalizeDepartmentName(departmentName)
|
||
const match = refs.departmentByName.get(normalizeLookupKey(normalizedDepartmentName)) || null
|
||
if (match) {
|
||
uniqueMatches.set(normalizeLookupKey(match.name), match)
|
||
}
|
||
}
|
||
|
||
if (uniqueMatches.size === 1) {
|
||
const match = [...uniqueMatches.values()][0]
|
||
return {
|
||
normalizedValue: match.name,
|
||
match,
|
||
rule: 'matched_owner_business_department'
|
||
}
|
||
}
|
||
}
|
||
|
||
return directResolution
|
||
}
|
||
|
||
const resolveAssetTypeMatch = (record, refs) =>
|
||
resolveReferenceMatch({
|
||
rawValue: record.assetNameRaw,
|
||
referenceMap: refs.assetTypeByName,
|
||
normalizeValue: normalizeAssetTypeName,
|
||
separators: ASSET_TYPE_SEPARATORS
|
||
})
|
||
|
||
const buildReadyImportRow = (record) => ({
|
||
equipment_name: record.assetNameRaw,
|
||
equipment_type: record.assetTypeResolvedName || record.assetTypeName,
|
||
equipment_model: record.modelRaw,
|
||
department: record.departmentMatch ? record.departmentResolvedName || record.departmentNormalized : '',
|
||
storage_location: record.locationMatch ? record.locationResolvedName || '' : '',
|
||
unit_price: record.unitPriceParsed,
|
||
total_quantity: '1',
|
||
supplier: '',
|
||
purchase_date: record.purchaseDateParsed,
|
||
description: buildDescription(record)
|
||
})
|
||
|
||
const classifyRecord = (record, refs, personIndexes) => {
|
||
const reasons = []
|
||
const ownerMatch = matchOwner(record, personIndexes)
|
||
const assetTypeResolution = resolveAssetTypeMatch(record, refs)
|
||
const departmentResolution = resolveDepartmentMatch(record, refs, ownerMatch)
|
||
const locationResolution = resolveStorageLocationMatch(record.addressRaw, refs.locationByName)
|
||
|
||
const enriched = {
|
||
...record,
|
||
assetTypeResolvedName: assetTypeResolution.normalizedValue || record.assetTypeName,
|
||
assetTypeResolutionRule: assetTypeResolution.rule || '',
|
||
assetTypeMatch: assetTypeResolution.match || null,
|
||
departmentNormalized: normalizeDepartmentName(record.departmentRaw),
|
||
departmentResolvedName: departmentResolution.normalizedValue || normalizeDepartmentName(record.departmentRaw),
|
||
departmentResolutionRule: departmentResolution.rule || '',
|
||
departmentMatch: departmentResolution.match || null,
|
||
locationResolvedName: locationResolution.normalizedValue || '',
|
||
locationResolutionRule: locationResolution.rule || '',
|
||
locationMatch: locationResolution.match || null,
|
||
ownerMatch,
|
||
bucket: '',
|
||
pendingReasons: []
|
||
}
|
||
|
||
if (isBlank(record.assetNameRaw)) {
|
||
enriched.bucket = 'discard-no-import'
|
||
enriched.pendingReasons = ['missing_asset_name']
|
||
return enriched
|
||
}
|
||
|
||
if (isScrappedRow(record)) {
|
||
enriched.bucket = 'discard-no-import'
|
||
enriched.pendingReasons = ['scrapped_source_row']
|
||
return enriched
|
||
}
|
||
|
||
if (!enriched.assetTypeMatch) {
|
||
reasons.push('unmapped_asset_type')
|
||
}
|
||
|
||
if (!isBlank(record.departmentRaw) && !enriched.departmentMatch) {
|
||
reasons.push('unmapped_department')
|
||
}
|
||
|
||
if (ownerMatch.status === 'pending') {
|
||
reasons.push(ownerMatch.reason)
|
||
}
|
||
|
||
if (record.statusKind === 'unknown') {
|
||
reasons.push('unknown_source_status')
|
||
}
|
||
|
||
if (record.statusKind === 'assigned' && ownerMatch.status === 'blank') {
|
||
reasons.push('assigned_but_owner_blank')
|
||
}
|
||
|
||
if (record.statusKind === 'unassigned' && ownerMatch.status === 'matched') {
|
||
reasons.push('unassigned_but_owner_present')
|
||
}
|
||
|
||
const hasOnlyOwnerReasons = reasons.length > 0 && reasons.every((reason) => OWNER_ONLY_PENDING_REASONS.has(reason))
|
||
|
||
if (reasons.length) {
|
||
if (hasOnlyOwnerReasons) {
|
||
enriched.bucket = 'import-without-owner'
|
||
enriched.pendingReasons = reasons
|
||
return enriched
|
||
}
|
||
|
||
enriched.bucket = reasons.includes('scrapped_source_row') ? 'discard-no-import' : 'pending-review'
|
||
enriched.pendingReasons = reasons
|
||
return enriched
|
||
}
|
||
|
||
if (record.statusKind === 'unassigned' && ownerMatch.status === 'blank') {
|
||
enriched.bucket = 'import-without-owner'
|
||
return enriched
|
||
}
|
||
|
||
if (record.statusKind === 'assigned' && ownerMatch.status === 'matched') {
|
||
enriched.bucket = 'import-ready'
|
||
return enriched
|
||
}
|
||
|
||
enriched.bucket = 'pending-review'
|
||
enriched.pendingReasons = ['unhandled_bucket_case']
|
||
return enriched
|
||
}
|
||
|
||
const buildReferenceMaps = (snapshot) => ({
|
||
assetTypeByName: new Map(snapshot.assetTypes.map((item) => [normalizeLookupKey(item.name), item])),
|
||
departmentByName: new Map(snapshot.departments.map((item) => [normalizeLookupKey(item.name), item])),
|
||
locationByName: new Map(snapshot.storageLocations.map((item) => [normalizeLookupKey(item.name), item]))
|
||
})
|
||
|
||
const summarizeBuckets = (classifiedRecords) => {
|
||
const summary = {
|
||
total: classifiedRecords.length,
|
||
importReady: 0,
|
||
importWithoutOwner: 0,
|
||
pendingReview: 0,
|
||
discardNoImport: 0
|
||
}
|
||
|
||
for (const record of classifiedRecords) {
|
||
if (record.bucket === 'import-ready') {
|
||
summary.importReady += 1
|
||
} else if (record.bucket === 'import-without-owner') {
|
||
summary.importWithoutOwner += 1
|
||
} else if (record.bucket === 'pending-review') {
|
||
summary.pendingReview += 1
|
||
} else if (record.bucket === 'discard-no-import') {
|
||
summary.discardNoImport += 1
|
||
}
|
||
}
|
||
|
||
return summary
|
||
}
|
||
|
||
const buildPendingRow = (record) => {
|
||
const hasOwnerIssue =
|
||
record.pendingReasons.includes('owner_not_in_active_synced_roster') ||
|
||
record.pendingReasons.includes('owner_ambiguous') ||
|
||
record.pendingReasons.includes('owner_ambiguous_even_after_department') ||
|
||
record.pendingReasons.includes('multi_owner_cell')
|
||
const hasDepartmentIssue = record.pendingReasons.includes('unmapped_department')
|
||
const hasStatusIssue = record.pendingReasons.includes('unknown_source_status')
|
||
|
||
let suggestedAction = '补齐映射或手动修正后再导入'
|
||
if (hasDepartmentIssue && hasOwnerIssue) {
|
||
suggestedAction = '先确认业务部门映射;owner 暂不自动建立,待行政补充'
|
||
} else if (hasDepartmentIssue) {
|
||
suggestedAction = '补齐业务部门映射后再导入'
|
||
} else if (hasStatusIssue) {
|
||
suggestedAction = '补齐资产状态后再导入'
|
||
} else if (hasOwnerIssue) {
|
||
suggestedAction = '行政核对 owner 后再导入'
|
||
}
|
||
|
||
return {
|
||
source_row: record.sourceRow,
|
||
legacy_asset_code: record.assetCodeRaw,
|
||
asset_name: record.assetNameRaw,
|
||
source_status: record.currentStatusRaw,
|
||
raw_owner_name: record.ownerRaw,
|
||
raw_department: record.departmentRaw,
|
||
normalized_department: record.departmentResolvedName || record.departmentNormalized || '',
|
||
raw_location: record.addressRaw,
|
||
raw_asset_category: record.assetCategoryRaw,
|
||
pending_reason: record.pendingReasons.join('|'),
|
||
suggested_action: suggestedAction
|
||
}
|
||
}
|
||
|
||
const buildDiscardRow = (record) => ({
|
||
source_row: record.sourceRow,
|
||
legacy_asset_code: record.assetCodeRaw,
|
||
asset_name: record.assetNameRaw,
|
||
discard_reason: record.pendingReasons.join('|'),
|
||
source_status: record.currentStatusRaw,
|
||
raw_asset_category: record.assetCategoryRaw,
|
||
notes: record.assetStatusUpdateRaw || record.maintenanceRecordRaw || ''
|
||
})
|
||
|
||
const buildOwnerResolutionRow = (record) => ({
|
||
source_row: record.sourceRow,
|
||
legacy_asset_code: record.assetCodeRaw,
|
||
source_status: record.currentStatusRaw,
|
||
asset_name: record.assetNameRaw,
|
||
raw_owner_name: record.ownerRaw,
|
||
raw_department: record.departmentRaw,
|
||
matched_person_id: record.ownerMatch.matchedPerson?.id || '',
|
||
matched_person_real_name: record.ownerMatch.matchedPerson?.real_name || '',
|
||
matched_person_nickname: record.ownerMatch.matchedPerson?.nickname || '',
|
||
match_rule: record.ownerMatch.matchRule || '',
|
||
confidence: record.ownerMatch.confidence || '',
|
||
next_action:
|
||
record.bucket === 'import-ready'
|
||
? '后续 owner 关系可建'
|
||
: record.bucket === 'import-without-owner'
|
||
? '先导入资产,不建立 owner,后续由行政补充'
|
||
: record.ownerMatch.status === 'blank'
|
||
? '无需 owner'
|
||
: '待人工核对'
|
||
})
|
||
|
||
const run = () => {
|
||
const options = parseArgs()
|
||
const outputDir = ensureOutputDir()
|
||
const manualOverrides = loadManualOverrides(options.overridesPath)
|
||
const workbookPath = resolvePathArg(options.workbookPath)
|
||
if (!fs.existsSync(workbookPath)) {
|
||
throw new Error(`工作簿不存在: ${workbookPath}`)
|
||
}
|
||
|
||
const workbook = loadWorkbookRows(workbookPath, manualOverrides.data)
|
||
const liveSnapshotResult = readLiveSnapshot(options.liveSnapshotPath, options)
|
||
const liveSnapshot = liveSnapshotResult.data
|
||
const refs = buildReferenceMaps(liveSnapshot)
|
||
const personIndexes = buildPersonIndexes(liveSnapshot.persons)
|
||
|
||
const classifiedRecords = workbook.rows.map((record) => classifyRecord(record, refs, personIndexes))
|
||
const summary = summarizeBuckets(classifiedRecords)
|
||
|
||
const importWithoutOwnerRows = classifiedRecords
|
||
.filter((record) => record.bucket === 'import-without-owner')
|
||
.map((record) => buildReadyImportRow(record))
|
||
|
||
const importReadyRows = classifiedRecords
|
||
.filter((record) => record.bucket === 'import-ready')
|
||
.map((record) => buildReadyImportRow(record))
|
||
|
||
const pendingRows = classifiedRecords
|
||
.filter((record) => record.bucket === 'pending-review')
|
||
.map(buildPendingRow)
|
||
|
||
const discardRows = classifiedRecords
|
||
.filter((record) => record.bucket === 'discard-no-import')
|
||
.map(buildDiscardRow)
|
||
|
||
const ownerResolutionRows = classifiedRecords
|
||
.filter((record) => record.statusKind === 'assigned' || !isBlank(record.ownerRaw))
|
||
.map(buildOwnerResolutionRow)
|
||
|
||
const manifest = {
|
||
workbookPath,
|
||
sheetName: workbook.sheetName,
|
||
generatedAt: new Date().toISOString(),
|
||
outputDir,
|
||
sourceRowCount: workbook.rows.length,
|
||
liveSnapshotSource: liveSnapshotResult.source,
|
||
liveSnapshotPath: liveSnapshotResult.path || null,
|
||
summary,
|
||
referenceCounts: {
|
||
assetTypes: liveSnapshot.assetTypes.length,
|
||
departments: liveSnapshot.departments.length,
|
||
storageLocations: liveSnapshot.storageLocations.length,
|
||
activePersons: liveSnapshot.persons.length
|
||
},
|
||
rulesVersion: 'v4-ownerless-import-and-manual-overrides',
|
||
importerFields: CSV_IMPORT_HEADERS,
|
||
typeAliases: ASSET_TYPE_ALIASES,
|
||
departmentAliases: DEPARTMENT_ALIASES,
|
||
storageLocationAliases: STORAGE_LOCATION_ALIASES,
|
||
manualOverrides: {
|
||
path: manualOverrides.path || null,
|
||
summary: manualOverrides.summary
|
||
}
|
||
}
|
||
|
||
writeJson(path.join(outputDir, 'live_reference_snapshot.json'), liveSnapshot)
|
||
writeJson(path.join(outputDir, 'migration_manifest.json'), manifest)
|
||
writeJsonl(path.join(outputDir, 'normalized_assets.jsonl'), classifiedRecords)
|
||
writeCsv(path.join(outputDir, 'assets_import_without_owner.csv'), CSV_IMPORT_HEADERS, importWithoutOwnerRows)
|
||
writeCsv(path.join(outputDir, 'assets_import_ready.csv'), CSV_IMPORT_HEADERS, importReadyRows)
|
||
writeCsv(
|
||
path.join(outputDir, 'pending_review.csv'),
|
||
[
|
||
'source_row',
|
||
'legacy_asset_code',
|
||
'asset_name',
|
||
'source_status',
|
||
'raw_owner_name',
|
||
'raw_department',
|
||
'normalized_department',
|
||
'raw_location',
|
||
'raw_asset_category',
|
||
'pending_reason',
|
||
'suggested_action'
|
||
],
|
||
pendingRows
|
||
)
|
||
writeCsv(
|
||
path.join(outputDir, 'discard_no_import.csv'),
|
||
['source_row', 'legacy_asset_code', 'asset_name', 'discard_reason', 'source_status', 'raw_asset_category', 'notes'],
|
||
discardRows
|
||
)
|
||
writeCsv(
|
||
path.join(outputDir, 'owner_resolution.csv'),
|
||
[
|
||
'source_row',
|
||
'legacy_asset_code',
|
||
'source_status',
|
||
'asset_name',
|
||
'raw_owner_name',
|
||
'raw_department',
|
||
'matched_person_id',
|
||
'matched_person_real_name',
|
||
'matched_person_nickname',
|
||
'match_rule',
|
||
'confidence',
|
||
'next_action'
|
||
],
|
||
ownerResolutionRows
|
||
)
|
||
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
outputDir,
|
||
summary,
|
||
files: {
|
||
liveSnapshot: 'live_reference_snapshot.json',
|
||
manifest: 'migration_manifest.json',
|
||
normalized: 'normalized_assets.jsonl',
|
||
importWithoutOwner: 'assets_import_without_owner.csv',
|
||
importReady: 'assets_import_ready.csv',
|
||
pendingReview: 'pending_review.csv',
|
||
discard: 'discard_no_import.csv',
|
||
ownerResolution: 'owner_resolution.csv'
|
||
}
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
)
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
run()
|
||
} catch (error) {
|
||
console.error(error?.stack ? error.stack : String(error))
|
||
process.exit(1)
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
parseArgs,
|
||
resolveRemoteConnection
|
||
}
|