fix: 修复风险扫描去重key不匹配导致大量重复数据,Dashboard风险提醒按员工聚合
This commit is contained in:
@@ -447,9 +447,16 @@ export async function runRiskDetection(orgId: string) {
|
||||
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user