fix: 优化视觉评分提交流程
This commit is contained in:
parent
50c256c1bf
commit
94fb4e1898
@ -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))
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
收起
|
||||
|
||||
245
lib/__tests__/performance-survey-work-item-score-batch.test.ts
Normal file
245
lib/__tests__/performance-survey-work-item-score-batch.test.ts
Normal 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 }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user