390 lines
12 KiB
JavaScript
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)
|
|
}
|