fix: 修复风险扫描去重key不匹配导致大量重复数据,Dashboard风险提醒按员工聚合

This commit is contained in:
selfrelease
2026-07-31 11:26:03 +08:00
parent 64f4f3e86a
commit c184670775
3 changed files with 89 additions and 9 deletions
+79 -7
View File
@@ -447,9 +447,16 @@ export async function runRiskDetection(orgId: string) {
// 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
const existingRisks = await prisma.riskItem.findMany({
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
select: { employeeId: true, type: true },
select: { id: true, employeeId: true, type: true, title: true, status: true },
})
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`))
// PENDING 风险的 employeeId:type → record 映射,用于更新标题
const pendingRiskMap = new Map<string, typeof existingRisks[number]>()
for (const r of existingRisks) {
if (r.status === 'PENDING' && r.employeeId) {
pendingRiskMap.set(`${r.employeeId}:${r.type}`, r)
}
}
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
@@ -477,10 +484,41 @@ export async function runRiskDetection(orgId: string) {
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}`)),
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
]
// 更新已存在的 PENDING 风险:标题/量化信息可能因天数变化而变化
const toUpdate: { id: string; title: string; description: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
for (const r of nonMonthlyRisks) {
const key = `${r.employeeId}:${r.type}`
const existing = pendingRiskMap.get(key)
if (existing && existing.title !== r.title) {
const emp = r.employeeId ? empMap.get(r.employeeId) : null
const cost = estimateRiskCost(r, emp)
toUpdate.push({
id: existing.id,
title: r.title,
description: r.description,
estimatedLoss: cost.estimatedLoss,
lossRange: cost.lossRange,
deadline: cost.deadline,
})
}
}
for (const u of toUpdate) {
await prisma.riskItem.update({
where: { id: u.id },
data: {
title: u.title,
description: u.description,
estimatedLoss: u.estimatedLoss,
lossRange: u.lossRange,
deadline: u.deadline,
},
})
}
if (toCreate.length > 0) {
// 为每个风险计算量化信息
const createData = toCreate.map((r) => {
@@ -527,9 +565,8 @@ export async function getDashboardData(orgId: string) {
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
include: { employee: true },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
take: 10,
include: { employee: { select: { id: true, name: true, department: true, idCardHash: true } } },
orderBy: [{ level: 'asc' }, { estimatedLoss: 'desc' }],
}),
prisma.riskItem.findMany({
where: { orgId, status: 'RESOLVED' },
@@ -759,9 +796,42 @@ export async function getDashboardData(orgId: string) {
lossRange: r.lossRange as [number, number] | null,
deadline: r.deadline?.toISOString().slice(0, 10) || null,
daysUntilDeadline,
employeeName: r.employee?.name || null,
employeeDepartment: r.employee?.department || null,
employeeIdCardHash: r.employee?.idCardHash || null,
}
})
// 按员工 + 风险类别去重:同一员工同一类别只保留 estimatedLoss 最大的一条
function getTodoRiskCategoryKey(title: string): string {
if (title.includes('未签合同') && title.includes('无固定期限')) return 'CONTRACT_UNSIGNED_OVER_YEAR'
if (title.includes('未签合同') && title.includes('个月')) return 'CONTRACT_UNSIGNED'
if (title.includes('到期') && title.includes('未续签')) return 'CONTRACT_EXPIRED'
if (title.includes('即将到期')) return 'CONTRACT_EXPIRING'
if (title.includes('试用期')) return 'CONTRACT_PROBATION'
if (title.includes('入职手续未完成')) return 'ONBOARDING_INCOMPLETE'
if (title.includes('孕期') || title.includes('哺乳期')) return 'SPECIAL_PREGNANCY'
if (title.includes('工伤')) return 'SPECIAL_INJURY'
if (title.includes('医疗期')) return 'SPECIAL_MEDICAL'
if (title.includes('社保')) return 'MONTHLY_SOCIAL'
if (title.includes('公积金')) return 'MONTHLY_HOUSING'
if (title.includes('工资')) return 'MONTHLY_PAYROLL'
if (title.includes('个税')) return 'MONTHLY_TAX'
return title.replace(/\d+/g, '').trim()
}
const dedupedTodoMap = new Map<string, typeof todosWithCost[number]>()
for (const t of todosWithCost) {
const personKey = t.employeeIdCardHash || t.employeeName || t.id
const catKey = getTodoRiskCategoryKey(t.title || '')
const dedupKey = `${personKey}:${catKey}`
const existing = dedupedTodoMap.get(dedupKey)
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 }
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
@@ -783,9 +853,11 @@ export async function getDashboardData(orgId: string) {
}))
// 紧急风险横幅:最高优先级且预估损失>0
const urgentRisk = todosWithCost.find((t) => t.priority === 'URGENT' && t.estimatedLoss > 0) || null
const urgentRisk = dedupedTodos.find((t) => t.priority === 'URGENT' && t.estimatedLoss > 0) || null
const todos = todosWithCost
// 按优先级排序去重后的 todos
dedupedTodos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
const todos = dedupedTodos
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
id: r.id,
+8 -2
View File
@@ -978,8 +978,14 @@ export default function Dashboard() {
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-800">{todo.title}</span>
<div className="flex items-center gap-1.5 flex-wrap">
{todo.employeeName && (
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
)}
{todo.employeeDepartment && (
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
)}
<span className="text-xs text-gray-700">{todo.title}</span>
{todo.priority && priorityConfig[todo.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
{priorityConfig[todo.priority].label}
+2
View File
@@ -108,6 +108,8 @@ export interface DashboardData {
lossRange: [number, number] | null
deadline: string | null
daysUntilDeadline: number | null
employeeName: string | null
employeeDepartment: string | null
}[]
resolvedTodos: {
id: string