import { randomUUID } from 'crypto' import { open, rm, unlink } from 'fs/promises' import path from 'path' import type { Session } from 'next-auth' import { prisma } from '@/lib/prisma' import { getPublicAppOrigin } from '@/lib/public-app-url' import { buildDingtalkEntryPath } from '@/lib/safe-redirect' import { getPerformanceSurveyContext, } from '@/lib/performance-survey-service' import { getPerformanceSurveyAccountProfileWhere, } from '@/lib/performance-survey-report-sync' import { ensureWorkReviewNotificationWorkerStarted, queueWorkReviewNotification, } from '@/lib/work-review-notifications' import { serializePerformanceSurveyImagePayload, type PerformanceSurveyStoredImageGroup, } from '@/lib/performance-survey-image-processing' import { PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS, PerformanceSurveyProcessedImageError, readPerformanceSurveyWebpDimensions, validatePerformanceSurveyProcessedImages, type PerformanceSurveyProcessedImageDeclaration, } from '@/lib/performance-survey-processed-images' import { deleteFromS3, uploadFileToS3 } from '@/lib/s3' import { deleteOwnedUploadSession, getOwnedUploadedFile, getOwnedUploadSession, } from '@/lib/upload-sessions' import { deriveCompletedWorkReviewStatus, deriveWorkReviewDecision, } from '@/lib/work-review-policy' const REVIEW_ROLES = { MANAGER: 'manager', FINAL_REVIEWER: 'final_reviewer', } as const const VERSION_STATUS = { PENDING_REVIEW: 'pending_review', REVISION_REQUIRED: 'revision_required', APPROVED: 'approved', } as const type ReviewRole = typeof REVIEW_ROLES[keyof typeof REVIEW_ROLES] type ResolvedReviewer = { employee: any userId: string | null dingtalkUserId: string | null name: string } type ResolvedProcessedImage = { declaration: PerformanceSurveyProcessedImageDeclaration filePath: string fileSize: number } type ImageCommentInput = { imageId: string imageIndex: number sourceName: string decision: typeof VERSION_STATUS.APPROVED | typeof VERSION_STATUS.REVISION_REQUIRED comment: string } export class WorkReviewApiError extends Error { status: number constructor(message: string, status = 400) { super(message) this.status = status } } function clean(value: unknown) { const text = String(value ?? '').trim() return text || null } function escapeDingtalkMarkdown(value: unknown) { return String(value ?? '') .replace(/\\/g, '\\\\') .replace(/([`*_\[\]#>])/g, '\\$1') .trim() } function getEmployeeName(employee: any) { return clean(employee?.flowerName) || clean(employee?.displayName) || clean(employee?.realName) || clean(employee?.jobNumber) || '未命名人员' } function getEmployeeSnapshot(employee: any) { return { id: employee.id, dingtalkUserId: employee.dingtalkUserId || null, dingEmail: employee.dingEmail || null, jobNumber: employee.jobNumber || null, flowerName: employee.flowerName || null, realName: employee.realName || null, displayName: employee.displayName, departmentName: employee.departmentName || null, departmentPath: employee.departmentPath || null, positionTitle: employee.positionTitle || null, positionLevel: employee.positionLevel || null, levelType: employee.levelType || null, managerName: employee.managerName || null, } } function isPLevelEmployee(employee: any) { const levelType = String(employee?.levelType || '').trim().toUpperCase() const positionLevel = String(employee?.positionLevel || '').trim().toUpperCase() return levelType === 'P' || /^P(?:\d|$)/.test(positionLevel) } function parseWorkDate(value: unknown) { const text = clean(value) if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) { throw new WorkReviewApiError('请选择有效的作品日期') } const date = new Date(`${text}T00:00:00.000Z`) if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text) { throw new WorkReviewApiError('请选择有效的作品日期') } return date } function getWorkReviewActionUrl(projectId: string) { const path = `/work-review?project=${encodeURIComponent(projectId)}` return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}` } async function resolveEmployeeReviewer(employee: any): Promise { const dingtalkUserId = clean(employee?.dingtalkUserId) const dingEmail = clean(employee?.dingEmail) const user = dingtalkUserId || dingEmail ? await prisma.user.findFirst({ where: { OR: [ ...(dingtalkUserId ? [{ dingtalkUserId }] : []), ...(dingEmail ? [{ email: dingEmail }] : []), ], }, select: { id: true, dingtalkUserId: true }, }) : null return { employee, userId: user?.id || null, dingtalkUserId: dingtalkUserId || clean(user?.dingtalkUserId), name: getEmployeeName(employee), } } async function resolveDirectManager(employee: any) { const managerDingtalkUserId = clean(employee?.managerDingtalkUserId) const managerJobNumber = clean(employee?.managerJobNumber) if (!managerDingtalkUserId && !managerJobNumber) { throw new WorkReviewApiError(`无法确定${getEmployeeName(employee)}的直属主管,请先校准考核人员档案`, 409) } const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { OR: [ ...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []), ...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []), ], }, ], }, take: 2, }) if (managers.length !== 1) { throw new WorkReviewApiError(`无法唯一匹配${getEmployeeName(employee)}的直属主管,请先校准考核人员档案`, 409) } return resolveEmployeeReviewer(managers[0]) } async function resolveFinalReviewer() { const configuredVersions = await prisma.performanceSurveyTemplateVersion.findMany({ where: { status: 'published', template: { code: { in: ['visual_brand_tone', 'visual_brand_tone_without_copy'], }, }, }, select: { definition: true }, orderBy: [{ publishedAt: 'desc' }, { version: 'desc' }], take: 10, }) const configuredIds = Array.from(new Set(configuredVersions.flatMap((version) => { const definition = version.definition as any const stages = Array.isArray(definition?.workflow?.stages) ? definition.workflow.stages : [] return stages.flatMap((stage: any) => ( Array.isArray(stage?.reviewers) ? stage.reviewers .filter((reviewer: any) => ( reviewer?.source === 'fixed_employee' && (!reviewer?.employeeName || reviewer.employeeName === '元泽') )) .map((reviewer: any) => clean(reviewer.employeeId)) .filter(Boolean) : [] )) }))) as string[] if (configuredIds.length === 1) { const configuredEmployee = await prisma.performanceSurveyEmployeeProfile.findFirst({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { id: configuredIds[0] }, ], }, }) if (configuredEmployee) return resolveEmployeeReviewer(configuredEmployee) } const employees = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ await getPerformanceSurveyAccountProfileWhere(), { OR: [ { displayName: '元泽' }, { flowerName: '元泽' }, { realName: '元泽' }, ], }, ], }, take: 2, }) if (employees.length !== 1) { throw new WorkReviewApiError('无法唯一匹配元泽的人员档案,请先在考核调研人员管理中校准', 409) } return resolveEmployeeReviewer(employees[0]) } async function getContext(session: Session | null) { ensureWorkReviewNotificationWorkerStarted() const ctx = await getPerformanceSurveyContext(session) return { ...ctx, canSubmit: isPLevelEmployee(ctx.employee), } } function getAccessibleWhere(ctx: Awaited>) { if (ctx.isSystemAdmin) return {} const employeeId = clean(ctx.employee?.id) return { OR: [ { submitterUserId: ctx.user.id }, ...(employeeId ? [ { submitterEmployeeId: employeeId }, { managerEmployeeId: employeeId }, { finalReviewerEmployeeId: employeeId }, ] : []), ], } } function normalizeReviewRole(value: unknown): ReviewRole { const role = clean(value) if (role === REVIEW_ROLES.MANAGER || role === REVIEW_ROLES.FINAL_REVIEWER) return role throw new WorkReviewApiError('评审区域无效') } function canReviewRole( ctx: Awaited>, project: { managerEmployeeId: string managerUserId: string | null finalReviewerEmployeeId: string finalReviewerUserId: string | null }, role: ReviewRole, ) { const employeeId = clean(ctx.employee?.id) if (role === REVIEW_ROLES.MANAGER) { return Boolean( (employeeId && employeeId === project.managerEmployeeId) || project.managerUserId === ctx.user.id, ) } return Boolean( (employeeId && employeeId === project.finalReviewerEmployeeId) || project.finalReviewerUserId === ctx.user.id, ) } function getPendingRoles( ctx: Awaited>, project: any, version: any, ) { const submittedRoles = new Set((version?.reviews || []).map((review: any) => review.reviewerRole)) return [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER].filter((role) => ( !submittedRoles.has(role) && canReviewRole(ctx, project, role) )) } function serializeProject(ctx: Awaited>, project: any) { const versions = [...(project.versions || [])] .sort((left, right) => right.version - left.version) .map((version) => { const images = serializePerformanceSurveyImagePayload(version.imageUrls) return { id: version.id, version: version.version, submissionNote: version.submissionNote, status: version.status, submittedAt: version.submittedAt, completedAt: version.completedAt, imageUrls: images.imageUrls, imageGroups: images.imageGroups, reviews: [...(version.reviews || [])] .sort((left, right) => left.reviewerRole.localeCompare(right.reviewerRole)) .map((review) => ({ id: review.id, reviewerRole: review.reviewerRole, reviewerName: review.reviewerName, decision: review.decision, imageComments: review.imageComments, generalComment: review.generalComment, reviewedAt: review.reviewedAt, })), } }) const latest = versions[0] || null return { id: project.id, title: project.title, description: project.description, workDate: project.workDate, status: project.status, latestVersion: project.latestVersion, submitter: { id: project.submitterEmployeeId, name: project.submitterName, snapshot: project.submitterSnapshot, }, manager: { id: project.managerEmployeeId, name: project.managerName, }, finalReviewer: { id: project.finalReviewerEmployeeId, name: project.finalReviewerName, }, createdAt: project.createdAt, updatedAt: project.updatedAt, versions, permissions: { canResubmit: Boolean( ctx.canSubmit && project.submitterUserId === ctx.user.id && project.status === VERSION_STATUS.REVISION_REQUIRED, ), pendingReviewRoles: latest ? getPendingRoles(ctx, project, latest) : [], }, } } export async function getWorkReviewBoard(session: Session | null, params?: URLSearchParams) { const ctx = await getContext(session) const status = clean(params?.get('status')) const query = clean(params?.get('query')) const projectId = clean(params?.get('project')) const page = projectId ? 1 : Math.max(1, Number(params?.get('page') || 1) || 1) const pageSize = Math.min(50, Math.max(1, Number(params?.get('pageSize') || 20) || 20)) const filters: any[] = [getAccessibleWhere(ctx)] if (projectId) { filters.push({ id: projectId }) } else if (status && Object.values(VERSION_STATUS).includes(status as any)) { filters.push({ status }) } if (!projectId && query) { filters.push({ OR: [ { title: { contains: query, mode: 'insensitive' } }, { submitterName: { contains: query, mode: 'insensitive' } }, ], }) } const where = filters.length === 1 ? filters[0] : { AND: filters } const [total, projects] = await prisma.$transaction([ prisma.workReviewProject.count({ where }), prisma.workReviewProject.findMany({ where, include: { versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, orderBy: [{ workDate: 'desc' }, { updatedAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), ]) const items = projects.map((project) => serializeProject(ctx, project)) return { me: { user: { id: ctx.user.id, name: getEmployeeName(ctx.employee) || ctx.user.username, avatar: ctx.user.avatar, role: ctx.user.role, }, employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null, permissions: { canSubmit: ctx.canSubmit, isSystemAdmin: ctx.isSystemAdmin, isManager: Boolean(ctx.isLeadership), isFinalReviewer: Boolean( ctx.employee && items.some((item) => item.finalReviewer.id === ctx.employee.id), ), }, }, items, pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, } } async function readWebpDimensions(filePath: string) { const handle = await open(filePath, 'r') try { const header = Buffer.alloc(64) const { bytesRead } = await handle.read(header, 0, header.length, 0) return readPerformanceSurveyWebpDimensions(header.subarray(0, bytesRead)) } finally { await handle.close() } } async function resolveProcessedUpload( declaration: PerformanceSurveyProcessedImageDeclaration, userId: string, ): Promise { const uploadSession = await getOwnedUploadSession(declaration.uploadId, userId) if (!uploadSession) throw new WorkReviewApiError('图片上传已过期,请重新选择图片') if (uploadSession.fileSize > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxArtifactBytes) { throw new WorkReviewApiError('单张预处理图片不能超过 8MB') } const filePath = await getOwnedUploadedFile(declaration.uploadId, userId) if (!filePath) throw new WorkReviewApiError(`图片「${declaration.sourceName}」上传不完整,请重试`) return { declaration, filePath, fileSize: uploadSession.fileSize } } async function cleanupProcessedUploads( userId: string, uploads: Array<{ uploadId: string; filePath: string }>, ) { await Promise.allSettled(uploads.flatMap(({ uploadId, filePath }) => [ unlink(filePath), rm(path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId), { recursive: true, force: true }), deleteOwnedUploadSession(uploadId, userId), ])) } async function withStoredImages(input: { userId: string projectId: string versionId: string processedImages: unknown task: (imageGroups: PerformanceSurveyStoredImageGroup[]) => Promise }) { let groups: ReturnType try { groups = validatePerformanceSurveyProcessedImages(input.processedImages, { maxSliceWidth: 3_200 }) } catch (error) { if (error instanceof PerformanceSurveyProcessedImageError) { throw new WorkReviewApiError(error.message) } throw error } const uploads: Array<{ uploadId: string; filePath: string }> = [] const uploadedUrls: string[] = [] const resolvedByUploadId = new Map() try { let totalBytes = 0 for (const group of groups) { for (const declaration of [...group.slices, ...(group.preview ? [group.preview] : [])]) { const resolved = await resolveProcessedUpload(declaration, input.userId) uploads.push({ uploadId: declaration.uploadId, filePath: resolved.filePath }) totalBytes += resolved.fileSize if (totalBytes > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxBatchBytes) { throw new WorkReviewApiError('本次作品的预处理图片总大小不能超过 256MB') } const dimensions = await readWebpDimensions(resolved.filePath) if (dimensions.width !== declaration.width || dimensions.height !== declaration.height) { throw new WorkReviewApiError(`图片「${declaration.sourceName}」尺寸校验失败,请重新上传`) } resolvedByUploadId.set(declaration.uploadId, resolved) } } const imageGroups: PerformanceSurveyStoredImageGroup[] = [] for (const group of groups) { const slices: PerformanceSurveyStoredImageGroup['slices'] = [] for (const declaration of group.slices) { const resolved = resolvedByUploadId.get(declaration.uploadId)! const fileName = [ 'work-review', input.projectId, input.versionId, `${group.sourceIndex + 1}-${declaration.sliceIndex + 1}.webp`, ].join('/') const url = await uploadFileToS3(resolved.filePath, fileName, declaration.mimeType) uploadedUrls.push(url) slices.push({ url, width: declaration.width, height: declaration.height }) } const firstSlice = slices[0] let previewUrl = firstSlice.url let previewWidth = firstSlice.width let previewHeight = firstSlice.height if (group.preview) { const resolved = resolvedByUploadId.get(group.preview.uploadId)! const fileName = [ 'work-review', input.projectId, input.versionId, `${group.sourceIndex + 1}-preview.webp`, ].join('/') previewUrl = await uploadFileToS3(resolved.filePath, fileName, group.preview.mimeType) uploadedUrls.push(previewUrl) previewWidth = group.preview.width previewHeight = group.preview.height } imageGroups.push({ version: 1, imageId: randomUUID(), sourceName: group.sourceName, sourceWidth: Math.max(...slices.map((slice) => slice.width)), sourceHeight: slices.reduce((sum, slice) => sum + slice.height, 0), width: Math.max(...slices.map((slice) => slice.width)), height: slices.reduce((sum, slice) => sum + slice.height, 0), previewUrl, previewWidth, previewHeight, slices, }) } return await input.task(imageGroups) } catch (error) { await Promise.allSettled(uploadedUrls.map((url) => deleteFromS3(url))) throw error } finally { await cleanupProcessedUploads(input.userId, uploads) } } async function queueSubmittedNotifications(project: any, version: any) { const actionUrl = getWorkReviewActionUrl(project.id) const content = [ '### ◆ 作品待评审', '', `**${escapeDingtalkMarkdown(project.title)}**`, '', `**提交人:** ${escapeDingtalkMarkdown(project.submitterName)} `, `**作品日期:** ${new Date(project.workDate).toISOString().slice(0, 10)} `, `**版本:** V${version.version} `, '', '> 请逐张查看作品图片并填写评审修改建议。', ].join('\n') return Promise.all([ queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.managerEmployeeId, recipientUserId: project.managerUserId, dingtalkUserId: project.managerDingtalkUserId, recipientName: project.managerName, eventType: 'work_review.version.submitted.manager', businessKey: `work-review-version:${version.id}:manager`, title: '作品过稿看板', content, actionUrl, }), queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.finalReviewerEmployeeId, recipientUserId: project.finalReviewerUserId, dingtalkUserId: project.finalReviewerDingtalkUserId, recipientName: project.finalReviewerName, eventType: 'work_review.version.submitted.final_reviewer', businessKey: `work-review-version:${version.id}:final-reviewer`, title: '作品过稿看板', content, actionUrl, }), ]) } async function queueCompletedNotification(project: any, version: any) { const approved = version.status === VERSION_STATUS.APPROVED return queueWorkReviewNotification({ projectId: project.id, versionId: version.id, recipientEmployeeId: project.submitterEmployeeId, recipientUserId: project.submitterUserId, dingtalkUserId: project.submitterEmployee?.dingtalkUserId || null, recipientName: project.submitterName, eventType: 'work_review.version.completed', businessKey: `work-review-version:${version.id}:completed`, title: '作品过稿看板', content: [ `### ${approved ? '✓ 作品已通过' : '◆ 评审已完成'}`, '', `**${escapeDingtalkMarkdown(project.title)}**`, '', `**版本:** V${version.version} `, `**评审结果:** ${approved ? '直属主管与元泽均已通过' : '请按评审建议修改后再次提交'} `, '', approved ? '> 本轮作品已完成过稿。' : '> 点击下方按钮查看每张图的修改建议。', ].join('\n'), actionUrl: getWorkReviewActionUrl(project.id), }) } async function queueNotificationsSafely(task: () => Promise) { try { await task() } catch (error) { console.error('[work-review] 业务已保存,但通知排队失败,将由运维检查补发:', error) } } function ensureCanSubmit(ctx: Awaited>) { if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案,无法提交作品', 403) if (!ctx.canSubmit) throw new WorkReviewApiError('只有 P 级人员可以提交作品', 403) } export async function createWorkReviewProject(session: Session | null, body: any) { const ctx = await getContext(session) ensureCanSubmit(ctx) const title = clean(body?.title) if (!title) throw new WorkReviewApiError('请填写作品名称') if (title.length > 120) throw new WorkReviewApiError('作品名称不能超过 120 个字') const description = clean(body?.description) const submissionNote = clean(body?.submissionNote) const workDate = parseWorkDate(body?.workDate) const manager = await resolveDirectManager(ctx.employee) const finalReviewer = await resolveFinalReviewer() const projectId = randomUUID() const versionId = randomUUID() const created = await withStoredImages({ userId: ctx.user.id, projectId, versionId, processedImages: body?.processedImages, task: async (imageGroups) => prisma.workReviewProject.create({ data: { id: projectId, title, description, workDate, submitterUserId: ctx.user.id, submitterEmployeeId: ctx.employee.id, submitterName: getEmployeeName(ctx.employee), submitterSnapshot: getEmployeeSnapshot(ctx.employee), managerEmployeeId: manager.employee.id, managerUserId: manager.userId, managerDingtalkUserId: manager.dingtalkUserId, managerName: manager.name, finalReviewerEmployeeId: finalReviewer.employee.id, finalReviewerUserId: finalReviewer.userId, finalReviewerDingtalkUserId: finalReviewer.dingtalkUserId, finalReviewerName: finalReviewer.name, versions: { create: { id: versionId, version: 1, submissionNote, imageUrls: imageGroups as any, }, }, }, include: { submitterEmployee: true, versions: { include: { reviews: true } }, }, }), }) await queueNotificationsSafely(() => queueSubmittedNotifications(created, created.versions[0])) return serializeProject(ctx, created) } export async function createWorkReviewVersion(session: Session | null, projectId: string, body: any) { const ctx = await getContext(session) ensureCanSubmit(ctx) const existing = await prisma.workReviewProject.findUnique({ where: { id: projectId }, include: { versions: { orderBy: { version: 'desc' }, take: 1 } }, }) if (!existing) throw new WorkReviewApiError('作品不存在', 404) if (existing.submitterUserId !== ctx.user.id) throw new WorkReviewApiError('只能重新提交自己的作品', 403) if (existing.status !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409) } const versionId = randomUUID() const submissionNote = clean(body?.submissionNote) const updated = await withStoredImages({ userId: ctx.user.id, projectId, versionId, processedImages: body?.processedImages, task: async (imageGroups) => prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw>` SELECT "status", "latest_version" FROM "work_review_projects" WHERE "id" = ${projectId} FOR UPDATE ` if (!locked[0]) throw new WorkReviewApiError('作品不存在', 404) if (locked[0].status !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409) } const version = locked[0].latest_version + 1 await tx.workReviewVersion.create({ data: { id: versionId, projectId, version, submissionNote, imageUrls: imageGroups as any, }, }) return tx.workReviewProject.update({ where: { id: projectId }, data: { status: VERSION_STATUS.PENDING_REVIEW, latestVersion: version, }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) }), }) const version = updated.versions.find((item) => item.id === versionId) if (!version) throw new WorkReviewApiError('新版本创建失败', 500) await queueNotificationsSafely(() => queueSubmittedNotifications(updated, version)) return serializeProject(ctx, updated) } function readImageComments(value: unknown, imageGroups: PerformanceSurveyStoredImageGroup[]) { if (!Array.isArray(value)) throw new WorkReviewApiError('请填写每张图的评审建议') const byIndex = new Map() for (const item of value) { const imageIndex = Number(item?.imageIndex) if (!Number.isInteger(imageIndex) || imageIndex < 0 || imageIndex >= imageGroups.length || byIndex.has(imageIndex)) { throw new WorkReviewApiError('评审图片序号无效') } byIndex.set(imageIndex, item) } return imageGroups.map((group, imageIndex): ImageCommentInput => { const item = byIndex.get(imageIndex) if (!item) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`) const decision = clean(item.decision) if (decision !== VERSION_STATUS.APPROVED && decision !== VERSION_STATUS.REVISION_REQUIRED) { throw new WorkReviewApiError(`请选择图片「${group.sourceName}」的评审结论`) } const comment = clean(item.comment) if (!comment) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`) if (comment.length > 2000) throw new WorkReviewApiError('单张图片的评审建议不能超过 2000 个字') return { imageId: group.imageId || `image-${imageIndex + 1}`, imageIndex, sourceName: group.sourceName, decision, comment, } }) } export async function submitWorkReview(session: Session | null, versionId: string, body: any) { const ctx = await getContext(session) if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案', 403) const role = normalizeReviewRole(body?.reviewerRole) const version = await prisma.workReviewVersion.findUnique({ where: { id: versionId }, include: { project: { include: { submitterEmployee: true }, }, reviews: true, }, }) if (!version) throw new WorkReviewApiError('作品版本不存在', 404) if (version.status !== VERSION_STATUS.PENDING_REVIEW) { throw new WorkReviewApiError('该版本评审已经结束', 409) } if (!canReviewRole(ctx, version.project, role)) { throw new WorkReviewApiError(role === REVIEW_ROLES.MANAGER ? '无权填写直属主管评审' : '无权填写元泽评审', 403) } const imageGroups = serializePerformanceSurveyImagePayload(version.imageUrls).imageGroups const imageComments = readImageComments(body?.imageComments, imageGroups) const generalComment = clean(body?.generalComment) if (generalComment && generalComment.length > 4000) { throw new WorkReviewApiError('总体意见不能超过 4000 个字') } const decision = deriveWorkReviewDecision(imageComments) const result = await prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw>` SELECT "status" FROM "work_review_versions" WHERE "id" = ${versionId} FOR UPDATE ` if (!locked[0]) throw new WorkReviewApiError('作品版本不存在', 404) if (locked[0].status !== VERSION_STATUS.PENDING_REVIEW) { throw new WorkReviewApiError('该版本评审已经结束', 409) } const duplicate = await tx.workReviewReview.findUnique({ where: { versionId_reviewerRole: { versionId, reviewerRole: role } }, }) if (duplicate) throw new WorkReviewApiError('该评审区域已经提交,不能重复提交', 409) await tx.workReviewReview.create({ data: { id: randomUUID(), versionId, reviewerRole: role, reviewerEmployeeId: ctx.employee.id, reviewerUserId: ctx.user.id, reviewerName: getEmployeeName(ctx.employee), reviewerSnapshot: getEmployeeSnapshot(ctx.employee), decision, imageComments: imageComments as any, generalComment, }, }) const reviews = await tx.workReviewReview.findMany({ where: { versionId } }) const finalStatus = deriveCompletedWorkReviewStatus(reviews as Array<{ reviewerRole: string decision: 'approved' | 'revision_required' }>) if (!finalStatus) return { completed: false, status: VERSION_STATUS.PENDING_REVIEW } const completedAt = new Date() await tx.workReviewVersion.update({ where: { id: versionId }, data: { status: finalStatus, completedAt }, }) await tx.workReviewProject.update({ where: { id: version.projectId }, data: { status: finalStatus }, }) return { completed: true, status: finalStatus, completedAt } }) if (result.completed) { await queueNotificationsSafely(() => queueCompletedNotification(version.project, { ...version, status: result.status, completedAt: result.completedAt, })) } const refreshed = await prisma.workReviewProject.findUnique({ where: { id: version.projectId }, include: { submitterEmployee: true, versions: { include: { reviews: true }, orderBy: { version: 'desc' }, }, }, }) if (!refreshed) throw new WorkReviewApiError('作品不存在', 404) return serializeProject(ctx, refreshed) }