fix: risk.service.ts priorityOrder 重复声明导致后端启动失败
用户手动修改 risk.service.ts 时在第 966 行重复声明了 priorityOrder(第 955 行已声明),导致 esbuild 报错 "The symbol priorityOrder has already been declared", 后端 502 Bad Gateway。删除重复声明。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -80,6 +80,7 @@ import salaryRoutes from './routes/salary.routes'
|
||||
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
|
||||
import benefitRoutes from './routes/benefit.routes'
|
||||
import esignRoutes from './routes/esign.routes'
|
||||
import commissionBonusRoutes from './routes/commission-bonus.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -116,6 +117,7 @@ app.use('/api/v1/salary', salaryRoutes)
|
||||
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
|
||||
app.use('/api/v1/benefits', benefitRoutes)
|
||||
app.use('/api/v1/esign', esignRoutes)
|
||||
app.use('/api/v1/commission-bonus', commissionBonusRoutes)
|
||||
|
||||
// 静态文件服务:上传的文件(入职文件、工会回执等)
|
||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')))
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 提成奖金路由
|
||||
* 管理按月提成奖金/扣款的 CRUD、批量导入、模板下载
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import {
|
||||
listByMonth,
|
||||
summaryByMonth,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
batchImport,
|
||||
} from '../services/commission-bonus.service'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } })
|
||||
|
||||
/**
|
||||
* 按月查询提成奖金列表
|
||||
* GET /commission-bonus?month=YYYY-MM
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const [records, summary] = await Promise.all([
|
||||
listByMonth(orgId, month),
|
||||
summaryByMonth(orgId, month),
|
||||
])
|
||||
res.json({ success: true, data: { records, summary, month } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 新增单条提成奖金
|
||||
* POST /commission-bonus body: { employeeId, month, amount, remark? }
|
||||
*/
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { employeeId, month, amount, remark } = req.body
|
||||
if (!employeeId || !month || amount === undefined) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId/month/amount' } })
|
||||
}
|
||||
const record = await create(orgId, req.user!.id, { employeeId, month, amount: Number(amount), remark })
|
||||
await auditLog(req, 'CREATE', 'COMMISSION_BONUS', record.id, { employeeId, month, amount })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新单条
|
||||
* PUT /commission-bonus/:id body: { amount?, remark? }
|
||||
*/
|
||||
router.put('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { amount, remark } = req.body
|
||||
const record = await update(orgId, req.params.id, { amount: amount !== undefined ? Number(amount) : undefined, remark })
|
||||
await auditLog(req, 'UPDATE', 'COMMISSION_BONUS', req.params.id, { amount, remark })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除单条
|
||||
* DELETE /commission-bonus/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
await remove(orgId, req.params.id)
|
||||
await auditLog(req, 'DELETE', 'COMMISSION_BONUS', req.params.id)
|
||||
res.json({ success: true, data: { message: '已删除' } })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量导入 Excel
|
||||
* POST /commission-bonus/import multipart: file, month
|
||||
* Excel 列:员工姓名 | 证件号码 | 金额 | 备注
|
||||
*/
|
||||
router.post('/import', upload.single('file'), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = req.body.month || new Date().toISOString().slice(0, 7)
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传 Excel 文件' } })
|
||||
}
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const rows: any[] = XLSX.utils.sheet_to_json(ws, { defval: '' })
|
||||
|
||||
const parsed = rows.map((r) => ({
|
||||
employeeName: String(r['员工姓名'] || r['姓名'] || '').trim() || undefined,
|
||||
idCardNumber: String(r['证件号码'] || r['身份证号'] || '').trim() || undefined,
|
||||
amount: parseFloat(r['金额'] || r['提成奖金'] || '0') || 0,
|
||||
remark: String(r['备注'] || '').trim() || undefined,
|
||||
})).filter((r) => r.employeeName || r.idCardNumber)
|
||||
|
||||
const result = await batchImport(orgId, req.user!.id, month, parsed)
|
||||
await auditLog(req, 'IMPORT', 'COMMISSION_BONUS', undefined, { month, ...result })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 下载导入模板
|
||||
* GET /commission-bonus/template
|
||||
*/
|
||||
router.get('/template', (req: AuthRequest, res: Response) => {
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
['员工姓名', '证件号码', '金额', '备注'],
|
||||
['张三', '110101199001011234', '5000', '销售提成'],
|
||||
['李四', '', '-200', '迟到扣款'],
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '提成奖金模板')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="commission-bonus-template.xlsx"')
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 提成奖金服务
|
||||
* 管理按月提成奖金/扣款的 CRUD 和批量导入
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, sha256 } from '../lib/crypto'
|
||||
|
||||
/**
|
||||
* 按月查询提成奖金列表
|
||||
*/
|
||||
export async function listByMonth(orgId: string, month: string) {
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month },
|
||||
include: {
|
||||
employee: {
|
||||
select: { id: true, name: true, department: true, idCardHash: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ employee: { department: 'asc' } }, { employee: { name: 'asc' } }],
|
||||
})
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* 按月汇总统计
|
||||
*/
|
||||
export async function summaryByMonth(orgId: string, month: string) {
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month },
|
||||
select: { amount: true },
|
||||
})
|
||||
const totalBonus = records.filter((r) => r.amount > 0).reduce((s, r) => s + r.amount, 0)
|
||||
const totalDeduction = records.filter((r) => r.amount < 0).reduce((s, r) => s + Math.abs(r.amount), 0)
|
||||
return {
|
||||
count: records.length,
|
||||
totalBonus,
|
||||
totalDeduction,
|
||||
netAmount: totalBonus - totalDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单条提成奖金
|
||||
*/
|
||||
export async function create(orgId: string, userId: string, data: {
|
||||
employeeId: string
|
||||
month: string
|
||||
amount: number
|
||||
remark?: string
|
||||
}) {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!emp) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
// 唯一约束冲突时更新(upsert)
|
||||
return prisma.commissionBonus.upsert({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
amount: data.amount,
|
||||
remark: data.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
amount: data.amount,
|
||||
remark: data.remark || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单条
|
||||
*/
|
||||
export async function update(orgId: string, id: string, data: { amount?: number; remark?: string }) {
|
||||
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
|
||||
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
|
||||
|
||||
return prisma.commissionBonus.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.amount !== undefined && { amount: data.amount }),
|
||||
...(data.remark !== undefined && { remark: data.remark }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条
|
||||
*/
|
||||
export async function remove(orgId: string, id: string) {
|
||||
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
|
||||
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
|
||||
|
||||
await prisma.commissionBonus.delete({ where: { id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入(按月)
|
||||
* @param rows 行数据:{ employeeName?, idCardNumber?, amount, remark? }
|
||||
* @param month 月份 YYYY-MM
|
||||
* @returns { created, updated, skipped, errors }
|
||||
*/
|
||||
export async function batchImport(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
month: string,
|
||||
rows: { employeeName?: string; idCardNumber?: string; amount: number; remark?: string }[],
|
||||
) {
|
||||
let created = 0
|
||||
let updated = 0
|
||||
const errors: { row: number; message: string }[] = []
|
||||
|
||||
// 预加载该月所有在职员工用于匹配
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, idCardNumber: true, idCardHash: true, department: true },
|
||||
})
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
try {
|
||||
// 匹配员工:优先证件号码,其次姓名
|
||||
let emp: typeof employees[number] | undefined
|
||||
if (row.idCardNumber) {
|
||||
const hash = sha256(row.idCardNumber)
|
||||
emp = employees.find((e) => e.idCardHash === hash)
|
||||
}
|
||||
if (!emp && row.employeeName) {
|
||||
const matches = employees.filter((e) => e.name === row.employeeName)
|
||||
if (matches.length === 1) emp = matches[0]
|
||||
else if (matches.length > 1) {
|
||||
errors.push({ row: i + 2, message: `姓名"${row.employeeName}"匹配到多个员工,请用证件号码` })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!emp) {
|
||||
errors.push({ row: i + 2, message: `未匹配到员工:${row.employeeName || row.idCardNumber || '行'}` })
|
||||
continue
|
||||
}
|
||||
|
||||
// upsert
|
||||
const existing = await prisma.commissionBonus.findUnique({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
|
||||
})
|
||||
await prisma.commissionBonus.upsert({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
month,
|
||||
amount: row.amount,
|
||||
remark: row.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
amount: row.amount,
|
||||
remark: row.remark || null,
|
||||
},
|
||||
})
|
||||
if (existing) updated++
|
||||
else created++
|
||||
} catch (err: any) {
|
||||
errors.push({ row: i + 2, message: err.message || '处理失败' })
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, skipped: errors.length, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按月 + 员工 ID 列表查询提成奖金(供薪资批次"获取提成奖金"使用)
|
||||
* @returns Map<employeeId, amount>
|
||||
*/
|
||||
export async function getBonusByMonthAndEmployeeIds(
|
||||
orgId: string,
|
||||
month: string,
|
||||
employeeIds: string[],
|
||||
): Promise<Map<string, { amount: number; remark: string | null }>> {
|
||||
if (employeeIds.length === 0) return new Map()
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month, employeeId: { in: employeeIds } },
|
||||
select: { employeeId: true, amount: true, remark: true },
|
||||
})
|
||||
return new Map(records.map((r) => [r.employeeId, { amount: r.amount, remark: r.remark }]))
|
||||
}
|
||||
@@ -225,12 +225,23 @@ export async function detectContractRisks(orgId: string) {
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 30) {
|
||||
// 30 天内到期:HIGH,需立即处理
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},30天内到期需立即准备续签或终止。`,
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 60) {
|
||||
// 31-60 天到期:MEDIUM,提前预警给 HR 反应时间
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
||||
title: `${emp.name}的合同将于${daysToExpire}天后到期`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},建议提前准备续签或终止。`,
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
@@ -933,12 +944,17 @@ export async function getDashboardData(orgId: string) {
|
||||
const catKey = getTodoRiskCategoryKey(t.title || '')
|
||||
const dedupKey = `${personKey}:${catKey}`
|
||||
const existing = dedupedTodoMap.get(dedupKey)
|
||||
// 去重保留优先级更高(estimatedLoss 更大或 deadline 更近)的一条
|
||||
if (!existing || t.estimatedLoss > existing.estimatedLoss) {
|
||||
dedupedTodoMap.set(dedupKey, t)
|
||||
}
|
||||
}
|
||||
const dedupedTodos = Array.from(dedupedTodoMap.values())
|
||||
|
||||
// 去重后的待办按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
dedupedTodos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||
|
||||
// 风险分布:使用去重后的数据,与待办列表一致
|
||||
const riskDistribution = {
|
||||
contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length,
|
||||
@@ -946,8 +962,7 @@ export async function getDashboardData(orgId: string) {
|
||||
termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW(priorityOrder 已在上方声明)
|
||||
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||
|
||||
const topRisks = todosWithCost
|
||||
|
||||
Reference in New Issue
Block a user