57 lines
4.9 KiB
TypeScript
57 lines
4.9 KiB
TypeScript
/** 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?.())
|