fix: 优化视觉评分提交流程

This commit is contained in:
chenpei 2026-07-17 13:33:00 +08:00
parent 50c256c1bf
commit 94fb4e1898
4 changed files with 672 additions and 74 deletions

View File

@ -25,6 +25,7 @@ import {
retryPerformanceSurveyNotificationById,
runPerformanceSurveyNotificationMaintenance,
searchPerformanceSurveyEmployees,
submitPerformanceSurveyWorkItemScores,
submitPerformanceSurveyWorkItemScore,
submitPerformanceSurveyResponse,
syncPerformanceSurveyEmployeeProfiles,
@ -189,6 +190,10 @@ export async function POST(request: Request, context: RouteContext) {
return ok(await submitPerformanceSurveyResponse(session, path[1], body))
}
if (path[0] === 'surveys' && path[1] && path[2] === 'work-item-scores' && path.length === 3) {
return ok(await submitPerformanceSurveyWorkItemScores(session, path[1], body))
}
if (path[0] === 'surveys' && path[1] && path[2] === 'work-items' && path[3] && path[4] === 'scores') {
return ok(await submitPerformanceSurveyWorkItemScore(session, path[1], path[3], body))
}

View File

@ -4385,14 +4385,33 @@ function VisualCaseSurvey({
const requiredCount = Number(template?.requiredWorkItemCount || 3)
const workItems = activeTarget?.workItems || []
const canUpload = Boolean(activeTarget?.canUploadWorkItems && ['draft', 'open'].includes(detail.data?.status))
const canCreateWorkItem = activeTarget?.canCreateWorkItem === true
|| (activeTarget?.canCreateWorkItem == null && canUpload && workItems.length < requiredCount)
const canScore = Boolean(activeTarget?.currentAssignment && detail.data?.status === 'open')
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
const completedScores = workItems.filter((item: any) => item.scores?.length > 0).length
const [publishing, setPublishing] = useState(false)
const [publishError, setPublishError] = useState<string | null>(null)
const [scoreDrafts, setScoreDrafts] = useState<Record<string, { scores: Record<string, number>; comment: string }>>({})
const [submittingAllScores, setSubmittingAllScores] = useState(false)
const [scoreSubmitMessage, setScoreSubmitMessage] = useState<string | null>(null)
const draftReadyToPublish = detail.data?.status === 'draft'
&& Boolean(detail.data?.canManage)
&& workItems.length >= requiredCount
const questions = template?.questions || []
const unscoredWorkItems = workItems.filter((item: any) => !item.scores?.[0])
const allUnscoredWorkItemsRated = unscoredWorkItems.length > 0
&& unscoredWorkItems.every((item: any) => questions.every((question) => (
Number.isFinite(scoreDrafts[item.id]?.scores?.[question.id])
)))
const canSubmitAllScores = canScore
&& workItems.length === requiredCount
&& unscoredWorkItems.length > 0
const updateScoreDraft = (workItemId: string, next: { scores: Record<string, number>; comment: string }) => {
setScoreDrafts((current) => ({ ...current, [workItemId]: next }))
setScoreSubmitMessage(null)
}
const publishDraft = async () => {
if (!draftReadyToPublish || publishing) return
@ -4411,6 +4430,36 @@ function VisualCaseSurvey({
}
}
const submitAllScores = async () => {
if (!canSubmitAllScores || submittingAllScores) return
if (!allUnscoredWorkItemsRated) {
setScoreSubmitMessage('请完成全部案例的每一项评分后再统一提交')
return
}
setSubmittingAllScores(true)
setScoreSubmitMessage(null)
try {
await apiRequest(`/surveys/${surveyId}/work-item-scores`, {
method: 'POST',
body: JSON.stringify({
items: unscoredWorkItems.map((item: any) => ({
workItemId: item.id,
scores: scoreDrafts[item.id].scores,
comment: scoreDrafts[item.id].comment,
})),
finalComment: '',
}),
})
await detail.refresh()
setScoreSubmitMessage('全部案例评分已提交')
} catch (error) {
setScoreSubmitMessage(`提交失败:${error instanceof Error ? error.message : '请重试'}`)
} finally {
setSubmittingAllScores(false)
}
}
return (
<main className="min-h-[100dvh] bg-white text-[#111827]">
<MobileSurveyRangeStyles />
@ -4458,12 +4507,11 @@ function VisualCaseSurvey({
</label>
)}
{canUpload && (
{canCreateWorkItem && (
<WorkItemUploadForm
key={activeTargetId}
surveyId={surveyId}
targetId={activeTargetId}
disabled={workItems.length >= requiredCount}
onUploaded={detail.refresh}
/>
)}
@ -4501,17 +4549,51 @@ function VisualCaseSurvey({
<div className="space-y-3">
{workItems.map((item: any, index: number) => (
<VisualWorkItemCard
<VisualWorkItemCard
key={`${activeTargetId}-${item.id}`}
surveyId={surveyId}
item={item}
index={index}
template={template}
canScore={canScore && !submitted}
onScored={detail.refresh}
/>
))}
</div>
index={index}
template={template}
canScore={canScore && !submitted}
scoreDraft={scoreDrafts[item.id] || { scores: {}, comment: '' }}
onScoreDraftChange={(next) => updateScoreDraft(item.id, next)}
/>
))}
</div>
{canSubmitAllScores && (
<section className="mt-4 rounded-xl border border-[#d9e7ff] bg-[#f8fbff] px-4 py-4">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-semibold text-[#101828]">统一提交全部案例评分</span>
<span className="shrink-0 font-semibold text-[#1677ff]">
{allUnscoredWorkItemsRated ? '评分已完整' : '请完成全部评分'}
</span>
</div>
<p className="mt-1.5 text-[12px] leading-5 text-[#667085]">
将一次性提交 {unscoredWorkItems.length} 个尚未评分的案例。
</p>
{scoreSubmitMessage && (
<div
className={`mt-2 text-sm font-medium ${
scoreSubmitMessage.includes('失败') || scoreSubmitMessage.includes('请完成')
? 'text-[#d92d20]'
: 'text-[#1677ff]'
}`}
aria-live="polite"
>
{scoreSubmitMessage}
</div>
)}
<button
type="button"
disabled={submittingAllScores || !allUnscoredWorkItemsRated}
onClick={submitAllScores}
className="mt-3 w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
>
{submittingAllScores ? '统一提交中...' : `提交全部案例评分(${unscoredWorkItems.length})`}
</button>
</section>
)}
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-[#e5edf6] bg-white/95 px-5 pb-[calc(14px+env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:sticky sm:-mx-5 sm:mt-3">
<div className="mx-auto flex max-w-[390px] items-center justify-between gap-3">
@ -4549,12 +4631,10 @@ function VisualCaseSurvey({
function WorkItemUploadForm({
surveyId,
targetId,
disabled,
onUploaded,
}: {
surveyId: string
targetId: string
disabled: boolean
onUploaded: () => Promise<void>
}) {
const [title, setTitle] = useState('')
@ -4581,7 +4661,6 @@ function WorkItemUploadForm({
}
const submit = async () => {
if (disabled) return
const validationError = validatePerformanceSurveyImageFiles(files)
if (validationError) {
setMessageTone('error')
@ -4628,20 +4707,18 @@ function WorkItemUploadForm({
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="案例名称"
disabled={disabled}
className="rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
/>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="案例说明(可选)"
disabled={disabled}
rows={3}
className="resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10 disabled:bg-[#f6f8fb]"
/>
<PerformanceSurveyImageDropzone
files={files}
disabled={disabled}
disabled={false}
ariaLabel="添加案例图片"
onFiles={addFiles}
/>
@ -4665,11 +4742,11 @@ function WorkItemUploadForm({
)}
<button
type="button"
disabled={busy || disabled || files.length === 0}
disabled={busy || files.length === 0}
onClick={submit}
className="rounded-[12px] bg-[#1677ff] px-4 py-2.5 text-sm font-semibold text-white disabled:bg-[#9ec9ff]"
>
{disabled ? '案例已满' : busy ? '上传中...' : '保存案例'}
{busy ? '上传中...' : '保存案例'}
</button>
</div>
</section>
@ -4677,50 +4754,28 @@ function WorkItemUploadForm({
}
function VisualWorkItemCard({
surveyId,
item,
index,
template,
canScore,
onScored,
scoreDraft,
onScoreDraftChange,
}: {
surveyId: string
item: any
index: number
template: Template | undefined
canScore: boolean
onScored: () => Promise<void>
scoreDraft: { scores: Record<string, number>; comment: string }
onScoreDraftChange: (next: { scores: Record<string, number>; comment: string }) => void
}) {
const existingScore = item.scores?.[0]
const questions = template?.questions || []
const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const scores = scoreDraft.scores
const comment = scoreDraft.comment
const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length
const allRated = questions.length > 0 && ratedCount === questions.length
const total = questions.reduce((sum, question) => sum + Number(scores[question.id] || 0), 0)
const submit = async () => {
if (!allRated) {
setMessage('请逐项完成评分后再提交')
return
}
setBusy(true)
setMessage(null)
try {
await apiRequest(`/surveys/${surveyId}/work-items/${item.id}/scores`, {
method: 'POST',
body: JSON.stringify({ scores, comment }),
})
await onScored()
} catch (error) {
setMessage(error instanceof Error ? error.message : '评分失败')
} finally {
setBusy(false)
}
}
return (
<section className="rounded-xl border border-[#dfe6f0] bg-white px-4 py-4 shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<div className="flex items-start justify-between gap-3">
@ -4766,37 +4821,41 @@ function VisualWorkItemCard({
data-rated={isRated ? 'true' : 'false'}
aria-label={`${question.title}评分,满分 ${maxScore} 分`}
onPointerDown={() => {
if (!isRated) setScores((current) => ({ ...current, [question.id]: 0 }))
if (!isRated) {
onScoreDraftChange({ scores: { ...scores, [question.id]: 0 }, comment })
}
}}
onKeyDown={(event) => {
if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) {
setScores((current) => ({ ...current, [question.id]: 0 }))
onScoreDraftChange({ scores: { ...scores, [question.id]: 0 }, comment })
}
}}
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
onChange={(event) => onScoreDraftChange({
scores: { ...scores, [question.id]: Number(event.target.value) },
comment,
})}
className="survey-score-range mt-2 w-full"
style={{ '--score-percent': `${percent}%`, '--score-color': accentColor } as CSSProperties}
/>
</div>
)
})}
<textarea
value={comment}
onChange={(event) => setComment(event.target.value.slice(0, 300))}
<textarea
value={comment}
onChange={(event) => onScoreDraftChange({
scores,
comment: event.target.value.slice(0, 300),
})}
rows={3}
placeholder="案例评语(可选)"
className="w-full resize-none rounded-[10px] border border-[#d9e0ea] px-3 py-2 text-sm outline-none focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
/>
{message && <div className="text-sm font-medium text-[#d92d20]">{message}</div>}
<button
type="button"
disabled={busy || !allRated}
onClick={submit}
className="w-full rounded-[14px] bg-[#1677ff] px-4 py-3 text-[15px] font-semibold text-white disabled:bg-[#9ec9ff]"
>
{busy ? '提交中...' : '提交本案例评分'}
</button>
</div>
<div className={`rounded-[10px] px-3 py-2 text-[12px] font-medium ${
allRated ? 'bg-[#edf8f1] text-[#087a3c]' : 'bg-[#f6f8fb] text-[#667085]'
}`}>
{allRated ? '本案例评分已完成,等待统一提交' : `还需完成 ${questions.length - ratedCount} 项评分`}
</div>
</div>
) : null}
</section>
)
@ -4806,6 +4865,27 @@ function ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
const groups = useMemo(() => normalizePerformanceSurveyImageGroups(images), [images])
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set())
const [activeGroupIndex, setActiveGroupIndex] = useState<number | null>(null)
const expandScrollPositions = useRef<Map<string, number>>(new Map())
const expandGroup = (groupKey: string) => {
expandScrollPositions.current.set(groupKey, window.scrollY)
setExpandedKeys((current) => new Set(current).add(groupKey))
}
const collapseGroup = (groupKey: string) => {
const scrollTop = expandScrollPositions.current.get(groupKey)
setExpandedKeys((current) => {
const next = new Set(current)
next.delete(groupKey)
return next
})
if (scrollTop == null) return
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
window.scrollTo({ top: scrollTop, behavior: 'auto' })
})
})
}
if (groups.length === 0) return null
@ -4815,7 +4895,10 @@ function ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
{groups.map((group, groupIndex) => {
const expanded = expandedKeys.has(group.key)
return (
<section key={group.key} className="overflow-hidden rounded-[14px] border border-[#e5edf6] bg-white">
<section
key={group.key}
className={`${expanded ? 'overflow-visible' : 'overflow-hidden'} rounded-[14px] border border-[#e5edf6] bg-white`}
>
{groups.length > 1 && (
<div className="flex items-center justify-between gap-3 border-b border-[#edf1f6] px-3 py-2.5">
<span className="min-w-0 truncate text-[12px] font-semibold text-[#667085]">{group.sourceName}</span>
@ -4842,16 +4925,27 @@ function ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
/>
</button>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white via-white/90 to-transparent" />
<button
type="button"
onClick={() => setExpandedKeys((current) => new Set(current).add(group.key))}
className="absolute bottom-4 left-1/2 h-10 -translate-x-1/2 rounded-full bg-[#0050cb] px-5 text-[13px] font-bold text-white shadow-[0_10px_24px_rgba(0,80,203,0.24)] transition-transform hover:scale-[1.02]"
<button
type="button"
onClick={() => expandGroup(group.key)}
aria-expanded={false}
className="absolute bottom-4 left-1/2 h-10 -translate-x-1/2 rounded-full bg-[#0050cb] px-5 text-[13px] font-bold text-white shadow-[0_10px_24px_rgba(0,80,203,0.24)] transition-transform hover:scale-[1.02]"
>
加载全部
</button>
</div>
) : (
<div className="bg-[#f6f8fb]">
<div className="sticky top-2 z-10 flex justify-center border-b border-[#e5edf6] bg-white/95 px-3 py-2.5 backdrop-blur">
<button
type="button"
onClick={() => collapseGroup(group.key)}
aria-label={`收起${group.sourceName}全部图片`}
className="h-9 rounded-full border border-[#d9e0ea] bg-white px-5 text-[13px] font-bold text-[#344054] shadow-sm"
>
收起全部
</button>
</div>
{group.slices.map((slice, sliceIndex) => (
<button
key={`${slice.url}-${sliceIndex}`}
@ -4874,11 +4968,7 @@ function ProgressiveLongImageGallery({ images }: { images: unknown[] }) {
<div className="sticky bottom-3 flex justify-center py-3">
<button
type="button"
onClick={() => setExpandedKeys((current) => {
const next = new Set(current)
next.delete(group.key)
return next
})}
onClick={() => collapseGroup(group.key)}
className="h-9 rounded-full border border-[#d9e0ea] bg-white/95 px-5 text-[13px] font-bold text-[#344054] shadow-sm backdrop-blur"
>
收起

View File

@ -0,0 +1,245 @@
/** @jest-environment node */
import type { Session } from 'next-auth'
import {
PerformanceSurveyApiError,
submitPerformanceSurveyWorkItemScores,
} from '@/lib/performance-survey-service'
import { prisma } from '@/lib/prisma'
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn() },
performanceSurvey: { findUnique: jest.fn() },
performanceSurveyAssignment: {
findFirst: jest.fn(),
count: jest.fn(),
},
performanceSurveyWorkItem: { findMany: jest.fn() },
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/performance-survey-report-sync', () => ({
ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(),
ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(),
getPerformanceSurveyAccountProfileWhere: jest.fn(),
syncPerformanceSurveyEmployees: jest.fn(),
}))
jest.mock('@/lib/performance-survey-notifications', () => ({
ensurePerformanceSurveyNotificationWorkerStarted: jest.fn(),
queueDuePerformanceSurveyReminders: jest.fn(),
queueSurveyAssignmentNotifications: jest.fn(),
queueSurveyCreatorNotification: jest.fn(),
queueSurveyResultNotifications: jest.fn(),
queueSurveyTargetConfirmationNotifications: jest.fn(),
recoverDuePerformanceSurveyNotifications: jest.fn(),
refreshPerformanceSurveyNotificationReadStatuses: jest.fn(),
retryPerformanceSurveyNotification: jest.fn(),
}))
jest.mock('@/lib/dingtalk-client', () => ({
getDingtalkPerformanceSurveyNotificationChannel: jest.fn(() => 'work_notice'),
getDingtalkRobotOtoConfigStatus: jest.fn(() => ({ configured: true })),
getDingtalkRobotOtoTestUserIds: jest.fn(() => []),
getDingtalkWorkNoticeConfigStatus: jest.fn(() => ({ configured: true })),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
uploadFileToS3: jest.fn(),
uploadToS3: jest.fn(),
}))
const session = { user: { id: 'user-1' } } as Session
const user = {
id: 'user-1',
username: '评分人',
email: 'evaluator@example.com',
role: 'USER',
department: '品牌创意A部',
managedDepartment: null,
isSuperAdmin: false,
dingtalkUserId: 'ding-evaluator-1',
realName: '评分人',
avatar: null,
}
const employee = {
id: 'employee-evaluator-1',
displayName: '评分人',
departmentName: '品牌创意A部',
departmentPath: '集团/品牌中心/品牌创意A部',
levelType: 'P',
positionLevel: 'P4',
isLeadership: false,
}
const workItems = [
{ id: 'work-item-1', targetId: 'target-1' },
{ id: 'work-item-2', targetId: 'target-1' },
{ id: 'work-item-3', targetId: 'target-1' },
]
const survey = {
id: 'survey-1',
status: 'open',
templateCode: 'visual_brand_tone',
templateSnapshot: {
code: 'visual_brand_tone',
surveyType: 'visual_case',
requiredWorkItemCount: 3,
questionMaxScore: 10,
questions: [{ id: 'visual_quality', title: '视觉质量', maxScore: 10 }],
scoreBands: [],
weight: 1,
},
}
const assignment = {
id: 'assignment-1',
surveyId: 'survey-1',
targetId: 'target-1',
evaluatorKey: 'ding:ding-evaluator-1',
status: 'pending',
response: null,
evaluatorSnapshot: { performanceSurveyWorkflowRole: 'self' },
}
function makeBatch(workItemIds: string[]) {
return {
finalComment: '整体完成度良好',
items: workItemIds.map((workItemId, index) => ({
workItemId,
scores: { visual_quality: 8 + index },
comment: `案例 ${index + 1}`,
})),
}
}
function setupTransaction(existingScores: Array<{ workItemId: string; totalScore: number; scores: unknown }> = []) {
const tx = {
$queryRawUnsafe: jest.fn().mockResolvedValue([{ status: 'open', dueAt: null }]),
performanceSurveyAssignment: {
findFirst: jest.fn().mockResolvedValue(assignment),
update: jest.fn().mockResolvedValue(assignment),
},
performanceSurveyWorkItem: {
findMany: jest.fn().mockResolvedValue(workItems),
},
performanceSurveyWorkItemScore: {
findMany: jest.fn().mockResolvedValue(existingScores),
create: jest.fn(async ({ data }) => data),
},
performanceSurveyResponse: {
create: jest.fn(async ({ data }) => data),
},
}
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
return tx
}
describe('performance survey visual work item score batch', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(user)
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(employee)
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(survey)
;(prisma.performanceSurveyAssignment.findFirst as jest.Mock).mockResolvedValue(assignment)
;(prisma.performanceSurveyAssignment.count as jest.Mock).mockResolvedValue(1)
;(prisma.performanceSurveyWorkItem.findMany as jest.Mock).mockImplementation(async ({ where }) => {
const ids = where?.id?.in as string[] | undefined
return ids ? workItems.filter((item) => ids.includes(item.id)) : workItems
})
})
test('一次写入三个案例评分、汇总响应并完成任务', async () => {
const tx = setupTransaction()
const result = await submitPerformanceSurveyWorkItemScores(session, 'survey-1', makeBatch(workItems.map((item) => item.id)))
expect(result).toEqual(expect.objectContaining({ completed: true, workItemScoreCount: 3 }))
expect(tx.performanceSurveyWorkItemScore.create).toHaveBeenCalledTimes(3)
expect(tx.performanceSurveyResponse.create).toHaveBeenCalledTimes(1)
expect(tx.performanceSurveyResponse.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
assignmentId: 'assignment-1',
totalScore: 27,
scores: expect.objectContaining({
visual_quality: 27,
workItemScores: {
'work-item-1': 8,
'work-item-2': 9,
'work-item-3': 10,
},
}),
}),
}))
expect(tx.performanceSurveyAssignment.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'assignment-1' },
data: expect.objectContaining({ status: 'submitted' }),
}))
})
test('全新评分缺少一个案例时事务内拒绝且不会写入任何案例评分', async () => {
const tx = setupTransaction()
await expect(
submitPerformanceSurveyWorkItemScores(session, 'survey-1', makeBatch(['work-item-1', 'work-item-2'])),
).rejects.toEqual(expect.objectContaining<Partial<PerformanceSurveyApiError>>({ status: 409 }))
expect(tx.performanceSurveyWorkItemScore.create).not.toHaveBeenCalled()
expect(tx.performanceSurveyResponse.create).not.toHaveBeenCalled()
expect(tx.performanceSurveyAssignment.update).not.toHaveBeenCalled()
})
test('重复案例在进入事务前拒绝', async () => {
await expect(
submitPerformanceSurveyWorkItemScores(session, 'survey-1', makeBatch(['work-item-1', 'work-item-1', 'work-item-3'])),
).rejects.toEqual(expect.objectContaining<Partial<PerformanceSurveyApiError>>({
message: '同一案例不能重复评分',
}))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('不同被考核人的案例组合会被拒绝且不会写入', async () => {
;(prisma.performanceSurveyWorkItem.findMany as jest.Mock).mockResolvedValue([
workItems[0],
workItems[1],
{ id: 'work-item-3', targetId: 'target-2' },
])
await expect(
submitPerformanceSurveyWorkItemScores(session, 'survey-1', makeBatch(workItems.map((item) => item.id))),
).rejects.toEqual(expect.objectContaining<Partial<PerformanceSurveyApiError>>({
message: '三个案例必须属于同一位被考核人',
}))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('旧版已保存一项评分时,只能一次续提剩余两项并合并为最终结果', async () => {
const tx = setupTransaction([
{ workItemId: 'work-item-1', totalScore: 7, scores: { visual_quality: 7 } },
])
const result = await submitPerformanceSurveyWorkItemScores(
session,
'survey-1',
makeBatch(['work-item-2', 'work-item-3']),
)
expect(result).toEqual(expect.objectContaining({ completed: true, workItemScoreCount: 2 }))
expect(tx.performanceSurveyWorkItemScore.create).toHaveBeenCalledTimes(2)
expect(tx.performanceSurveyResponse.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
totalScore: 24,
scores: expect.objectContaining({ visual_quality: 24 }),
}),
}))
})
})

View File

@ -2358,6 +2358,23 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
targets: visibleTargets.map((target) => {
const currentAssignment = currentAssignments.get(target.id)
const response = currentAssignment?.response
const isVisualTemplate = isVisualCaseTemplate(survey.templateCode)
const requiredWorkItemCount = isVisualTemplate
? Math.max(1, Number((survey.templateSnapshot as PerformanceSurveyTemplateShape).requiredWorkItemCount || 3))
: 0
const workItemUploadRemaining = isVisualTemplate
? Math.max(0, requiredWorkItemCount - target.workItems.length)
: 0
// 兼容旧调用方:该字段仅表示当前人是否为视觉评分对象;新调用方应使用下方容量感知字段决定是否展示上传操作。
const canUploadWorkItems = Boolean(
isVisualTemplate && ctx.employee?.id && target.employeeProfileId === ctx.employee.id,
)
const canCreateWorkItem = Boolean(
isVisualTemplate
&& workItemUploadRemaining > 0
&& (survey.status === 'open' || (survey.status === 'draft' && canManage))
&& (canManage || canUploadWorkItems),
)
return {
id: target.id,
targetType: target.targetType,
@ -2365,7 +2382,10 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
targetSnapshot: target.targetSnapshot,
responseCount: target.responses.length,
assignmentCount: target.assignments.length,
canUploadWorkItems: Boolean(isVisualCaseTemplate(survey.templateCode) && ctx.employee?.id && target.employeeProfileId === ctx.employee.id),
canUploadWorkItems,
canCreateWorkItem,
requiredWorkItemCount,
workItemUploadRemaining,
workItems: target.workItems.map((item) => {
const images = serializePerformanceSurveyImagePayload(item.imageUrls)
return {
@ -3285,6 +3305,244 @@ async function createPerformanceSurveyWorkItemFromSources(
}
}
type PerformanceSurveyWorkItemScoreBatchItem = {
workItemId: string
scores: Record<string, unknown>
comment: string | null
scoreResult: ReturnType<typeof calculateResponseScores>
}
function readPerformanceSurveyWorkItemScoreBatch(
template: PerformanceSurveyTemplateShape,
body: any,
) {
const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3))
const rawItems = body?.items
if (!Array.isArray(rawItems) || rawItems.length === 0 || rawItems.length > requiredWorkItemCount) {
throw new PerformanceSurveyApiError(`请提交 1-${requiredWorkItemCount} 个案例的评分`)
}
const seenWorkItemIds = new Set<string>()
return rawItems.map((rawItem, index): PerformanceSurveyWorkItemScoreBatchItem => {
const workItemId = clean(rawItem?.workItemId)
if (!workItemId) {
throw new PerformanceSurveyApiError(`第 ${index + 1} 个案例缺少案例标识`)
}
if (seenWorkItemIds.has(workItemId)) {
throw new PerformanceSurveyApiError('同一案例不能重复评分')
}
seenWorkItemIds.add(workItemId)
const submittedScores = calculateSubmittedPerformanceSurveyScores(template, rawItem?.scores)
return {
workItemId,
scores: submittedScores.scores,
comment: clean(rawItem?.comment),
scoreResult: submittedScores.result,
}
})
}
export async function submitPerformanceSurveyWorkItemScores(
session: Session | null,
surveyId: string,
body: any,
) {
const ctx = await getPerformanceSurveyContext(session)
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
})
if (!survey) throw new PerformanceSurveyApiError('调研不存在', 404)
if (survey.status !== 'open') throw new PerformanceSurveyApiError('调研未开放或已关闭')
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
if (!isVisualCaseTemplate(template)) throw new PerformanceSurveyApiError('当前模板不支持案例评分')
const submissions = readPerformanceSurveyWorkItemScoreBatch(template, body)
const requiredWorkItemCount = Math.max(1, Number(template.requiredWorkItemCount || 3))
const submittedWorkItemIds = submissions.map((item) => item.workItemId)
const submittedWorkItems = await prisma.performanceSurveyWorkItem.findMany({
where: {
surveyId,
id: { in: submittedWorkItemIds },
},
select: { id: true, targetId: true },
})
if (submittedWorkItems.length !== submissions.length) {
throw new PerformanceSurveyApiError('案例不存在或不属于当前调研', 404)
}
const targetIds = Array.from(new Set(submittedWorkItems.map((item) => item.targetId)))
if (targetIds.length !== 1) {
throw new PerformanceSurveyApiError('三个案例必须属于同一位被考核人')
}
const targetId = targetIds[0]
const assignment = await prisma.performanceSurveyAssignment.findFirst({
where: {
surveyId,
targetId,
evaluatorKey: ctx.evaluatorKey,
},
include: { response: true },
})
if (!assignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403)
if (assignment.response || assignment.status === 'submitted') {
throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交')
}
await assertVisualAssignmentStageOpen(survey, assignment)
const result = await prisma.$transaction(async (tx) => {
assertLockedSurveyOpen(await lockPerformanceSurvey(tx, surveyId))
const currentAssignment = await tx.performanceSurveyAssignment.findFirst({
where: {
surveyId,
targetId,
evaluatorKey: ctx.evaluatorKey,
},
include: { response: true },
})
if (!currentAssignment) throw new PerformanceSurveyApiError('你没有该案例的评分权限', 403)
if (currentAssignment.response || currentAssignment.status === 'submitted') {
throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交')
}
const currentWorkItems = await tx.performanceSurveyWorkItem.findMany({
where: { surveyId, targetId },
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
const currentWorkItemIds = new Set(currentWorkItems.map((item: { id: string }) => item.id))
if (
currentWorkItems.length !== requiredWorkItemCount
|| submittedWorkItemIds.some((workItemId) => !currentWorkItemIds.has(workItemId))
) {
throw new PerformanceSurveyApiError(`请一次完成当前 ${requiredWorkItemCount} 个案例的评分`)
}
const existingScores: Array<{
workItemId: string
totalScore: number
scores: unknown
}> = await tx.performanceSurveyWorkItemScore.findMany({
where: { assignmentId: currentAssignment.id },
select: {
workItemId: true,
totalScore: true,
scores: true,
},
})
const existingWorkItemIds = new Set(existingScores.map((score) => score.workItemId))
const remainingWorkItemIds = currentWorkItems
.map((item: { id: string }) => item.id)
.filter((workItemId: string) => !existingWorkItemIds.has(workItemId))
const submittedWorkItemIdSet = new Set(submittedWorkItemIds)
const includesAlreadyScoredWorkItem = submittedWorkItemIds.some((workItemId) => existingWorkItemIds.has(workItemId))
const missesRemainingWorkItem = remainingWorkItemIds.some((workItemId) => !submittedWorkItemIdSet.has(workItemId))
const includesUnexpectedWorkItem = submittedWorkItemIds.some((workItemId) => !remainingWorkItemIds.includes(workItemId))
if (
existingWorkItemIds.size !== existingScores.length
|| includesAlreadyScoredWorkItem
|| missesRemainingWorkItem
|| includesUnexpectedWorkItem
|| submissions.length !== remainingWorkItemIds.length
) {
throw new PerformanceSurveyApiError(`请一次提交剩余 ${remainingWorkItemIds.length} 个未评分案例的完整评分`, 409)
}
const createdScores: Array<{
workItemId: string
totalScore: number
scores: unknown
}> = []
for (const submission of submissions) {
createdScores.push(await tx.performanceSurveyWorkItemScore.create({
data: {
id: randomUUID(),
workItemId: submission.workItemId,
surveyId,
targetId,
assignmentId: currentAssignment.id,
evaluatorUserId: ctx.user.id,
evaluatorEmployeeId: ctx.employee?.id || null,
evaluatorName: getDisplayName(ctx.user),
scores: submission.scores as any,
comment: submission.comment,
totalScore: submission.scoreResult.totalScore,
},
}))
}
const assignmentScores = [...existingScores, ...createdScores]
const totalScore = assignmentScores.reduce((sum, score) => sum + Number(score.totalScore || 0), 0)
const aggregateScores = Object.fromEntries(
template.questions.map((question) => [
question.id,
assignmentScores.reduce((sum, score) => {
const values = score.scores as Record<string, unknown>
return sum + Number(values?.[question.id] || 0)
}, 0),
]),
)
const response = await tx.performanceSurveyResponse.create({
data: {
id: randomUUID(),
surveyId,
targetId,
assignmentId: currentAssignment.id,
respondentUserId: ctx.user.id,
respondentEmployeeId: ctx.employee?.id || null,
respondentName: getDisplayName(ctx.user),
respondentSnapshot: ctx.employee
? getEmployeeSnapshot(ctx.employee)
: {
userId: ctx.user.id,
displayName: getDisplayName(ctx.user),
departmentName: ctx.user.department,
snapshotAt: new Date().toISOString(),
},
scores: {
...aggregateScores,
workItemScores: Object.fromEntries(assignmentScores.map((score) => [score.workItemId, score.totalScore])),
dimensions: aggregateScores,
},
comment: clean(body?.finalComment),
totalScore,
achievementScore: totalScore,
kpiScore: totalScore,
weightedScore: totalScore,
},
})
await tx.performanceSurveyAssignment.update({
where: { id: currentAssignment.id },
data: {
status: 'submitted',
submittedAt: new Date(),
},
})
return { response, createdScores }
})
const pendingCount = await prisma.performanceSurveyAssignment.count({
where: { surveyId, status: 'pending' },
})
if (pendingCount === 0) {
await queueSurveyCreatorNotification(surveyId, 'survey.creator.completed')
}
if (isSelfWorkflowRole(readWorkflowRole(assignment.evaluatorSnapshot))) {
await queueSurveyAssignmentNotifications(surveyId)
}
return {
id: result.response.id,
completed: true,
workItemScoreCount: result.createdScores.length,
message: '全部案例评分完成',
}
}
export async function submitPerformanceSurveyWorkItemScore(
session: Session | null,
surveyId: string,