gongxiangp2/lib/work-review-service.ts

918 lines
31 KiB
TypeScript

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<ResolvedReviewer> {
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<ReturnType<typeof getContext>>) {
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<ReturnType<typeof getContext>>,
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<ReturnType<typeof getContext>>,
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<ReturnType<typeof getContext>>, 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<ResolvedProcessedImage> {
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<T>(input: {
userId: string
projectId: string
versionId: string
processedImages: unknown
task: (imageGroups: PerformanceSurveyStoredImageGroup[]) => Promise<T>
}) {
let groups: ReturnType<typeof validatePerformanceSurveyProcessedImages>
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<string, ResolvedProcessedImage>()
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<unknown>) {
try {
await task()
} catch (error) {
console.error('[work-review] 业务已保存,但通知排队失败,将由运维检查补发:', error)
}
}
function ensureCanSubmit(ctx: Awaited<ReturnType<typeof getContext>>) {
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<Array<{ status: string; latest_version: number }>>`
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<number, any>()
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<Array<{ status: string }>>`
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)
}