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 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 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 parseArgs = () => { const args = process.argv.slice(2) const options = { workbookPath: WORKBOOK_PATH, overridesPath: '', liveSnapshotPath: '' } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--workbook') { options.workbookPath = args[index + 1] || WORKBOOK_PATH index += 1 } else if (arg === '--overrides') { options.overridesPath = args[index + 1] || '' index += 1 } else if (arg === '--live-snapshot') { options.liveSnapshotPath = args[index + 1] || '' index += 1 } } 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 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 = '') => { 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')) } } if (!fs.existsSync(PLINK_PATH)) { throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) } if (!SSH_PASSWORD) { throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') } const remoteScript = buildRemoteSnapshotScript() const result = spawnSync( PLINK_PATH, ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], { encoding: 'utf8', input: remoteScript, maxBuffer: 20 * 1024 * 1024, windowsHide: true } ) if (result.error) { throw result.error } if (result.status !== 0) { throw new Error(`读取 live 快照失败: ${result.stderr || result.stdout}`) } const stdout = (result.stdout || '').trim() if (!stdout) { throw new Error('live 快照返回为空') } return { source: 'remote', path: '', data: JSON.parse(stdout) } } 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) 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 ) ) } try { run() } catch (error) { console.error(error?.stack ? error.stack : String(error)) process.exit(1) }