gongxiangp2/scripts/initialize-personnel-salary.ts

57 lines
4.9 KiB
TypeScript
Raw 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.

/** Local-only, explicit payroll initialization. No workbook data or person mapping belongs in Git.
* tsx scripts/initialize-personnel-salary.ts --workbook=/absolute/source.xlsx --manifest=/absolute/private.json [--apply]
* Manifest: { sheet, startRow, endRow, actorUserId, people: [{row, employeeProfileId, name, flowerName, jobNumber}] }
*/
import { loadEnvConfig } from '@next/env'
import ExcelJS from 'exceljs'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import { createSalarySchema } from '../lib/personnel-salary-contract'
loadEnvConfig(process.cwd())
// Load the database module only after local environment validation has run.
let disconnect: (() => Promise<void>) | undefined
const manifestSchema = z.object({ sheet: z.string(), startRow: z.number().int().positive(), endRow: z.number().int().positive(), actorUserId: z.string(),
people: z.array(z.object({ row: z.number().int().positive(), employeeProfileId: z.string().uuid(), name: z.string(), flowerName: z.string(), jobNumber: z.string() })).length(7) }).strict()
async function main() {
const database = new URL(process.env.DATABASE_URL || '')
if (!['127.0.0.1', 'localhost', '[::1]'].includes(database.hostname) || !database.pathname.includes('salary_isolated')) throw new Error('仅允许连接本机 salary_isolated 隔离数据库,拒绝其他目标。')
const { prisma } = require('../lib/prisma') as typeof import('../lib/prisma')
const { listEmployedBrandCenterPeople } = require('../lib/employee-directory') as typeof import('../lib/employee-directory')
const { initializeSalaryRecords, requireSalaryAccess } = require('../lib/personnel-salary') as typeof import('../lib/personnel-salary')
disconnect = () => prisma.$disconnect()
const args = process.argv.slice(2)
const option = (name: string) => args.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3)
const workbookPath = option('workbook'), manifestPath = option('manifest')
if (!workbookPath || !manifestPath) throw new Error('缺少 --workbook 和 --manifest 参数。')
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')))
if (manifest.endRow - manifest.startRow + 1 !== manifest.people.length || new Set(manifest.people.map(person => person.row)).size !== manifest.people.length) throw new Error('清单行范围或人员重复。')
await requireSalaryAccess(manifest.actorUserId, true)
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(workbookPath)
const sheet = workbook.getWorksheet(manifest.sheet)
if (!sheet) throw new Error('工作表不存在。')
const candidates = await listEmployedBrandCenterPeople(prisma)
const rows = manifest.people.map(expected => {
if (expected.row < manifest.startRow || expected.row > manifest.endRow) throw new Error('人员行超出范围。')
// Read cached formula results only; never evaluate formulas or external workbook links.
// Every identity result is still checked against the explicit stable-ID manifest and current roster.
const cells = Array.from({ length: 7 }, (_, index) => {
const cell = sheet.getRow(expected.row).getCell(index + 1).value
const value = cell && typeof cell === 'object' && 'formula' in cell ? cell.result : cell
if (value !== null && typeof value !== 'string' && typeof value !== 'number') throw new Error('单元格无可核验的缓存值,请人工检查来源。')
return value
})
const value = createSalarySchema.parse({ employeeProfileId: expected.employeeProfileId, name: String(cells[0] ?? '').trim(), flowerName: String(cells[1] ?? '').trim(),
department: String(cells[2] ?? '').trim(), brand: String(cells[3] ?? '').trim(), baseSalary: String(cells[4] ?? ''), gradeSalary: String(cells[5] ?? ''), performanceSalary: String(cells[6] ?? '') })
const person = candidates.find(candidate => candidate.employeeProfileId === expected.employeeProfileId)
if (!person || person.name !== expected.name || person.flowerName !== expected.flowerName || person.jobNumber !== expected.jobNumber || value.name !== expected.name || value.flowerName !== expected.flowerName) throw new Error('稳定人员 ID 与花名册或来源不一致;停止导入。')
return { expectedJobNumber: expected.jobNumber, expectedFlowerName: expected.flowerName, value }
})
if (new Set(rows.map(row => row.value.employeeProfileId)).size !== rows.length) throw new Error('人员 ID 重复。')
if (!args.includes('--apply')) { console.log(JSON.stringify({ dryRun: true, verifiedPeople: rows.length, message: '已重新核验稳定 ID,未写入;使用 --apply 执行一次性初始化。' })); return }
console.log(JSON.stringify(await initializeSalaryRecords(rows, manifest.actorUserId)))
}
main().catch(() => { console.error('初始化失败,未输出薪资或身份明细;请核对本地环境、权限、人员清单和来源格式。'); process.exitCode = 1 }).finally(() => disconnect?.())