{formatMonthLabel(row.month)} - CURRENT + {row.month === getCurrentMonthValue() && ( + 本月 + )} {templates.map((template) => ( onOpenDetail({ @@ -995,7 +999,7 @@ function MonthlyPublicLedger({ - + {templates.map((template) => ( void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined - const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed)) - const className = `${compact ? 'min-h-[58px] px-2 py-2' : 'min-h-[86px] px-3 py-3'} border text-left transition-colors ${ - canOpen - ? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]' - : 'border-[#e2e2e2] bg-[#f9f9f9]' - }` + const hasSurvey = Number(cell?.surveyCount || 0) > 0 + const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed)) + const className = embedded + ? `${compact ? 'min-h-[82px]' : 'min-h-[100px]'} px-1 py-2 text-left transition-colors ${canOpen ? 'hover:bg-[#f8faff]' : ''}` + : `${compact ? 'min-h-[82px] px-2 py-2' : 'min-h-[108px] px-3 py-3'} border text-left transition-colors ${ + canOpen + ? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]' + : 'border-[#e2e2e2] bg-[#f9f9f9]' + }` const content = ( <> - {getTemplateDisplayName(template)} + + {getTemplateDisplayName(template)} + {hasSurvey && } + + + {cell?.title || '未发起'} + - - {hasScore ? formatNullableScore(cell.score) : '-'} + + {hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'} {hasScore && /120} @@ -1116,25 +1131,29 @@ function MonthlyTableScoreCell({ onClick: () => void }) { const hasScore = cell && cell.score !== null && cell.score !== undefined - const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed)) + const hasSurvey = Number(cell?.surveyCount || 0) > 0 + const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed)) const content = ( - <> - - {hasScore ? formatNullableScore(cell.score) : '-'} - - {hasScore && /120} - > + + + + {hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'} + + {hasScore && /120} + + {hasSurvey && {cell?.title}} + ) if (!canOpen) { - return {content} + return {content} } return ( {content} @@ -1150,6 +1169,24 @@ function EmptyLedger({ title }: { title: string }) { ) } +function getSurveyStatusMeta(status: unknown) { + const value = String(status || '').toLowerCase() + if (value === 'open') return { label: '进行中', dot: 'bg-[#0050cb]', text: 'text-[#0050cb]' } + if (value === 'closed') return { label: '已结束', dot: 'bg-[#5f5e5e]', text: 'text-[#5f5e5e]' } + if (value === 'draft') return { label: '草稿', dot: 'bg-[#b26a00]', text: 'text-[#8a5200]' } + return { label: '待发布', dot: 'bg-[#8a8a8a]', text: 'text-[#5f5e5e]' } +} + +function InlineSurveyState({ status }: { status: unknown }) { + const meta = getSurveyStatusMeta(status) + return ( + + + {meta.label} + + ) +} + function MonthlyScoreDetailModal({ request, onClose, @@ -1201,7 +1238,8 @@ function MonthlyScoreDetailModal({ const isVisualDetail = Boolean(detail.data?.template?.isVisual || detail.data?.workItems?.length) const responseCount = Number(detail.data?.responseCount || detail.data?.participants?.length || 0) - const surveyCount = Number(detail.data?.surveyCount || 0) + const primarySurvey = detail.data?.surveys?.[0] || null + const workflow = detail.data?.workflow || {} return createPortal(( - 评估详情报告 + + {formatMonthLabel(detail.data?.month || request.month)} + · + {detail.data?.template?.name || '考核明细'} + {primarySurvey && } + - {detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'} + {primarySurvey?.title || detail.data?.template?.fullName || '考核明细'} × @@ -1241,20 +1284,21 @@ function MonthlyScoreDetailModal({ {detail.error && } {detail.data && ( - - + - - + + + - - 核心评分 - - - - + + 调研进度 + + + + + {isVisualDetail && ( @@ -1292,15 +1336,6 @@ function MonthlyScoreDetailModal({ )} - - - 关闭详情 - - ), document.body) @@ -1323,28 +1358,77 @@ function DetailSectionTitle({ children }: { children: ReactNode }) { ) } -function DetailScoreCard({ - label, - value, - suffix, - strong = false, -}: { - label: string - value: ReactNode - suffix?: string - strong?: boolean -}) { +function WorkflowCount({ label, value, total }: { label: string; value: number; total: number }) { return ( - - {label} - - {value} - {suffix && {suffix}} + + {label} + + {value}/ {total} ) } +function WorkflowState({ label, tone = 'muted' }: { label: string; tone?: 'blue' | 'green' | 'red' | 'muted' }) { + const toneClass = tone === 'green' + ? 'bg-[#149447]' + : tone === 'blue' + ? 'bg-[#0050cb]' + : tone === 'red' + ? 'bg-[#ba1a1a]' + : 'bg-[#8a8a8a]' + return ( + + + {label} + + ) +} + +function EvaluatorWorkflowList({ items }: { items: any[] }) { + if (!items.length) { + return 暂无评价人 + } + + return ( + + {items.map((item) => { + const noticeStatus = String(item.notification?.status || '') + const readStatus = String(item.notification?.readStatus || 'unknown') + const notice = noticeStatus === 'succeeded' + ? { label: '已发送', tone: 'blue' as const } + : noticeStatus === 'processing' || noticeStatus === 'pending' || noticeStatus === 'retrying' + ? { label: '发送中', tone: 'muted' as const } + : noticeStatus === 'skipped' || noticeStatus === 'exhausted' + ? { label: '发送失败', tone: 'red' as const } + : { label: '未发起', tone: 'muted' as const } + const read = readStatus === 'read' + ? { label: '已查看', tone: 'green' as const } + : readStatus === 'unread' + ? { label: '未查看', tone: 'muted' as const } + : { label: '未确认', tone: 'muted' as const } + const submitted = item.assignmentStatus === 'submitted' + ? { label: '已评分', tone: 'green' as const } + : { label: '待评分', tone: 'muted' as const } + + return ( + + + {item.name} + {item.submittedAt && {formatDate(item.submittedAt)}} + + + + + + + + ) + })} + + ) +} + function ScoreParticipantList({ items }: { items: any[] }) { if (!items.length) { return 暂无评分记录 @@ -2117,7 +2201,8 @@ function CreateSurveyForm({ const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [templateCode, setTemplateCode] = useState('') - const [dueAt, setDueAt] = useState(getCurrentMonthValue()) + const [assessmentMonth, setAssessmentMonth] = useState(getCurrentMonthValue()) + const [deadlineAt, setDeadlineAt] = useState('') const openNow = true const [targetEmployees, setTargetEmployees] = useState([]) const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState([]) @@ -2200,10 +2285,10 @@ function CreateSurveyForm({ } } } - const assessmentMonth = dueAt || getCurrentMonthValue() + const selectedAssessmentMonth = assessmentMonth || getCurrentMonthValue() const submitTitle = title.trim() || targetItemsToSubmit[0]?.name - || `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}` + || `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}` const created = await apiRequest('/surveys', { method: 'POST', body: JSON.stringify({ @@ -2211,7 +2296,8 @@ function CreateSurveyForm({ description, surveyKind, templateCode: selectedTemplate, - dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null, + assessmentMonth: selectedAssessmentMonth, + dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null, openNow, targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id), targetItems: targetItemsToSubmit, @@ -2243,7 +2329,8 @@ function CreateSurveyForm({ setTitle('') setDescription('') - setDueAt(getCurrentMonthValue()) + setAssessmentMonth(getCurrentMonthValue()) + setDeadlineAt('') setTargetEmployees([]) setManualEvaluatorEmployees([]) setEvaluatorDepartmentValues([]) @@ -2274,7 +2361,8 @@ function CreateSurveyForm({ - + + 考评模板 { expect(body.msg.action_card.single_url).toContain('app_id=0_4279191869') expect(body.msg.action_card.single_url).toContain(encodeURIComponent('https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey')) }) + + test('工作通知发送结果会区分已读和未读人员', async () => { + process.env.DINGTALK_CLIENT_ID = 'login-client-id' + process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret' + process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869' + + ;(global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errcode: 0, + send_result: { + read_user_id_list: ['ding-read'], + unread_user_id_list: ['ding-unread'], + failed_user_id_list: ['ding-failed'], + }, + }), + }) + + const { getDingtalkWorkNoticeSendResult } = await import('@/lib/dingtalk-client') + const result = await getDingtalkWorkNoticeSendResult('task-1') + + expect(result.readUserIds).toEqual(['ding-read']) + expect(result.unreadUserIds).toEqual(['ding-unread']) + expect(result.failedUserIds).toEqual(['ding-failed']) + const [, options] = (global.fetch as jest.Mock).mock.calls[1] + expect(JSON.parse(options.body)).toEqual({ + agent_id: 4279191869, + task_id: 'task-1', + }) + }) }) diff --git a/lib/__tests__/performance-survey-notifications.test.ts b/lib/__tests__/performance-survey-notifications.test.ts index 8117add..06db247 100644 --- a/lib/__tests__/performance-survey-notifications.test.ts +++ b/lib/__tests__/performance-survey-notifications.test.ts @@ -4,10 +4,11 @@ import { processPerformanceSurveyNotification, queueSurveyTargetConfirmationNotifications, recoverDuePerformanceSurveyNotifications, + refreshPerformanceSurveyNotificationReadStatuses, retryPerformanceSurveyNotification, } from '@/lib/performance-survey-notifications' import { prisma } from '@/lib/prisma' -import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client' +import { getDingtalkWorkNoticeSendResult, sendDingtalkWorkNotice } from '@/lib/dingtalk-client' jest.mock('@/lib/prisma', () => ({ prisma: { @@ -33,6 +34,7 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/dingtalk-client', () => ({ sendDingtalkWorkNotice: jest.fn(), + getDingtalkWorkNoticeSendResult: jest.fn(), })) jest.mock('@/lib/public-app-url', () => ({ @@ -70,6 +72,14 @@ describe('performance survey notifications', () => { taskId: 'task-1', raw: { errcode: 0, task_id: 'task-1' }, }) + ;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({ + readUserIds: [], + unreadUserIds: [], + failedUserIds: [], + invalidUserIds: [], + forbiddenUserIds: [], + raw: { errcode: 0 }, + }) }) afterEach(() => { @@ -264,4 +274,32 @@ describe('performance survey notifications', () => { }), }) }) + + test('通知发送结果会把收件人更新为已查看', async () => { + ;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{ + id: 'notification-1', + providerMessageId: 'task-1', + dingtalkUserId: 'ding-user-1', + }]) + ;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({ + readUserIds: ['ding-user-1'], + unreadUserIds: [], + failedUserIds: [], + invalidUserIds: [], + forbiddenUserIds: [], + raw: { errcode: 0 }, + }) + + const count = await refreshPerformanceSurveyNotificationReadStatuses() + + expect(count).toBe(1) + expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({ + where: { id: 'notification-1' }, + data: expect.objectContaining({ + readStatus: 'read', + readAt: expect.any(Date), + readStatusCheckedAt: expect.any(Date), + }), + }) + }) }) diff --git a/lib/__tests__/performance-survey-period.test.ts b/lib/__tests__/performance-survey-period.test.ts new file mode 100644 index 0000000..ac2a260 --- /dev/null +++ b/lib/__tests__/performance-survey-period.test.ts @@ -0,0 +1,49 @@ +/** @jest-environment node */ + +import { + PerformanceSurveyApiError, + resolvePerformanceSurveyPeriodInput, +} from '@/lib/performance-survey-service' + +describe('performance survey period', () => { + const now = new Date('2026-07-10T08:00:00.000Z') + + test('考核月份不会再自动变成截止时间', () => { + const result = resolvePerformanceSurveyPeriodInput({ + assessmentMonth: '2026-03', + dueAt: null, + openNow: true, + }, now) + + expect(result).toEqual({ + assessmentMonth: '2026-03', + dueAt: null, + }) + }) + + test('独立设置的未来截止时间会被保留', () => { + const result = resolvePerformanceSurveyPeriodInput({ + assessmentMonth: '2026-07', + dueAt: '2026-07-20T10:00:00.000Z', + openNow: true, + }, now) + + expect(result.assessmentMonth).toBe('2026-07') + expect(result.dueAt?.toISOString()).toBe('2026-07-20T10:00:00.000Z') + }) + + test('发布时不允许使用已经过去的截止时间', () => { + expect(() => resolvePerformanceSurveyPeriodInput({ + assessmentMonth: '2026-07', + dueAt: '2026-07-01T00:00:00.000Z', + openNow: true, + }, now)).toThrow(new PerformanceSurveyApiError('截止时间必须晚于当前时间')) + }) + + test('显式传入无效月份时不会静默改成当前月', () => { + expect(() => resolvePerformanceSurveyPeriodInput({ + assessmentMonth: '2026-13', + dueAt: null, + }, now)).toThrow(new PerformanceSurveyApiError('考核月份格式无效')) + }) +}) diff --git a/lib/dingtalk-client.ts b/lib/dingtalk-client.ts index 99a9bb8..5371e17 100644 --- a/lib/dingtalk-client.ts +++ b/lib/dingtalk-client.ts @@ -13,6 +13,15 @@ type DingtalkWorkNoticeResult = { raw?: unknown } +export type DingtalkWorkNoticeSendResult = { + readUserIds: string[] + unreadUserIds: string[] + failedUserIds: string[] + invalidUserIds: string[] + forbiddenUserIds: string[] + raw: unknown +} + type DingtalkAppConfig = { appKey: string appSecret: string @@ -264,3 +273,49 @@ export async function sendDingtalkWorkNotice( raw: data, } } + +export async function getDingtalkWorkNoticeSendResult( + taskId: string, +): Promise { + const agentId = Number(getDingtalkWorkNoticeAgentId()) + if (!Number.isFinite(agentId) || agentId <= 0) { + throw new Error('钉钉工作通知 DINGTALK_WORK_NOTICE_AGENT_ID 必须是正数') + } + + const normalizedTaskId = String(taskId || '').trim() + if (!normalizedTaskId) throw new Error('缺少钉钉工作通知 taskId') + + const token = await getDingtalkWorkNoticeAccessToken() + const response = await fetch( + `https://oapi.dingtalk.com/topapi/message/corpconversation/getsendresult?access_token=${encodeURIComponent(token)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_id: agentId, + task_id: normalizedTaskId, + }), + }, + ) + const data = await response.json().catch(async () => ({ raw: await response.text() })) + if (!response.ok) { + throw new Error(`查询钉钉工作通知发送结果失败: HTTP ${response.status}`) + } + if (data.errcode !== 0) { + throw new Error(`查询钉钉工作通知发送结果失败: ${data.errmsg || '未知错误'}`) + } + + const result = data.send_result && typeof data.send_result === 'object' ? data.send_result : {} + const stringList = (value: unknown) => Array.isArray(value) + ? value.map((item) => String(item || '').trim()).filter(Boolean) + : [] + + return { + readUserIds: stringList(result.read_user_id_list), + unreadUserIds: stringList(result.unread_user_id_list), + failedUserIds: stringList(result.failed_user_id_list), + invalidUserIds: stringList(result.invalid_user_id_list), + forbiddenUserIds: stringList(result.forbidden_user_id_list), + raw: data, + } +} diff --git a/lib/performance-survey-notifications.ts b/lib/performance-survey-notifications.ts index 753a42d..60053f7 100644 --- a/lib/performance-survey-notifications.ts +++ b/lib/performance-survey-notifications.ts @@ -2,7 +2,10 @@ import { randomUUID } from 'crypto' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' import { buildDingtalkEntryPath } from '@/lib/safe-redirect' -import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client' +import { + getDingtalkWorkNoticeSendResult, + sendDingtalkWorkNotice, +} from '@/lib/dingtalk-client' const STATUS = { PENDING: 'pending', @@ -18,11 +21,13 @@ const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 6 const RECOVERY_INTERVAL_MS = 60_000 const REMINDER_INTERVAL_MS = 10 * 60_000 const STALE_PROCESSING_MS = 5 * 60_000 +const READ_STATUS_REFRESH_MS = 5 * 60_000 let workerStarted = false let recoveryTimer: NodeJS.Timeout | null = null let reminderTimer: NodeJS.Timeout | null = null const deliveryTimers = new Map() +let readStatusRefreshInFlight: Promise | null = null type QueueNotificationInput = { surveyId?: string | null @@ -98,11 +103,13 @@ export function ensurePerformanceSurveyNotificationWorkerStarted() { const bootstrap = setTimeout(() => { void recoverDuePerformanceSurveyNotifications() void queueDuePerformanceSurveyReminders() + void refreshPerformanceSurveyNotificationReadStatuses() }, 1000) bootstrap.unref?.() recoveryTimer = setInterval(() => { void recoverDuePerformanceSurveyNotifications() + void refreshPerformanceSurveyNotificationReadStatuses() }, RECOVERY_INTERVAL_MS) recoveryTimer.unref?.() @@ -251,8 +258,8 @@ export async function queueSurveyTargetConfirmationNotifications(surveyId: strin businessKey: `target-confirmation:${survey.id}:${target.id}`, title: isVisualSurvey ? '请确认并提交视觉评分作品' : '请确认一项代发起调研', content: isVisualSurvey - ? `${survey.creatorName} 为你发起了一项视觉评分调研,请确认被考核人信息并提交 ${requiredWorkItemCount} 个案例作品。\n\n调研:${survey.title}\n被考核人:${target.targetName}${survey.dueAt ? `\n考核月份:${formatMonth(survey.dueAt)}` : ''}\n\n如信息有误,请联系发起人。` - : `${survey.creatorName} 为你发起了一项调研,请确认被考核人信息。\n\n调研:${survey.title}\n被考核人:${target.targetName}${survey.dueAt ? `\n考核月份:${formatMonth(survey.dueAt)}` : ''}\n\n如信息有误,请联系发起人。`, + ? `${survey.creatorName} 为你发起了一项视觉评分调研,请确认被考核人信息并提交 ${requiredWorkItemCount} 个案例作品。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。` + : `${survey.creatorName} 为你发起了一项调研,请确认被考核人信息。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。`, actionUrl: getSurveyUrl( isVisualSurvey ? `/performance-survey/survey/${survey.id}?targetId=${target.id}` @@ -419,6 +426,9 @@ export async function retryPerformanceSurveyNotification(notificationId: string) lastResponseBody: null, lastError: null, providerMessageId: null, + readStatus: 'unknown', + readAt: null, + readStatusCheckedAt: null, sentAt: null, }, }) @@ -495,6 +505,9 @@ export async function processPerformanceSurveyNotification(notificationId: strin status: STATUS.SUCCEEDED, attemptCount: { increment: 1 }, providerMessageId: result.taskId || result.requestId || null, + readStatus: 'unknown', + readAt: null, + readStatusCheckedAt: null, lastStatusCode: 200, lastResponseBody: truncateBody(JSON.stringify(result.raw || result)), lastError: null, @@ -530,6 +543,73 @@ export async function processPerformanceSurveyNotification(notificationId: strin } } +export function refreshPerformanceSurveyNotificationReadStatuses(limit = 20) { + if (readStatusRefreshInFlight) return readStatusRefreshInFlight + readStatusRefreshInFlight = refreshPerformanceSurveyNotificationReadStatusesInternal(limit) + .finally(() => { + readStatusRefreshInFlight = null + }) + return readStatusRefreshInFlight +} + +async function refreshPerformanceSurveyNotificationReadStatusesInternal(limit = 20) { + const staleBefore = new Date(Date.now() - READ_STATUS_REFRESH_MS) + const notifications = await prisma.performanceSurveyNotification.findMany({ + where: { + status: STATUS.SUCCEEDED, + providerMessageId: { not: null }, + readStatus: { in: ['unknown', 'unread'] }, + OR: [ + { readStatusCheckedAt: null }, + { readStatusCheckedAt: { lte: staleBefore } }, + ], + }, + select: { + id: true, + providerMessageId: true, + dingtalkUserId: true, + }, + orderBy: { sentAt: 'asc' }, + take: Math.max(1, Math.min(limit, 100)), + }) + + let refreshed = 0 + for (const notification of notifications) { + const taskId = clean(notification.providerMessageId) + const recipientDingtalkUserId = clean(notification.dingtalkUserId) + if (!taskId || !recipientDingtalkUserId) continue + + try { + const result = await getDingtalkWorkNoticeSendResult(taskId) + const read = result.readUserIds.includes(recipientDingtalkUserId) + const unread = result.unreadUserIds.includes(recipientDingtalkUserId) + const deliveryFailed = [ + ...result.failedUserIds, + ...result.invalidUserIds, + ...result.forbiddenUserIds, + ].includes(recipientDingtalkUserId) + const checkedAt = new Date() + + await prisma.performanceSurveyNotification.update({ + where: { id: notification.id }, + data: { + readStatus: read ? 'read' : unread ? 'unread' : deliveryFailed ? 'delivery_failed' : 'unknown', + readAt: read ? checkedAt : null, + readStatusCheckedAt: checkedAt, + }, + }) + refreshed += 1 + } catch { + await prisma.performanceSurveyNotification.update({ + where: { id: notification.id }, + data: { readStatusCheckedAt: new Date() }, + }) + } + } + + return refreshed +} + function formatDateTime(value: Date | string | null | undefined) { if (!value) return '' const date = new Date(value) @@ -537,8 +617,12 @@ function formatDateTime(value: Date | string | null | undefined) { return date.toLocaleString('zh-CN', { hour12: false }) } -function formatMonth(value: Date | string) { - const date = new Date(value) +function formatAssessmentMonth(value: string | null | undefined, fallback: Date | string) { + const normalized = String(value || '').trim() + const match = normalized.match(/^(\d{4})-(\d{2})$/) + if (match) return `${match[1]}年${Number(match[2])}月` + + const date = new Date(fallback) if (Number.isNaN(date.getTime())) return '-' return `${date.getFullYear()}年${date.getMonth() + 1}月` } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 974eb11..88f67be 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -19,6 +19,7 @@ import { queueSurveyResultNotifications, queueSurveyTargetConfirmationNotifications, recoverDuePerformanceSurveyNotifications, + refreshPerformanceSurveyNotificationReadStatuses, retryPerformanceSurveyNotification, } from '@/lib/performance-survey-notifications' import { @@ -155,6 +156,16 @@ function isMonthlyAssessmentTemplateCode(code?: string | null): code is MonthlyA return Boolean(code && MONTHLY_ASSESSMENT_TEMPLATE_SET.has(code)) } +function normalizeAssessmentMonth(value: unknown) { + const normalized = clean(value) + return normalized && /^\d{4}-(0[1-9]|1[0-2])$/.test(normalized) ? normalized : null +} + +function formatAssessmentMonthTitle(month: string, templateName: string) { + const [year, monthValue] = month.split('-') + return `${year}年${Number(monthValue)}月 · ${templateName}` +} + function encodeDepartmentPart(value: string) { return encodeURIComponent(value) } @@ -281,7 +292,7 @@ function toDate(value: unknown) { } function getMonthRange(value: unknown) { - const normalized = clean(value) + const normalized = normalizeAssessmentMonth(value) const match = normalized?.match(/^(\d{4})-(\d{2})$/) if (!match) return null @@ -291,7 +302,7 @@ function getMonthRange(value: unknown) { const start = new Date(year, month - 1, 1, 0, 0, 0, 0) const end = new Date(year, month, 1, 0, 0, 0, 0) - return { start, end } + return { start, end, value: normalized } } function getCurrentMonthRange() { @@ -301,6 +312,29 @@ function getCurrentMonthRange() { return { start, end, value: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` } } +export function resolvePerformanceSurveyPeriodInput( + input: { assessmentMonth?: unknown; dueAt?: unknown; openNow?: boolean }, + now = new Date(), +) { + const requestedAssessmentMonth = clean(input.assessmentMonth) + const assessmentMonth = normalizeAssessmentMonth(requestedAssessmentMonth) + if (requestedAssessmentMonth && !assessmentMonth) { + throw new PerformanceSurveyApiError('考核月份格式无效') + } + + const requestedDueAt = clean(input.dueAt) + const dueAt = toDate(input.dueAt) + if (requestedDueAt && !dueAt) throw new PerformanceSurveyApiError('截止时间格式无效') + if (input.openNow && dueAt && dueAt.getTime() <= now.getTime()) { + throw new PerformanceSurveyApiError('截止时间必须晚于当前时间') + } + + return { + assessmentMonth: assessmentMonth || getMonthRangeFromDate(now).value, + dueAt, + } +} + function getMonthRangeFromDate(value: Date) { const start = new Date(value.getFullYear(), value.getMonth(), 1, 0, 0, 0, 0) const end = new Date(value.getFullYear(), value.getMonth() + 1, 1, 0, 0, 0, 0) @@ -322,20 +356,36 @@ function getRecentMonthValues(count = 12) { } function getMonthSpanRange(months: string[]) { - const ranges = months.map((month) => getMonthRange(month)).filter((range): range is { start: Date; end: Date } => Boolean(range)) + const ranges = months.flatMap((month) => { + const range = getMonthRange(month) + return range ? [range] : [] + }) const start = new Date(Math.min(...ranges.map((range) => range.start.getTime()))) const end = new Date(Math.max(...ranges.map((range) => range.end.getTime()))) return { start, end } } function getSurveyMonthWhere(monthRange: { start: Date; end: Date }) { + const startMonth = getMonthValueFromDate(monthRange.start) + const inclusiveEnd = new Date(monthRange.end.getTime() - 1) + const endMonth = getMonthValueFromDate(inclusiveEnd) return { OR: [ - { dueAt: { gte: monthRange.start, lt: monthRange.end } }, + { assessmentMonth: { gte: startMonth, lte: endMonth } }, { AND: [ - { dueAt: null }, - { createdAt: { gte: monthRange.start, lt: monthRange.end } }, + { assessmentMonth: null }, + { + OR: [ + { dueAt: { gte: monthRange.start, lt: monthRange.end } }, + { + AND: [ + { dueAt: null }, + { createdAt: { gte: monthRange.start, lt: monthRange.end } }, + ], + }, + ], + }, ], }, ], @@ -520,17 +570,7 @@ async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchP } if (monthRange) { - and.push({ - OR: [ - { dueAt: { gte: monthRange.start, lt: monthRange.end } }, - { - AND: [ - { dueAt: null }, - { createdAt: { gte: monthRange.start, lt: monthRange.end } }, - ], - }, - ], - }) + and.push(getSurveyMonthWhere(monthRange)) } return and.length === 1 ? and[0] : { AND: and } @@ -1018,6 +1058,7 @@ export async function listPerformanceSurveys(session: Session | null, params?: U templateCode: survey.templateCode, status: survey.status, departmentName: survey.departmentName, + assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, @@ -1032,6 +1073,7 @@ export async function listPerformanceSurveys(session: Session | null, params?: U templateCode: survey.templateCode, status: survey.status, departmentName: survey.departmentName, + assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, @@ -1090,7 +1132,8 @@ function getMonthlyTemplates(templates: PerformanceSurveyTemplateShape[]) { } function getDashboardTargetMonth(target: any) { - return getMonthValueFromDate(target?.survey?.dueAt || target?.survey?.createdAt || target?.createdAt) + return normalizeAssessmentMonth(target?.survey?.assessmentMonth) + || getMonthValueFromDate(target?.survey?.dueAt || target?.survey?.createdAt || target?.createdAt) } function getTargetResponses(targets: any[]) { @@ -1105,6 +1148,12 @@ function getTargetWorkItems(targets: any[]) { return targets.flatMap((target) => target.workItems || []) } +function getTargetWorkItemCount(targets: any[]) { + return targets.reduce((sum, target) => ( + sum + Number(target?._count?.workItems ?? target?.workItems?.length ?? 0) + ), 0) +} + function summarizeMonthlyScoreCell( template: PerformanceSurveyTemplateShape, targets: any[], @@ -1113,6 +1162,13 @@ function summarizeMonthlyScoreCell( if (targets.length === 0) { return { templateCode: template.code, + title: null, + titles: [], + surveyId: null, + targetId: null, + status: null, + assessmentMonth: null, + dueAt: null, score: null, surveyCount: 0, responseCount: 0, @@ -1132,16 +1188,29 @@ function summarizeMonthlyScoreCell( respondentName: response.respondentName, })) const aggregate = calculateAggregateScore(responseItems, template.scoreBands, template.weight, template) + const primaryTarget = targets[0] + const titles = Array.from(new Set(targets.map((target) => clean(target.survey?.title)).filter(Boolean))) + const statuses = targets.map((target) => clean(target.survey?.status)).filter(Boolean) + const status = statuses.includes('open') ? 'open' : statuses.includes('draft') ? 'draft' : statuses[0] || null return { templateCode: template.code, + title: titles[0] || null, + titles, + surveyId: primaryTarget?.surveyId || primaryTarget?.survey?.id || null, + targetId: primaryTarget?.id || null, + status, + assessmentMonth: getDashboardTargetMonth(primaryTarget), + dueAt: primaryTarget?.survey?.dueAt || null, + openedAt: primaryTarget?.survey?.openedAt || null, + closedAt: primaryTarget?.survey?.closedAt || null, score: responses.length > 0 ? aggregate.kpiScore : null, surveyCount: targets.length, responseCount: responses.length, assignmentCount: assignments.length, pendingCount: assignments.filter((assignment: any) => assignment.status !== 'submitted').length, - workItemCount: getTargetWorkItems(targets).length, - detailAllowed: detailAllowed && responses.length > 0, + workItemCount: getTargetWorkItemCount(targets), + detailAllowed, } } @@ -1206,31 +1275,59 @@ async function getMonthlyTargets(employeeIds: string[], monthRange: { start: Dat ], }, } as any, - include: { - employeeProfile: true, - survey: true, - assignments: true, - responses: true, - workItems: { - orderBy: { sortOrder: 'asc' }, - include: { scores: true }, + select: { + id: true, + surveyId: true, + employeeProfileId: true, + createdAt: true, + survey: { + select: { + id: true, + title: true, + templateCode: true, + status: true, + assessmentMonth: true, + dueAt: true, + openedAt: true, + closedAt: true, + createdAt: true, + }, }, + assignments: { + select: { + id: true, + status: true, + submittedAt: true, + }, + }, + responses: { + select: { + id: true, + totalScore: true, + kpiScore: true, + createdAt: true, + scores: true, + respondentName: true, + }, + }, + _count: { select: { workItems: true } }, }, - orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }], + orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }], take: 5000, }) } async function assertNoMonthlyAssessmentDuplicate( templateCode: string, - assessmentDate: Date, + assessmentMonth: string, targetRows: Array<{ employeeProfileId?: string | null }>, ) { if (!isMonthlyAssessmentTemplateCode(templateCode)) return const employeeIds = Array.from(new Set(targetRows.map((target) => target.employeeProfileId).filter((id): id is string => Boolean(id)))) if (employeeIds.length === 0) return - const monthRange = getMonthRangeFromDate(assessmentDate) + const monthRange = getMonthRange(assessmentMonth) + if (!monthRange) throw new PerformanceSurveyApiError('考核月份格式无效') const existingTargets = await prisma.performanceSurveyTarget.findMany({ where: { targetType: 'employee', @@ -1256,7 +1353,7 @@ async function assertNoMonthlyAssessmentDuplicate( )))) throw new PerformanceSurveyApiError( - `${monthRange.value} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`, + `${assessmentMonth} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`, ) } @@ -1282,12 +1379,20 @@ export async function getPerformanceSurveyDashboard(session: Session | null, par ? await getMonthlyTargets(publicEmployees.map((employee) => employee.id), monthRange) : [] const publicDetailAllowed = Boolean(ctx.isSystemAdmin || ctx.isLeadership) + const publicTargetsByEmployee = new Map() + for (const target of publicTargets) { + const key = clean(target.employeeProfileId) + if (!key) continue + const current = publicTargetsByEmployee.get(key) || [] + current.push(target) + publicTargetsByEmployee.set(key, current) + } const publicRows = publicEmployees .map((employee) => buildMonthlyRow( employee, monthValue, templates, - publicTargets.filter((target) => target.employeeProfileId === employee.id), + publicTargetsByEmployee.get(employee.id) || [], publicDetailAllowed, )) .filter((row) => row.hasData) @@ -1398,13 +1503,50 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul }, }, }, - orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }], + orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }], take: 200, }) + const surveyIds = Array.from(new Set(targets.map((target) => target.surveyId))) + const notifications = surveyIds.length > 0 + ? await prisma.performanceSurveyNotification.findMany({ + where: { + surveyId: { in: surveyIds }, + assignmentId: { not: null }, + eventType: 'survey.assignment.created', + }, + select: { + id: true, + surveyId: true, + targetId: true, + assignmentId: true, + recipientName: true, + eventType: true, + status: true, + readStatus: true, + readAt: true, + readStatusCheckedAt: true, + sentAt: true, + completedAt: true, + lastError: true, + providerMessageId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }) + : [] + const notificationByAssignment = new Map() + for (const notification of notifications) { + if (notification.assignmentId && !notificationByAssignment.has(notification.assignmentId)) { + notificationByAssignment.set(notification.assignmentId, notification) + } + } const cell = summarizeMonthlyScoreCell(template, targets, true) const responses = getTargetResponses(targets) const participants = responses.map((response: any) => ({ id: response.id, + surveyId: response.surveyId, + surveyTitle: targets.find((target) => target.id === response.targetId)?.survey.title || '', + targetId: response.targetId, name: response.respondentEmployee ? getEmployeeDisplayName(response.respondentEmployee) : response.respondentName || '匿名评价', @@ -1427,6 +1569,9 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul return { id: item.id, + surveyId: item.surveyId, + surveyTitle: targets.find((target) => target.id === item.targetId)?.survey.title || '', + targetId: item.targetId, title: item.title, description: item.description, imageUrls: images.imageUrls, @@ -1435,6 +1580,57 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul scores, } }) + const surveys = targets.map((target) => { + const targetResponses = target.responses || [] + return { + id: target.survey.id, + targetId: target.id, + title: target.survey.title, + status: target.survey.status, + assessmentMonth: normalizeAssessmentMonth(target.survey.assessmentMonth) || getDashboardTargetMonth(target), + dueAt: target.survey.dueAt, + openedAt: target.survey.openedAt, + closedAt: target.survey.closedAt, + createdAt: target.survey.createdAt, + creatorName: target.survey.creatorName, + responseCount: targetResponses.length, + assignmentCount: target.assignments.length, + pendingCount: target.assignments.filter((assignment) => assignment.status !== 'submitted').length, + } + }) + const evaluators = targets.flatMap((target) => ( + target.assignments.map((assignment) => { + const notification = notificationByAssignment.get(assignment.id) + const response = target.responses.find((item) => item.assignmentId === assignment.id) + return { + id: assignment.id, + surveyId: target.survey.id, + surveyTitle: target.survey.title, + targetId: target.id, + name: assignment.evaluatorName, + assignmentStatus: assignment.status, + submittedAt: assignment.submittedAt, + score: response ? roundScore(Number(response.kpiScore ?? response.totalScore ?? 0)) : null, + notification: notification + ? { + id: notification.id, + status: notification.status, + sentAt: notification.sentAt, + completedAt: notification.completedAt, + lastError: notification.lastError, + providerMessageId: notification.providerMessageId, + readStatus: notification.readStatus, + readAt: notification.readAt, + readStatusCheckedAt: notification.readStatusCheckedAt, + } + : null, + } + }) + )) + const assignmentCount = evaluators.length + const submittedCount = evaluators.filter((item) => item.assignmentStatus === 'submitted').length + const notifiedCount = evaluators.filter((item) => item.notification?.status === 'succeeded').length + const readCount = evaluators.filter((item) => item.notification?.readStatus === 'read').length return { month: monthValue, @@ -1449,6 +1645,16 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul score: cell.score, surveyCount: cell.surveyCount, responseCount: cell.responseCount, + surveys, + evaluators, + workflow: { + assignmentCount, + notifiedCount, + readCount, + submittedCount, + pendingCount: Math.max(0, assignmentCount - submittedCount), + isClosed: surveys.length > 0 && surveys.every((survey) => survey.status === 'closed'), + }, participants, workItems, } @@ -1457,15 +1663,24 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul export async function createPerformanceSurvey(session: Session | null, body: any) { const ctx = await getPerformanceSurveyContext(session) - const title = clean(body?.title) - if (!title) throw new PerformanceSurveyApiError('调研标题不能为空') - const templateCode = clean(body?.templateCode) || 'cross_department' const template = await getPerformanceSurveyTemplate(templateCode) const requestedSurveyKind = clean(body?.surveyKind) || template.surveyType const surveyKind = template.code === 'course_share' ? 'person_period' : requestedSurveyKind - const dueAt = toDate(body?.dueAt) const openNow = Boolean(body?.openNow || body?.status === 'open') + const period = resolvePerformanceSurveyPeriodInput({ + assessmentMonth: body?.assessmentMonth, + dueAt: body?.dueAt, + openNow, + }) + const assessmentMonth = period.assessmentMonth + const dueAt = period.dueAt + const requestedTitle = clean(body?.title) + const title = isMonthlyAssessmentTemplateCode(template.code) + ? formatAssessmentMonthTitle(assessmentMonth, template.shortName || template.name) + : requestedTitle + if (!title) throw new PerformanceSurveyApiError('调研标题不能为空') + const requestedDepartmentName = clean(body?.departmentName) const departmentName = requestedDepartmentName || ctx.scopeDepartment @@ -1502,7 +1717,7 @@ export async function createPerformanceSurvey(session: Session | null, body: any sortOrder: index, })) - await assertNoMonthlyAssessmentDuplicate(template.code, dueAt || new Date(), targetRows) + await assertNoMonthlyAssessmentDuplicate(template.code, assessmentMonth, targetRows) const assignmentRows: any[] = [] for (const target of targetRows) { @@ -1548,6 +1763,7 @@ export async function createPerformanceSurvey(session: Session | null, body: any creatorName: getDisplayName(ctx.user), creatorEmployeeId: ctx.employee?.id || null, departmentName, + assessmentMonth, dueAt, openedAt: openNow ? new Date() : null, }, @@ -1629,6 +1845,7 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey status: survey.status, creatorName: survey.creatorName, departmentName: survey.departmentName, + assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, openedAt: survey.openedAt, closedAt: survey.closedAt, @@ -1724,7 +1941,20 @@ export async function updatePerformanceSurvey(session: Session | null, surveyId: if (body?.title != null) data.title = clean(body.title) || survey.title if (body?.description !== undefined) data.description = clean(body.description) - if (body?.dueAt !== undefined) data.dueAt = toDate(body.dueAt) + if (body?.assessmentMonth !== undefined) { + const assessmentMonth = normalizeAssessmentMonth(body.assessmentMonth) + if (!assessmentMonth) throw new PerformanceSurveyApiError('考核月份格式无效') + data.assessmentMonth = assessmentMonth + if (isMonthlyAssessmentTemplateCode(survey.templateCode)) { + const template = survey.templateSnapshot as PerformanceSurveyTemplateShape + data.title = formatAssessmentMonthTitle(assessmentMonth, template.shortName || template.name) + } + } + if (body?.dueAt !== undefined) { + const requestedDueAt = clean(body.dueAt) + data.dueAt = toDate(body.dueAt) + if (requestedDueAt && !data.dueAt) throw new PerformanceSurveyApiError('截止时间格式无效') + } if (nextStatus === 'open' && survey.status === 'draft') { data.status = 'open' @@ -1736,6 +1966,12 @@ export async function updatePerformanceSurvey(session: Session | null, surveyId: throw new PerformanceSurveyApiError('无效的调研状态') } + const effectiveDueAt = body?.dueAt !== undefined ? data.dueAt : survey.dueAt + const effectiveStatus = data.status || survey.status + if (effectiveStatus === 'open' && effectiveDueAt && effectiveDueAt.getTime() <= Date.now()) { + throw new PerformanceSurveyApiError('截止时间必须晚于当前时间') + } + await prisma.performanceSurvey.update({ where: { id: surveyId }, data, @@ -1900,6 +2136,7 @@ export async function getPerformanceSurveyResults( status: survey.status, templateCode: survey.templateCode, template, + assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt), dueAt: survey.dueAt, closedAt: survey.closedAt, counts: survey._count, @@ -2332,7 +2569,7 @@ export async function exportPerformanceSurveyCsv(session: Session | null) { }) const rows = [ - ['提交时间', '调研ID', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '建议'], + ['提交时间', '调研ID', '考核月份', '模板代码', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '建议'], ...responses.map((response) => { const template = response.survey.templateSnapshot as PerformanceSurveyTemplateShape const respondentSnapshot = response.respondentSnapshot as Record @@ -2348,6 +2585,8 @@ export async function exportPerformanceSurveyCsv(session: Session | null) { return [ response.createdAt.toISOString(), response.surveyId, + response.survey.assessmentMonth || getMonthValueFromDate(response.survey.dueAt || response.survey.createdAt), + response.survey.templateCode, template.name || response.survey.templateCode, response.survey.creatorName, response.survey.creatorUserId, @@ -2397,6 +2636,9 @@ export async function listPerformanceSurveyNotifications(session: Session | null maxAttempts: item.maxAttempts, nextRetryAt: item.nextRetryAt, lastError: item.lastError, + readStatus: item.readStatus, + readAt: item.readAt, + readStatusCheckedAt: item.readStatusCheckedAt, sentAt: item.sentAt, completedAt: item.completedAt, createdAt: item.createdAt, @@ -2444,7 +2686,8 @@ export async function runPerformanceSurveyNotificationMaintenance(session: Sessi ensureManager(ctx) const reminderIds = await queueDuePerformanceSurveyReminders() const recovered = await recoverDuePerformanceSurveyNotifications() - return { reminderCount: reminderIds.length, recovered } + const readStatusRefreshed = await refreshPerformanceSurveyNotificationReadStatuses() + return { reminderCount: reminderIds.length, recovered, readStatusRefreshed } } async function resolveSurveyTargets(body: any, surveyKind: string, currentEmployeeId?: string | null) { diff --git a/prisma/migrations/20260710_add_performance_survey_month_and_notice_read_status/migration.sql b/prisma/migrations/20260710_add_performance_survey_month_and_notice_read_status/migration.sql new file mode 100644 index 0000000..dd7f8e2 --- /dev/null +++ b/prisma/migrations/20260710_add_performance_survey_month_and_notice_read_status/migration.sql @@ -0,0 +1,40 @@ +ALTER TABLE "performance_surveys" + ADD COLUMN IF NOT EXISTS "assessment_month" VARCHAR(7); + +UPDATE "performance_surveys" +SET "assessment_month" = TO_CHAR( + COALESCE("due_at", "created_at"), + 'YYYY-MM' +) +WHERE "assessment_month" IS NULL; + +-- 早期月度调研把“考核月份”误写进了截止时间。仅清理月初零点且发布时已过期的记录。 +UPDATE "performance_surveys" +SET "due_at" = NULL +WHERE "template_code" IN ( + 'course_share', + 'cross_department', + 'work_cooperation', + 'visual_brand_tone' +) + AND "due_at" IS NOT NULL + AND EXTRACT(DAY FROM "due_at") = 1 + AND "due_at"::time = TIME '00:00:00' + AND COALESCE("opened_at", "created_at") > "due_at"; + +CREATE INDEX IF NOT EXISTS "performance_surveys_assessment_month_idx" + ON "performance_surveys" ("assessment_month"); + +CREATE INDEX IF NOT EXISTS "performance_surveys_template_month_idx" + ON "performance_surveys" ("template_code", "assessment_month"); + +CREATE INDEX IF NOT EXISTS "performance_survey_targets_employee_survey_idx" + ON "performance_survey_targets" ("employee_profile_id", "survey_id"); + +ALTER TABLE "performance_survey_notifications" + ADD COLUMN IF NOT EXISTS "read_status" TEXT NOT NULL DEFAULT 'unknown', + ADD COLUMN IF NOT EXISTS "read_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "read_status_checked_at" TIMESTAMP(3); + +CREATE INDEX IF NOT EXISTS "performance_survey_notifications_read_status_checked_at_idx" + ON "performance_survey_notifications" ("read_status", "read_status_checked_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48e834a..ed160d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -574,6 +574,7 @@ model PerformanceSurvey { creatorName String @map("creator_name") creatorEmployeeId String? @map("creator_employee_id") departmentName String? @map("department_name") + assessmentMonth String? @map("assessment_month") dueAt DateTime? @map("due_at") openedAt DateTime? @map("opened_at") closedAt DateTime? @map("closed_at") @@ -589,8 +590,10 @@ model PerformanceSurvey { @@index([status]) @@index([surveyKind]) @@index([templateCode]) + @@index([templateCode, assessmentMonth], map: "performance_surveys_template_month_idx") @@index([creatorUserId]) @@index([departmentName]) + @@index([assessmentMonth]) @@index([dueAt]) @@map("performance_surveys") } @@ -615,6 +618,7 @@ model PerformanceSurveyTarget { @@index([surveyId]) @@index([targetType]) @@index([employeeProfileId]) + @@index([employeeProfileId, surveyId], map: "performance_survey_targets_employee_survey_idx") @@map("performance_survey_targets") } @@ -757,6 +761,9 @@ model PerformanceSurveyNotification { lastResponseBody String? @map("last_response_body") lastError String? @map("last_error") providerMessageId String? @map("provider_message_id") + readStatus String @default("unknown") @map("read_status") + readAt DateTime? @map("read_at") + readStatusCheckedAt DateTime? @map("read_status_checked_at") sentAt DateTime? @map("sent_at") completedAt DateTime? @map("completed_at") createdAt DateTime @default(now()) @map("created_at") @@ -774,6 +781,7 @@ model PerformanceSurveyNotification { @@index([recipientUserId]) @@index([dingtalkUserId]) @@index([status, nextRetryAt]) + @@index([readStatus, readStatusCheckedAt], map: "performance_survey_notifications_read_status_checked_at_idx") @@index([createdAt]) @@map("performance_survey_notifications") }