const fs = require('fs') const path = require('path') const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) const PROJECT_ROOT = path.resolve(__dirname, '../..') const DEFAULT_OUTPUT = path.join(PROJECT_ROOT, 'migration_manual_overrides.json') const WORKBOOK_PREFIX = '行政手动补录清单' const normalizeText = (value) => String(value == null ? '' : value) .replace(/\r/g, ' ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() const normalizeAssetCode = (value) => normalizeText(value).toUpperCase() const parseArgs = () => { const args = process.argv.slice(2) const options = { workbookPath: '', outputPath: DEFAULT_OUTPUT } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--workbook') { options.workbookPath = args[index + 1] || '' index += 1 } else if (arg === '--output') { options.outputPath = args[index + 1] || DEFAULT_OUTPUT index += 1 } } return options } const resolvePathArg = (value) => { const target = normalizeText(value) if (!target) { return '' } return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) } const resolveLatestWorkbookPath = () => { const matches = fs .readdirSync(PROJECT_ROOT, { withFileTypes: true }) .filter( (entry) => entry.isFile() && entry.name.startsWith(WORKBOOK_PREFIX) && entry.name.endsWith('.xlsx') && !entry.name.startsWith('~$') ) .map((entry) => ({ path: path.join(PROJECT_ROOT, entry.name), mtimeMs: fs.statSync(path.join(PROJECT_ROOT, entry.name)).mtimeMs })) .sort((left, right) => left.mtimeMs - right.mtimeMs) if (!matches.length) { throw new Error(`未找到 ${WORKBOOK_PREFIX}*.xlsx`) } return matches[matches.length - 1].path } const resolveWorkbookPath = (value) => { const resolved = resolvePathArg(value) const workbookPath = resolved || resolveLatestWorkbookPath() if (!fs.existsSync(workbookPath)) { throw new Error(`工作簿不存在: ${workbookPath}`) } return workbookPath } const resolveOutputPath = (value) => { const resolved = resolvePathArg(value || DEFAULT_OUTPUT) if (!resolved) { return DEFAULT_OUTPUT } return resolved } const normalizeOverrideMap = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} } const result = {} for (const [key, rawValue] of Object.entries(value)) { const normalizedKey = normalizeText(key) const normalizedValue = normalizeText(rawValue) if (!normalizedKey || !normalizedValue) { continue } result[normalizedKey] = normalizedValue } 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 assetCode = normalizeAssetCode(key) if (!assetCode || !rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) { continue } const normalizedOverride = {} for (const [fieldKey, fieldValue] of Object.entries(rawOverride)) { const normalizedValue = normalizeText(fieldValue) if (!normalizedValue) { continue } normalizedOverride[fieldKey] = normalizedValue } if (Object.keys(normalizedOverride).length > 0) { result[assetCode] = normalizedOverride } } return result } const loadExistingOverrides = (filePath) => { if (!fs.existsSync(filePath)) { return { departmentAliases: {}, locationAliases: {}, ownerAliases: {}, assetOverrides: {} } } const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) return { departmentAliases: normalizeOverrideMap(parsed.departmentAliases), locationAliases: normalizeOverrideMap(parsed.locationAliases), ownerAliases: normalizeOverrideMap(parsed.ownerAliases), assetOverrides: normalizeAssetOverrideMap(parsed.assetOverrides) } } const readSheetRows = (workbook, sheetName) => { const sheet = workbook.Sheets[sheetName] if (!sheet) { return [] } return XLSX.utils.sheet_to_json(sheet, { defval: '' }) } const createWorkbookState = (existing) => ({ output: { departmentAliases: { ...existing.departmentAliases }, locationAliases: { ...existing.locationAliases }, ownerAliases: { ...existing.ownerAliases }, assetOverrides: Object.fromEntries( Object.entries(existing.assetOverrides).map(([assetCode, fields]) => [assetCode, { ...fields }]) ) }, provenance: { ownerAliases: Object.fromEntries( Object.keys(existing.ownerAliases).map((key) => [key, 'existing output']) ), assetOverrides: Object.fromEntries( Object.entries(existing.assetOverrides).map(([assetCode, fields]) => [ assetCode, Object.fromEntries(Object.keys(fields).map((fieldKey) => [fieldKey, 'existing output'])) ]) ) }, stats: { ownerAliasRowsApplied: 0, assetOverrideFieldsApplied: 0, assetOverrideRowsTouched: 0, blankEditableRows: 0, skippedRowsWithoutAssetCode: 0 } }) const setOwnerAlias = (state, ownerName, targetEmployee, context) => { const normalizedOwnerName = normalizeText(ownerName) const normalizedTarget = normalizeText(targetEmployee) if (!normalizedOwnerName || !normalizedTarget) { return } const currentValue = state.output.ownerAliases[normalizedOwnerName] const currentSource = state.provenance.ownerAliases[normalizedOwnerName] || '' if (currentValue && currentValue !== normalizedTarget && currentSource !== 'existing output') { throw new Error( `花名映射冲突: ${normalizedOwnerName} 在 ${currentSource} 和 ${context} 填了不同目标员工` ) } if (currentValue !== normalizedTarget) { state.stats.ownerAliasRowsApplied += 1 } state.output.ownerAliases[normalizedOwnerName] = normalizedTarget state.provenance.ownerAliases[normalizedOwnerName] = context } const setAssetOverrideField = (state, assetCode, fieldKey, fieldValue, context) => { const normalizedAssetCode = normalizeAssetCode(assetCode) const normalizedValue = normalizeText(fieldValue) if (!normalizedValue) { return false } if (!normalizedAssetCode) { state.stats.skippedRowsWithoutAssetCode += 1 return false } if (!state.output.assetOverrides[normalizedAssetCode]) { state.output.assetOverrides[normalizedAssetCode] = {} } if (!state.provenance.assetOverrides[normalizedAssetCode]) { state.provenance.assetOverrides[normalizedAssetCode] = {} } const currentValue = state.output.assetOverrides[normalizedAssetCode][fieldKey] const currentSource = state.provenance.assetOverrides[normalizedAssetCode][fieldKey] || '' if (currentValue && currentValue !== normalizedValue && currentSource !== 'existing output') { throw new Error( `资产覆盖冲突: ${normalizedAssetCode} 的 ${fieldKey} 在 ${currentSource} 和 ${context} 填了不同值` ) } if (currentValue !== normalizedValue) { if (Object.keys(state.output.assetOverrides[normalizedAssetCode]).length === 0) { state.stats.assetOverrideRowsTouched += 1 } state.stats.assetOverrideFieldsApplied += 1 } state.output.assetOverrides[normalizedAssetCode][fieldKey] = normalizedValue state.provenance.assetOverrides[normalizedAssetCode][fieldKey] = context return true } const processPendingSheet = (state, rows) => { for (let index = 0; index < rows.length; index += 1) { const row = rows[index] const context = `待确认部门 第${index + 2}行` const assetCode = row.legacy_asset_code const targets = [ ['department', row.行政填写_目标部门], ['owner', row.行政填写_目标员工], ['location', row.行政填写_标准地址], ['currentStatus', row.行政填写_资产状态] ] let applied = false for (const [fieldKey, fieldValue] of targets) { applied = setAssetOverrideField(state, assetCode, fieldKey, fieldValue, context) || applied } if (!applied) { state.stats.blankEditableRows += 1 } } } const processOwnerManualSheet = (state, rows) => { for (let index = 0; index < rows.length; index += 1) { const row = rows[index] const context = `待补使用人 第${index + 2}行` const applied = setAssetOverrideField(state, row.legacy_asset_code, 'owner', row.行政填写_目标员工, context) if (!applied) { state.stats.blankEditableRows += 1 } } } const processOwnerAliasSheet = (state, rows) => { for (let index = 0; index < rows.length; index += 1) { const row = rows[index] const ownerName = normalizeText(row.owner_name) const targetEmployee = normalizeText(row.行政填写_目标员工) if (!ownerName || !targetEmployee) { state.stats.blankEditableRows += 1 continue } setOwnerAlias(state, ownerName, targetEmployee, `花名核对 第${index + 2}行`) } } const processLocationSheet = (state, rows) => { for (let index = 0; index < rows.length; index += 1) { const row = rows[index] const context = `待补地址 第${index + 2}行` const applied = setAssetOverrideField( state, row.legacy_asset_code, 'location', row.行政填写_标准地址, context ) if (!applied) { state.stats.blankEditableRows += 1 } } } const sortObjectByKey = (value) => Object.fromEntries( Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey, 'zh-CN')) ) const sortNestedAssetOverrides = (assetOverrides) => Object.fromEntries( Object.entries(assetOverrides) .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey, 'zh-CN')) .map(([assetCode, fields]) => [assetCode, sortObjectByKey(fields)]) ) const buildSummary = (state) => ({ ownerAliases: Object.keys(state.output.ownerAliases).length, departmentAliases: Object.keys(state.output.departmentAliases).length, locationAliases: Object.keys(state.output.locationAliases).length, assetOverrides: Object.keys(state.output.assetOverrides).length, assetOverrideFields: Object.values(state.output.assetOverrides).reduce( (total, fields) => total + Object.keys(fields).length, 0 ), ownerAliasRowsApplied: state.stats.ownerAliasRowsApplied, assetOverrideFieldsApplied: state.stats.assetOverrideFieldsApplied, assetOverrideRowsTouched: state.stats.assetOverrideRowsTouched, blankEditableRows: state.stats.blankEditableRows, skippedRowsWithoutAssetCode: state.stats.skippedRowsWithoutAssetCode }) const run = () => { const options = parseArgs() const workbookPath = resolveWorkbookPath(options.workbookPath) const outputPath = resolveOutputPath(options.outputPath) const existing = loadExistingOverrides(outputPath) const state = createWorkbookState(existing) const workbook = XLSX.readFile(workbookPath, { raw: false }) processPendingSheet(state, readSheetRows(workbook, '待确认部门')) processOwnerManualSheet(state, readSheetRows(workbook, '待补使用人')) processOwnerAliasSheet(state, readSheetRows(workbook, '花名核对')) processLocationSheet(state, readSheetRows(workbook, '待补地址')) const finalOutput = { departmentAliases: sortObjectByKey(state.output.departmentAliases), locationAliases: sortObjectByKey(state.output.locationAliases), ownerAliases: sortObjectByKey(state.output.ownerAliases), assetOverrides: sortNestedAssetOverrides(state.output.assetOverrides) } fs.mkdirSync(path.dirname(outputPath), { recursive: true }) fs.writeFileSync(outputPath, `${JSON.stringify(finalOutput, null, 2)}\n`) console.log( JSON.stringify( { workbookPath, outputPath, summary: buildSummary(state) }, null, 2 ) ) } try { run() } catch (error) { console.error(error && error.stack ? error.stack : String(error)) process.exit(1) }