GuDingZiChan/backend/scripts/prepareEquipmentMigrationDryRun.js

1279 lines
38 KiB
JavaScript
Raw Permalink 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 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
}