fix: 修复风险扫描去重key不匹配导致大量重复数据,Dashboard风险提醒按员工聚合
This commit is contained in:
@@ -447,9 +447,16 @@ export async function runRiskDetection(orgId: string) {
|
|||||||
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||||
const existingRisks = await prisma.riskItem.findMany({
|
const existingRisks = await prisma.riskItem.findMany({
|
||||||
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
|
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}`))
|
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),避免已完成的当月任务被重新创建
|
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
|
||||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
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 去重
|
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
|
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
|
||||||
const toCreate = [
|
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}`)),
|
...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) {
|
if (toCreate.length > 0) {
|
||||||
// 为每个风险计算量化信息
|
// 为每个风险计算量化信息
|
||||||
const createData = toCreate.map((r) => {
|
const createData = toCreate.map((r) => {
|
||||||
@@ -527,9 +565,8 @@ export async function getDashboardData(orgId: string) {
|
|||||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
||||||
prisma.riskItem.findMany({
|
prisma.riskItem.findMany({
|
||||||
where: { orgId, status: 'PENDING' },
|
where: { orgId, status: 'PENDING' },
|
||||||
include: { employee: true },
|
include: { employee: { select: { id: true, name: true, department: true, idCardHash: true } } },
|
||||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ level: 'asc' }, { estimatedLoss: 'desc' }],
|
||||||
take: 10,
|
|
||||||
}),
|
}),
|
||||||
prisma.riskItem.findMany({
|
prisma.riskItem.findMany({
|
||||||
where: { orgId, status: 'RESOLVED' },
|
where: { orgId, status: 'RESOLVED' },
|
||||||
@@ -759,9 +796,42 @@ export async function getDashboardData(orgId: string) {
|
|||||||
lossRange: r.lossRange as [number, number] | null,
|
lossRange: r.lossRange as [number, number] | null,
|
||||||
deadline: r.deadline?.toISOString().slice(0, 10) || null,
|
deadline: r.deadline?.toISOString().slice(0, 10) || null,
|
||||||
daysUntilDeadline,
|
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
|
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
||||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||||
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||||
@@ -783,9 +853,11 @@ export async function getDashboardData(orgId: string) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// 紧急风险横幅:最高优先级且预估损失>0
|
// 紧急风险横幅:最高优先级且预估损失>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]) => ({
|
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -978,8 +978,14 @@ export default function Dashboard() {
|
|||||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||||
<TodoIcon type={todo.type} level={todo.level} />
|
<TodoIcon type={todo.type} level={todo.level} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
{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] && (
|
{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}`}>
|
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||||
{priorityConfig[todo.priority].label}
|
{priorityConfig[todo.priority].label}
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface DashboardData {
|
|||||||
lossRange: [number, number] | null
|
lossRange: [number, number] | null
|
||||||
deadline: string | null
|
deadline: string | null
|
||||||
daysUntilDeadline: number | null
|
daysUntilDeadline: number | null
|
||||||
|
employeeName: string | null
|
||||||
|
employeeDepartment: string | null
|
||||||
}[]
|
}[]
|
||||||
resolvedTodos: {
|
resolvedTodos: {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user