GuDingZiChan/backend/scripts/buildMigrationManualOverridesFromWorkbook.js
2026-03-23 12:05:51 +08:00

390 lines
12 KiB
JavaScript

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)
}