feat: 规范月度调研命名与明细

This commit is contained in:
chenpei 2026-07-10 17:15:54 +08:00
parent 7af0c1b926
commit 993a92dd3f
9 changed files with 760 additions and 120 deletions

View File

@ -518,7 +518,7 @@ export default function PerformanceSurveyPage() {
const isSurveySubmitPage = path[0] === 'survey' && Boolean(path[1])
const isResultsPage = path[0] === 'results' && Boolean(path[1])
let content = <Dashboard />
let content = <Dashboard me={currentUser.data} />
if (isSurveySubmitPage && path[1]) {
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
}
@ -583,16 +583,15 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
)
}
function Dashboard() {
function Dashboard({ me }: { me: any | null }) {
const router = useRouter()
const searchParams = useSearchParams()
const requestedTabRaw = searchParams.get('tab') || 'personal'
const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw)
const searchParamsKey = searchParams.toString()
const [tab, setTab] = useState<PerformanceSurveyDashboardTab>(requestedTab)
const me = useApi<any>('/me', [])
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
const avatar = me.data?.user?.avatar || me.data?.user?.image || ''
const displayName = me?.employee?.displayName || me?.employee?.flowerName || me?.user?.name || '当前用户'
const avatar = me?.user?.avatar || me?.user?.image || ''
const availableTabIds = useMemo(
() => ['personal', 'public', 'manage'],
[],
@ -650,7 +649,7 @@ function Dashboard() {
<PerformanceDashboardBoard view={tab === 'public' ? 'public' : 'personal'} />
)}
{tab === 'manage' && (
<ManagePanel currentEmployee={me.data?.employee || null} />
<ManagePanel currentEmployee={me?.employee || null} />
)}
</main>
<nav className="fixed inset-x-0 bottom-0 z-30 grid h-[68px] grid-cols-3 border-t border-[#e2e2e2] bg-white sm:hidden">
@ -908,16 +907,21 @@ function MonthlyPersonalLedger({
{rows.map((row) => (
<article
key={row.month}
className="border border-[#c2c6d8] bg-white p-4"
className={`rounded-lg border bg-white p-4 ${
row.month === getCurrentMonthValue() ? 'border-[#0050cb]' : 'border-[#e2e2e2]'
}`}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">CURRENT</div>
{row.month === getCurrentMonthValue() && (
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">本月</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
embedded
template={template}
cell={row.cells?.[template.code]}
onClick={() => onOpenDetail({
@ -995,7 +999,7 @@ function MonthlyPublicLedger({
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-1">
<div className="grid grid-cols-2 gap-2">
{templates.map((template) => (
<MonthlyScoreCell
key={template.code}
@ -1067,28 +1071,39 @@ function MonthlyScoreCell({
template,
cell,
compact = false,
embedded = false,
canOpenOverride,
onClick,
}: {
template: any
cell: any
compact?: boolean
embedded?: boolean
canOpenOverride?: boolean
onClick: () => 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 = (
<>
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
{hasSurvey && <InlineSurveyState status={cell?.status} />}
</div>
<div className={`${compact ? 'line-clamp-2 text-[10px] leading-4' : 'truncate text-[12px]'} mt-1.5 font-semibold text-[#1a1c1c]`}>
{cell?.title || '未发起'}
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className={`${compact ? 'text-[17px]' : 'text-[24px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : '-'}
<span className={`${compact ? 'text-[16px]' : 'text-[22px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
</div>
@ -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 = (
<>
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
</>
<div className="min-w-0">
<div className="flex items-baseline justify-center gap-1">
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
</span>
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
</div>
{hasSurvey && <div className="mt-1 truncate text-[10px] font-medium text-[#5f5e5e]" title={cell?.title || ''}>{cell?.title}</div>}
</div>
)
if (!canOpen) {
return <div className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left">{content}</div>
return <div className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center">{content}</div>
}
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left transition-colors hover:text-[#003fa4]"
className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center transition-colors hover:text-[#003fa4]"
>
{content}
</button>
@ -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 (
<span className={`inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold ${meta.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</span>
)
}
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((
<div
@ -1220,16 +1258,21 @@ function MonthlyScoreDetailModal({
>
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#e2e2e2] bg-white px-4 pb-4 pt-[calc(16px+env(safe-area-inset-top))] sm:px-5 sm:pt-4">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-[#5f5e5e]">评估详情报告</div>
<div className="flex flex-wrap items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
<span>{formatMonthLabel(detail.data?.month || request.month)}</span>
<span>·</span>
<span>{detail.data?.template?.name || '考核明细'}</span>
{primarySurvey && <InlineSurveyState status={primarySurvey.status} />}
</div>
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
{detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'}
{primarySurvey?.title || detail.data?.template?.fullName || '考核明细'}
</h2>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c]"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] outline-none transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c] focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
aria-label="关闭详情"
>
×
@ -1241,20 +1284,21 @@ function MonthlyScoreDetailModal({
{detail.error && <ErrorPanel message={detail.error} />}
{detail.data && (
<div className="space-y-7">
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4">
<DetailMetaItem label="评估周期" value={detail.data.month || request.month} />
<div className="grid grid-cols-2 gap-5 border-b border-[#e2e2e2] pb-5 sm:grid-cols-4">
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
<DetailMetaItem label="评分人数" value={responseCount || '-'} />
<DetailMetaItem label="记录数" value={surveyCount || '-'} />
<DetailMetaItem label="发起人" value={primarySurvey?.creatorName || '-'} />
<DetailMetaItem label="截止时间" value={primarySurvey?.dueAt ? formatDate(primarySurvey.dueAt) : '未设置'} />
<DetailMetaItem label="当前得分" value={detail.data.score === null || detail.data.score === undefined ? '待评分' : `${formatNullableScore(detail.data.score)} / 120`} />
</div>
<section className="space-y-3">
<DetailSectionTitle>核心评分</DetailSectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DetailScoreCard label="最终分" value={formatNullableScore(detail.data.score)} suffix="/120" strong />
<DetailScoreCard label="评分人数" value={responseCount || 0} suffix="人" />
<DetailScoreCard label="月度记录" value={surveyCount || 0} suffix="条" />
<section className="space-y-4">
<DetailSectionTitle>调研进度</DetailSectionTitle>
<div className="grid grid-cols-3 border-y border-[#e2e2e2] py-4 text-center">
<WorkflowCount label="通知" value={workflow.notifiedCount || 0} total={workflow.assignmentCount || 0} />
<WorkflowCount label="已查看" value={workflow.readCount || 0} total={workflow.assignmentCount || 0} />
<WorkflowCount label="已评分" value={workflow.submittedCount || responseCount || 0} total={workflow.assignmentCount || 0} />
</div>
<EvaluatorWorkflowList items={detail.data.evaluators || []} />
</section>
{isVisualDetail && (
@ -1292,15 +1336,6 @@ function MonthlyScoreDetailModal({
</div>
)}
</div>
<div className="sticky bottom-0 border-t border-[#e2e2e2] bg-white px-4 pb-[calc(12px+env(safe-area-inset-bottom))] pt-3 sm:px-5 sm:pb-3">
<button
type="button"
onClick={onClose}
className="h-11 w-full rounded-lg border border-[#c2c6d8] text-[13px] font-semibold text-[#1a1c1c] transition-colors hover:bg-[#eeeeee]"
>
关闭详情
</button>
</div>
</div>
</div>
), 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 (
<div className="rounded-lg border border-[#e2e2e2] bg-[#f9f9f9] p-4">
<div className="mb-2 block text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
<div className="flex items-baseline gap-1">
<span className={`${strong ? 'text-[32px] leading-10' : 'text-[24px] leading-8'} font-bold text-[#1a1c1c]`}>{value}</span>
{suffix && <span className="text-[12px] font-medium text-[#5f5e5e]">{suffix}</span>}
<div className="border-r border-[#e2e2e2] last:border-r-0">
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
<div className="mt-1 text-[20px] font-bold text-[#1a1c1c]">
{value}<span className="ml-1 text-[12px] font-medium text-[#727687]">/ {total}</span>
</div>
</div>
)
}
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 (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11px] font-semibold text-[#5f5e5e]">
<span className={`h-1.5 w-1.5 rounded-full ${toneClass}`} />
{label}
</span>
)
}
function EvaluatorWorkflowList({ items }: { items: any[] }) {
if (!items.length) {
return <div className="border border-dashed border-[#c2c6d8] px-4 py-6 text-center text-sm font-semibold text-[#5f5e5e]">暂无评价人</div>
}
return (
<div className="divide-y divide-[#eeeeee] border-y border-[#eeeeee]">
{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 (
<div key={item.id} className="grid gap-3 py-3 sm:grid-cols-[minmax(120px,1fr)_repeat(3,auto)] sm:items-center sm:gap-6">
<div className="min-w-0">
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{item.name}</div>
{item.submittedAt && <div className="mt-1 text-[11px] text-[#727687]">{formatDate(item.submittedAt)}</div>}
</div>
<div className="grid grid-cols-3 gap-3 sm:contents">
<WorkflowState {...notice} />
<WorkflowState {...read} />
<WorkflowState {...submitted} />
</div>
</div>
)
})}
</div>
)
}
function ScoreParticipantList({ items }: { items: any[] }) {
if (!items.length) {
return <div className="rounded-lg border border-[#eeeeee] bg-[#f9f9f9] px-4 py-4 text-sm font-semibold text-[#5f5e5e]">暂无评分记录</div>
@ -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<Employee[]>([])
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
@ -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<any>('/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({
<div className="space-y-6">
<div className="border border-[#eeeeee] bg-white p-6">
<div className="space-y-6">
<TextInput label="考核月份" value={dueAt} onChange={setDueAt} type="month" />
<TextInput label="考核月份" value={assessmentMonth} onChange={setAssessmentMonth} type="month" />
<TextInput label="截止时间(可选)" value={deadlineAt} onChange={setDeadlineAt} type="datetime-local" />
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
<select

View File

@ -120,4 +120,39 @@ describe('dingtalk client', () => {
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',
})
})
})

View File

@ -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),
}),
})
})
})

View File

@ -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('考核月份格式无效'))
})
})

View File

@ -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<DingtalkWorkNoticeSendResult> {
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,
}
}

View File

@ -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<string, NodeJS.Timeout>()
let readStatusRefreshInFlight: Promise<number> | 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}月`
}

View File

@ -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<string, any[]>()
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<string, (typeof notifications)[number]>()
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<string, unknown>
@ -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) {

View File

@ -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");

View File

@ -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")
}