1793 lines
61 KiB
TypeScript
1793 lines
61 KiB
TypeScript
import { randomUUID } from 'crypto'
|
||
import { open } from 'fs/promises'
|
||
import type { Session } from 'next-auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { getWorkReviewDashboardSyncState } from '@/lib/work-review-dashboard-sync'
|
||
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 {
|
||
getRequiredWorkReviewRoles,
|
||
getWorkReviewReviewMode,
|
||
WORK_REVIEW_REVIEW_ROLES,
|
||
type WorkReviewReviewRole,
|
||
} from '@/lib/work-review-review-mode'
|
||
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 {
|
||
acquireTaskMaterialUploadSessionLocks,
|
||
type MaterialUploadStorageLockRelease,
|
||
} from '@/lib/material-upload-cross-process-lock'
|
||
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
|
||
import {
|
||
getOwnedUploadedFile,
|
||
getOwnedUploadSession,
|
||
} from '@/lib/upload-sessions'
|
||
import {
|
||
canDeleteWorkReviewProject,
|
||
canSubmitWorkReview,
|
||
deriveCompletedWorkReviewStatus,
|
||
deriveWorkReviewDecision,
|
||
getWorkReviewEmployeeAliasKeys,
|
||
getWorkReviewEmployeeIdentityKeys,
|
||
getWorkReviewEmployeeMaterialUserIds,
|
||
getWorkReviewReviewerSelectionError,
|
||
isSameWorkReviewEmployeeAlias,
|
||
isSameWorkReviewEmployeeIdentity,
|
||
validateWorkReviewAnnotations,
|
||
type NormalizedWorkReviewAnnotation,
|
||
} from '@/lib/work-review-policy'
|
||
import {
|
||
buildFirstPassSubmitterRates,
|
||
buildFirstPassReviewSummary,
|
||
normalizeFirstPassReviewStatus,
|
||
type FirstPassReviewStatus,
|
||
type FirstPassReviewSummary,
|
||
type FirstPassSubmitterRate,
|
||
} from '@/lib/work-review-metrics'
|
||
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
|
||
import { getWorkReviewImageSelectionError, isWorkReviewImageType } from '@/lib/work-review-image-types'
|
||
import { WORK_REVIEW_IMAGE_TYPES, type WorkReviewImageType } from '@/types/work-review'
|
||
|
||
const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES
|
||
|
||
const VERSION_STATUS = {
|
||
PENDING_REVIEW: 'pending_review',
|
||
REVISION_REQUIRED: 'revision_required',
|
||
APPROVED: 'approved',
|
||
} as const
|
||
|
||
type ReviewRole = WorkReviewReviewRole
|
||
|
||
type ResolvedReviewer = {
|
||
employee: any
|
||
userId: string | null
|
||
dingtalkUserId: string | null
|
||
name: string
|
||
}
|
||
|
||
type ResolvedProcessedImage = {
|
||
declaration: PerformanceSurveyProcessedImageDeclaration
|
||
filePath: string
|
||
fileSize: number
|
||
}
|
||
|
||
type WorkReviewStoredImageGroup = PerformanceSurveyStoredImageGroup & { assetType?: WorkReviewImageType }
|
||
|
||
type WorkReviewStoredImagePayload = {
|
||
version: 2
|
||
workImageGroups: WorkReviewStoredImageGroup[]
|
||
referenceImageGroups: WorkReviewStoredImageGroup[]
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||
}
|
||
|
||
export function serializeWorkReviewImagePayload(value: unknown) {
|
||
if (isRecord(value) && Number(value.version) === 2) {
|
||
const workImages = serializeTypedWorkReviewImages(value.workImageGroups)
|
||
const referenceImages = serializeTypedWorkReviewImages(value.referenceImageGroups)
|
||
return {
|
||
imageUrls: workImages.imageUrls,
|
||
imageGroups: workImages.imageGroups,
|
||
referenceImageUrls: referenceImages.imageUrls,
|
||
referenceImageGroups: referenceImages.imageGroups,
|
||
}
|
||
}
|
||
|
||
const workImages = serializePerformanceSurveyImagePayload(value)
|
||
return {
|
||
imageUrls: workImages.imageUrls,
|
||
imageGroups: workImages.imageGroups,
|
||
referenceImageUrls: [],
|
||
referenceImageGroups: [],
|
||
}
|
||
}
|
||
|
||
function serializeTypedWorkReviewImages(value: unknown) {
|
||
const serialized = serializePerformanceSurveyImagePayload(value)
|
||
const typesByImageId = new Map<string, WorkReviewImageType>()
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
if (isRecord(item) && item.imageId && isWorkReviewImageType(item.assetType)) {
|
||
typesByImageId.set(String(item.imageId), item.assetType)
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
...serialized,
|
||
imageGroups: serialized.imageGroups.map((group): WorkReviewStoredImageGroup => ({
|
||
...group,
|
||
...(group.imageId && typesByImageId.has(group.imageId)
|
||
? { assetType: typesByImageId.get(group.imageId) }
|
||
: {}),
|
||
})),
|
||
}
|
||
}
|
||
|
||
function buildWorkReviewImagePayload(
|
||
workImageGroups: WorkReviewStoredImageGroup[],
|
||
referenceImageGroups: WorkReviewStoredImageGroup[],
|
||
): WorkReviewStoredImagePayload {
|
||
return {
|
||
version: 2,
|
||
workImageGroups,
|
||
referenceImageGroups,
|
||
}
|
||
}
|
||
|
||
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 getRequiredReviewRoles(project: { id: string; managerEmployeeId: string }): ReviewRole[] {
|
||
return getRequiredWorkReviewRoles(project)
|
||
}
|
||
|
||
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 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 materialUserIds = getWorkReviewEmployeeMaterialUserIds(employee)
|
||
const sourcePayload = employee?.sourcePayload
|
||
const materialUserEmail = sourcePayload && typeof sourcePayload === 'object' && !Array.isArray(sourcePayload)
|
||
? clean((sourcePayload as Record<string, unknown>).materialUserEmail)
|
||
: null
|
||
const emails = Array.from(new Set([dingEmail, materialUserEmail].filter(Boolean))) as string[]
|
||
const user = dingtalkUserId || emails.length > 0 || materialUserIds.length > 0
|
||
? await prisma.user.findFirst({
|
||
where: {
|
||
OR: [
|
||
...(materialUserIds.length > 0 ? [{ id: { in: materialUserIds } }] : []),
|
||
...(dingtalkUserId ? [{ dingtalkUserId }] : []),
|
||
...(emails.length > 0 ? [{ email: { in: emails } }] : []),
|
||
],
|
||
},
|
||
select: { id: true, dingtalkUserId: true },
|
||
})
|
||
: null
|
||
|
||
return {
|
||
employee,
|
||
userId: user?.id || null,
|
||
dingtalkUserId: dingtalkUserId || clean(user?.dingtalkUserId),
|
||
name: getEmployeeName(employee),
|
||
}
|
||
}
|
||
|
||
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 resolveSelectedReviewer(
|
||
employeeId: unknown,
|
||
submitterEmployee: any,
|
||
submitterUserId: string,
|
||
finalReviewer: ResolvedReviewer,
|
||
) {
|
||
const reviewerEmployeeId = clean(employeeId)
|
||
const selectionError = getWorkReviewReviewerSelectionError({
|
||
reviewerEmployeeId,
|
||
submitterEmployeeId: submitterEmployee.id,
|
||
finalReviewerEmployeeId: finalReviewer.employee.id,
|
||
})
|
||
if (selectionError) throw new WorkReviewApiError(selectionError)
|
||
|
||
const employee = await prisma.performanceSurveyEmployeeProfile.findFirst({
|
||
where: {
|
||
AND: [
|
||
await getPerformanceSurveyAccountProfileWhere(),
|
||
{ id: reviewerEmployeeId },
|
||
],
|
||
},
|
||
})
|
||
if (!employee) {
|
||
throw new WorkReviewApiError('所选评审人不存在或账号不可用,请重新选择', 409)
|
||
}
|
||
if (
|
||
isSameWorkReviewEmployeeIdentity(employee, submitterEmployee)
|
||
|| isSameWorkReviewEmployeeAlias(employee, submitterEmployee)
|
||
) {
|
||
throw new WorkReviewApiError('评审人不能选择自己,请选择其他人员')
|
||
}
|
||
if (
|
||
isSameWorkReviewEmployeeIdentity(employee, finalReviewer.employee)
|
||
|| isSameWorkReviewEmployeeAlias(employee, finalReviewer.employee)
|
||
) {
|
||
throw new WorkReviewApiError('元泽已是固定评审人,请选择另一名评审人')
|
||
}
|
||
const reviewer = await resolveEmployeeReviewer(employee)
|
||
if (reviewer.userId && reviewer.userId === submitterUserId) {
|
||
throw new WorkReviewApiError('评审人不能选择自己,请选择其他人员')
|
||
}
|
||
if (reviewer.userId && finalReviewer.userId && reviewer.userId === finalReviewer.userId) {
|
||
throw new WorkReviewApiError('元泽已是固定评审人,请选择另一名评审人')
|
||
}
|
||
return reviewer
|
||
}
|
||
|
||
async function getContext(session: Session | null) {
|
||
ensureWorkReviewNotificationWorkerStarted()
|
||
const ctx = await getPerformanceSurveyContext(session)
|
||
return {
|
||
...ctx,
|
||
canSubmit: canSubmitWorkReview(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 getRequiredReviewRoles(project).filter((role) => (
|
||
!submittedRoles.has(role) && canReviewRole(ctx, project, role)
|
||
))
|
||
}
|
||
|
||
function serializeProject(ctx: Awaited<ReturnType<typeof getContext>>, project: any) {
|
||
const reviewMode = getWorkReviewReviewMode(project)
|
||
const requiredReviewRoles = getRequiredReviewRoles(project)
|
||
const versions = [...(project.versions || [])]
|
||
.sort((left, right) => right.version - left.version)
|
||
.map((version) => {
|
||
const images = serializeWorkReviewImagePayload(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,
|
||
referenceImageUrls: images.referenceImageUrls,
|
||
referenceImageGroups: images.referenceImageGroups,
|
||
reviews: [...(version.reviews || [])]
|
||
.filter((review) => requiredReviewRoles.includes(review.reviewerRole))
|
||
.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,
|
||
brand: project.brand || null,
|
||
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,
|
||
reviewMode,
|
||
requiredReviewRoles,
|
||
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,
|
||
canDeleteProjects: canDeleteWorkReviewProject(ctx.user),
|
||
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)),
|
||
},
|
||
}
|
||
}
|
||
|
||
export type OngoingWorkReviewProjectListItem = {
|
||
id: string
|
||
title: string
|
||
brand?: string | null
|
||
description: string | null
|
||
status: 'pending_review' | 'revision_required'
|
||
workDate: string
|
||
latestVersion: number
|
||
submitterName: string
|
||
managerName: string
|
||
finalReviewerName: string
|
||
updatedAt: string
|
||
}
|
||
|
||
export type OngoingWorkReviewProjectPage = {
|
||
items: OngoingWorkReviewProjectListItem[]
|
||
pagination: {
|
||
page: number
|
||
pageSize: number
|
||
total: number
|
||
totalPages: number
|
||
}
|
||
}
|
||
|
||
export type FirstPassReviewProjectListItem = {
|
||
id: string
|
||
title: string
|
||
brand?: string | null
|
||
/** Present in API responses; optional for compatibility with older callers. */
|
||
description?: string | null
|
||
currentStatus: string
|
||
latestVersion: number
|
||
submitterName: string
|
||
managerName: string
|
||
finalReviewerName: string
|
||
workDate: string
|
||
/** Present in API responses; optional for compatibility with older callers. */
|
||
createdAt?: string
|
||
updatedAt: string
|
||
firstVersionStatus: FirstPassReviewStatus
|
||
firstVersionSubmittedAt: string
|
||
firstVersionCompletedAt: string | null
|
||
/** Actual V1 review decisions without image comments or other large fields. */
|
||
firstVersionReviews?: Array<{
|
||
reviewerRole: string
|
||
reviewerName: string
|
||
decision: string
|
||
reviewedAt: string
|
||
}>
|
||
}
|
||
|
||
export type FirstPassReviewProjectPage = {
|
||
summary: FirstPassReviewSummary
|
||
submitterRates: FirstPassSubmitterRate[]
|
||
items: FirstPassReviewProjectListItem[]
|
||
pagination: {
|
||
page: number
|
||
pageSize: number
|
||
total: number
|
||
totalPages: number
|
||
}
|
||
}
|
||
|
||
function getDepartmentDashboardProjectWhere(ctx: Awaited<ReturnType<typeof getContext>>) {
|
||
return {
|
||
submitterEmployee: {
|
||
is: buildDepartmentDashboardEmployeeWhere(ctx.scopeDepartmentPath),
|
||
},
|
||
}
|
||
}
|
||
|
||
function getFirstPassReviewWhere(projectWhere: any) {
|
||
return {
|
||
version: 1,
|
||
project: { is: projectWhere },
|
||
}
|
||
}
|
||
|
||
async function getFirstPassReviewSummaryForProjectWhere(
|
||
projectWhere: any,
|
||
): Promise<FirstPassReviewSummary> {
|
||
const [rows, totalApproved] = await Promise.all([
|
||
prisma.workReviewVersion.groupBy({
|
||
by: ['status'],
|
||
where: getFirstPassReviewWhere(projectWhere),
|
||
_count: { _all: true },
|
||
}),
|
||
prisma.workReviewProject.count({
|
||
where: {
|
||
AND: [projectWhere, { status: 'approved' }],
|
||
},
|
||
}),
|
||
])
|
||
|
||
const summary = buildFirstPassReviewSummary(rows.map((row) => ({
|
||
status: row.status,
|
||
count: row._count._all,
|
||
})), totalApproved)
|
||
const sync = getWorkReviewDashboardSyncState()
|
||
return {
|
||
...summary,
|
||
syncStatus: sync.status,
|
||
lastSyncedAt: sync.lastSyncedAt,
|
||
syncRowsChecked: sync.rowsChecked,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 品牌中心看板“首轮通过”的真实统计口径。
|
||
*
|
||
* 分子仅包含 V1 状态为 approved 的作品,分母是作品过稿后台当前状态为
|
||
* approved 的全部作品。全部 V1 上传数只用于明细总量展示。
|
||
*/
|
||
export async function getFirstPassReviewSummary(
|
||
session: Session | null,
|
||
): Promise<FirstPassReviewSummary> {
|
||
// 首轮通过是品牌中心全量作品指标,不按登录人的部门路径缩小分母。
|
||
// getContext 仍负责会话校验和作品过稿通知 worker 的启动,但其部门范围
|
||
// 只适用于“进行中项目”等部门视图,不适用于这个全量作品指标。
|
||
await getContext(session)
|
||
return getFirstPassReviewSummaryForProjectWhere({})
|
||
}
|
||
|
||
export async function getFirstPassReviewProjects(
|
||
session: Session | null,
|
||
params?: { page?: number; pageSize?: number; query?: string },
|
||
): Promise<FirstPassReviewProjectPage> {
|
||
// 明细与卡片使用同一套品牌中心全量作品口径;搜索和分页只缩小明细列表。
|
||
await getContext(session)
|
||
const page = Math.max(1, Number(params?.page || 1) || 1)
|
||
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
|
||
const query = clean(params?.query)
|
||
const scopeWhere = {}
|
||
const scopeFilters: any[] = [
|
||
scopeWhere,
|
||
{ versions: { some: { version: 1 } } },
|
||
]
|
||
|
||
const baseWhere = { AND: scopeFilters }
|
||
let where: any = baseWhere
|
||
if (query) {
|
||
where = {
|
||
AND: [
|
||
baseWhere,
|
||
{
|
||
OR: [
|
||
{ title: { contains: query, mode: 'insensitive' } },
|
||
{ submitterName: { contains: query, mode: 'insensitive' } },
|
||
{ managerName: { contains: query, mode: 'insensitive' } },
|
||
],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
const [summary, total, projects, submitterProjects] = await Promise.all([
|
||
// Searching and detail-only V1 filters must not change the headline rate.
|
||
// The denominator is every approved project in the complete department scope.
|
||
getFirstPassReviewSummaryForProjectWhere(scopeWhere),
|
||
prisma.workReviewProject.count({ where }),
|
||
prisma.workReviewProject.findMany({
|
||
where,
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
brand: true,
|
||
description: true,
|
||
status: true,
|
||
latestVersion: true,
|
||
submitterName: true,
|
||
managerName: true,
|
||
finalReviewerName: true,
|
||
workDate: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
versions: {
|
||
where: { version: 1 },
|
||
select: {
|
||
status: true,
|
||
submittedAt: true,
|
||
completedAt: true,
|
||
reviews: {
|
||
select: {
|
||
reviewerRole: true,
|
||
reviewerName: true,
|
||
decision: true,
|
||
reviewedAt: true,
|
||
},
|
||
orderBy: [{ reviewedAt: 'asc' }, { reviewerRole: 'asc' }],
|
||
},
|
||
},
|
||
take: 1,
|
||
},
|
||
},
|
||
orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }, { id: 'desc' }],
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
prisma.workReviewProject.findMany({
|
||
where: { AND: [scopeWhere, { versions: { some: { version: 1 } } }] },
|
||
select: {
|
||
submitterName: true,
|
||
status: true,
|
||
versions: {
|
||
where: { version: 1 },
|
||
select: { status: true },
|
||
take: 1,
|
||
},
|
||
},
|
||
}),
|
||
])
|
||
|
||
return {
|
||
summary,
|
||
submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({
|
||
submitterName: project.submitterName,
|
||
currentStatus: project.status,
|
||
firstVersionStatus: project.versions[0]?.status || null,
|
||
}))),
|
||
items: projects.map((project) => {
|
||
const firstVersion = project.versions[0]
|
||
return {
|
||
id: project.id,
|
||
title: project.title,
|
||
brand: project.brand || null,
|
||
description: project.description,
|
||
currentStatus: project.status,
|
||
latestVersion: project.latestVersion,
|
||
submitterName: project.submitterName,
|
||
managerName: project.managerName,
|
||
finalReviewerName: project.finalReviewerName,
|
||
workDate: project.workDate.toISOString(),
|
||
createdAt: project.createdAt.toISOString(),
|
||
updatedAt: project.updatedAt.toISOString(),
|
||
firstVersionStatus: normalizeFirstPassReviewStatus(firstVersion.status),
|
||
firstVersionSubmittedAt: firstVersion.submittedAt.toISOString(),
|
||
firstVersionCompletedAt: firstVersion.completedAt?.toISOString() || null,
|
||
firstVersionReviews: firstVersion.reviews.map((review) => ({
|
||
reviewerRole: review.reviewerRole,
|
||
reviewerName: review.reviewerName,
|
||
decision: review.decision,
|
||
reviewedAt: review.reviewedAt.toISOString(),
|
||
})),
|
||
}
|
||
}),
|
||
pagination: {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 部门看板“进行中项目”的真实数据口径。
|
||
*
|
||
* 当前仓库唯一的项目实体是作品过稿项目,因此这里仅统计仍在评审或待修改的
|
||
* WorkReviewProject,并严格限制在登录人的明确部门路径范围内,避免跨部门展示项目。
|
||
*/
|
||
export async function getOngoingWorkReviewProjects(
|
||
session: Session | null,
|
||
params?: { page?: number; pageSize?: number; query?: string },
|
||
): Promise<OngoingWorkReviewProjectPage> {
|
||
const ctx = await getContext(session)
|
||
const page = Math.max(1, Number(params?.page || 1) || 1)
|
||
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
|
||
const query = clean(params?.query)
|
||
const filters: any[] = [
|
||
getDepartmentDashboardProjectWhere(ctx),
|
||
{ status: { in: [VERSION_STATUS.PENDING_REVIEW, VERSION_STATUS.REVISION_REQUIRED] } },
|
||
]
|
||
|
||
if (query) {
|
||
filters.push({
|
||
OR: [
|
||
{ title: { contains: query, mode: 'insensitive' } },
|
||
{ submitterName: { contains: query, mode: 'insensitive' } },
|
||
{ managerName: { contains: query, mode: 'insensitive' } },
|
||
],
|
||
})
|
||
}
|
||
|
||
const where = { AND: filters }
|
||
const [total, projects] = await prisma.$transaction([
|
||
prisma.workReviewProject.count({ where }),
|
||
prisma.workReviewProject.findMany({
|
||
where,
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
brand: true,
|
||
description: true,
|
||
status: true,
|
||
workDate: true,
|
||
latestVersion: true,
|
||
submitterName: true,
|
||
managerName: true,
|
||
finalReviewerName: true,
|
||
updatedAt: true,
|
||
},
|
||
orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }],
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
])
|
||
|
||
return {
|
||
items: projects.map((project) => ({
|
||
...project,
|
||
status: project.status as OngoingWorkReviewProjectListItem['status'],
|
||
workDate: project.workDate.toISOString(),
|
||
updatedAt: project.updatedAt.toISOString(),
|
||
})),
|
||
pagination: {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||
},
|
||
}
|
||
}
|
||
|
||
function getStoredWorkReviewAssetUrls(value: unknown) {
|
||
const payload = serializeWorkReviewImagePayload(value)
|
||
const groups = [...payload.imageGroups, ...payload.referenceImageGroups]
|
||
return Array.from(new Set(groups.flatMap((group) => [
|
||
group.previewUrl,
|
||
...group.slices.map((slice) => slice.url),
|
||
]).filter(Boolean)))
|
||
}
|
||
|
||
export async function deleteWorkReviewProject(session: Session | null, projectId: string) {
|
||
const ctx = await getContext(session)
|
||
if (!canDeleteWorkReviewProject(ctx.user)) {
|
||
throw new WorkReviewApiError('只有无邪可以删除作品过稿记录', 403)
|
||
}
|
||
|
||
const id = clean(projectId)
|
||
if (!id) throw new WorkReviewApiError('作品参数无效')
|
||
const project = await prisma.workReviewProject.findUnique({
|
||
where: { id },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
versions: { select: { imageUrls: true } },
|
||
},
|
||
})
|
||
if (!project) throw new WorkReviewApiError('作品不存在', 404)
|
||
|
||
const result = await prisma.workReviewProject.deleteMany({ where: { id } })
|
||
if (result.count !== 1) {
|
||
throw new WorkReviewApiError('作品已被删除,请刷新看板', 409)
|
||
}
|
||
|
||
const assetUrls = Array.from(new Set(project.versions.flatMap((version) => (
|
||
getStoredWorkReviewAssetUrls(version.imageUrls)
|
||
))))
|
||
const cleanupResults = await Promise.allSettled(assetUrls.map((url) => deleteFromS3(url)))
|
||
const cleanupFailures = cleanupResults.filter((item) => item.status === 'rejected')
|
||
if (cleanupFailures.length > 0) {
|
||
console.error(`[work-review] 作品 ${id} 已删除,但有 ${cleanupFailures.length} 个图片对象清理失败`)
|
||
}
|
||
|
||
return { id: project.id, title: project.title }
|
||
}
|
||
|
||
export async function getWorkReviewReviewerOptions(session: Session | null) {
|
||
const ctx = await getContext(session)
|
||
if (!ctx.employee) {
|
||
throw new WorkReviewApiError('当前账号尚未匹配考核人员档案,无法选择评审人', 403)
|
||
}
|
||
const finalReviewer = await resolveFinalReviewer()
|
||
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
|
||
where: {
|
||
AND: [
|
||
await getPerformanceSurveyAccountProfileWhere(),
|
||
{ id: { notIn: [ctx.employee.id, finalReviewer.employee.id] } },
|
||
],
|
||
},
|
||
select: {
|
||
id: true,
|
||
dingtalkUserId: true,
|
||
dingEmail: true,
|
||
jobNumber: true,
|
||
flowerName: true,
|
||
realName: true,
|
||
displayName: true,
|
||
departmentName: true,
|
||
positionTitle: true,
|
||
sourcePayload: true,
|
||
},
|
||
orderBy: [{ departmentName: 'asc' }, { displayName: 'asc' }],
|
||
take: 1000,
|
||
})
|
||
const excludedUserIds = new Set([
|
||
ctx.user.id.toLowerCase(),
|
||
...(finalReviewer.userId ? [finalReviewer.userId.toLowerCase()] : []),
|
||
])
|
||
const excludedIdentityKeys = new Set([
|
||
...getWorkReviewEmployeeIdentityKeys(ctx.employee),
|
||
...getWorkReviewEmployeeIdentityKeys(finalReviewer.employee),
|
||
])
|
||
const excludedAliasKeys = new Set([
|
||
...getWorkReviewEmployeeAliasKeys(ctx.employee),
|
||
...getWorkReviewEmployeeAliasKeys(finalReviewer.employee),
|
||
])
|
||
const seenIdentityKeys = new Set<string>()
|
||
return employees.filter((employee) => {
|
||
if (getWorkReviewEmployeeMaterialUserIds(employee).some((id) => excludedUserIds.has(id))) {
|
||
return false
|
||
}
|
||
const identityKeys = getWorkReviewEmployeeIdentityKeys(employee)
|
||
const aliasKeys = getWorkReviewEmployeeAliasKeys(employee)
|
||
if (aliasKeys.some((key) => excludedAliasKeys.has(key))) {
|
||
return false
|
||
}
|
||
if (identityKeys.some((key) => excludedIdentityKeys.has(key) || seenIdentityKeys.has(key))) {
|
||
return false
|
||
}
|
||
identityKeys.forEach((key) => seenIdentityKeys.add(key))
|
||
return true
|
||
}).map((employee) => ({
|
||
id: employee.id,
|
||
name: getEmployeeName(employee),
|
||
jobNumber: employee.jobNumber,
|
||
departmentName: employee.departmentName,
|
||
positionTitle: employee.positionTitle,
|
||
}))
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
type ValidatedWorkReviewProcessedImageGroups = Array<
|
||
ReturnType<typeof validatePerformanceSurveyProcessedImages>[number] & { assetType: WorkReviewImageType }
|
||
>
|
||
|
||
type WorkReviewPersistenceOutcome =
|
||
| 'not_attempted'
|
||
| 'attempted'
|
||
| 'not_committed'
|
||
| 'committed'
|
||
| 'unknown'
|
||
|
||
function validateStoredImages(
|
||
processedImages: unknown,
|
||
options: { optional?: boolean } = {},
|
||
): ValidatedWorkReviewProcessedImageGroups {
|
||
if (options.optional && (processedImages == null || (Array.isArray(processedImages) && processedImages.length === 0))) {
|
||
return []
|
||
}
|
||
|
||
let groups: ReturnType<typeof validatePerformanceSurveyProcessedImages>
|
||
try {
|
||
groups = validatePerformanceSurveyProcessedImages(processedImages, { maxSliceWidth: 3_200 })
|
||
} catch (error) {
|
||
if (error instanceof PerformanceSurveyProcessedImageError) {
|
||
throw new WorkReviewApiError(error.message)
|
||
}
|
||
throw error
|
||
}
|
||
// 分类属于整张源图,预览和所有切片必须一致,不能借分片绕过整套校验。
|
||
return groups.map((group) => {
|
||
const declarations = (processedImages as Array<Record<string, unknown>>)
|
||
.filter((item) => Number(item.sourceIndex) === group.sourceIndex)
|
||
const types = declarations.map((item) => item.assetType === undefined ? 'tone' : item.assetType)
|
||
if (types.some((type) => !isWorkReviewImageType(type)) || new Set(types).size !== 1) {
|
||
throw new WorkReviewApiError('图片分类无效,同一张图片的预览和切片必须使用相同分类')
|
||
}
|
||
return { ...group, assetType: types[0] as WorkReviewImageType }
|
||
})
|
||
}
|
||
|
||
function getProcessedImageDeclarations(groups: ValidatedWorkReviewProcessedImageGroups) {
|
||
return groups.flatMap((group) => [
|
||
...group.slices,
|
||
...(group.preview ? [group.preview] : []),
|
||
])
|
||
}
|
||
|
||
function getLockedUploadIds(
|
||
workGroups: ValidatedWorkReviewProcessedImageGroups,
|
||
referenceGroups: ValidatedWorkReviewProcessedImageGroups,
|
||
) {
|
||
const uploadIds = [
|
||
...getProcessedImageDeclarations(workGroups),
|
||
...getProcessedImageDeclarations(referenceGroups),
|
||
].map((declaration) => declaration.uploadId)
|
||
if (new Set(uploadIds).size !== uploadIds.length) {
|
||
throw new WorkReviewApiError('作品图和参考图不能复用同一图片上传凭证')
|
||
}
|
||
return uploadIds.sort()
|
||
}
|
||
|
||
async function resolveStoredImageGroups(
|
||
groups: ValidatedWorkReviewProcessedImageGroups,
|
||
userId: string,
|
||
) {
|
||
const resolvedByUploadId = new Map<string, ResolvedProcessedImage>()
|
||
let totalBytes = 0
|
||
for (const declaration of getProcessedImageDeclarations(groups)) {
|
||
const resolved = await resolveProcessedUpload(declaration, userId)
|
||
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)
|
||
}
|
||
return resolvedByUploadId
|
||
}
|
||
|
||
async function uploadStoredImageGroups(input: {
|
||
groups: ValidatedWorkReviewProcessedImageGroups
|
||
resolvedByUploadId: Map<string, ResolvedProcessedImage>
|
||
projectId: string
|
||
versionId: string
|
||
assetKind: 'work' | 'reference'
|
||
uploadedUrls: Set<string>
|
||
}) {
|
||
const imageGroups: Array<PerformanceSurveyStoredImageGroup & { assetType: WorkReviewImageType }> = []
|
||
for (const group of input.groups) {
|
||
const slices: PerformanceSurveyStoredImageGroup['slices'] = []
|
||
for (const declaration of group.slices) {
|
||
const resolved = input.resolvedByUploadId.get(declaration.uploadId)!
|
||
const fileName = [
|
||
'work-review',
|
||
input.projectId,
|
||
input.versionId,
|
||
input.assetKind,
|
||
`${group.sourceIndex + 1}-${declaration.sliceIndex + 1}.webp`,
|
||
].join('/')
|
||
const url = await uploadFileToS3(resolved.filePath, fileName, declaration.mimeType)
|
||
input.uploadedUrls.add(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 = input.resolvedByUploadId.get(group.preview.uploadId)!
|
||
const fileName = [
|
||
'work-review',
|
||
input.projectId,
|
||
input.versionId,
|
||
input.assetKind,
|
||
`${group.sourceIndex + 1}-preview.webp`,
|
||
].join('/')
|
||
previewUrl = await uploadFileToS3(resolved.filePath, fileName, group.preview.mimeType)
|
||
input.uploadedUrls.add(previewUrl)
|
||
previewWidth = group.preview.width
|
||
previewHeight = group.preview.height
|
||
}
|
||
imageGroups.push({
|
||
assetType: group.assetType,
|
||
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 imageGroups
|
||
}
|
||
|
||
async function cleanupConsumedWorkReviewUploadSessions(uploadIds: string[]) {
|
||
const results = await Promise.allSettled(
|
||
uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId)),
|
||
)
|
||
results.forEach((result, index) => {
|
||
if (result.status === 'rejected') {
|
||
console.warn(
|
||
`[work-review] 作品已保存,但上传会话 ${uploadIds[index]} 清理失败:`,
|
||
result.reason,
|
||
)
|
||
return
|
||
}
|
||
if (!result.value.success) {
|
||
console.warn(
|
||
`[work-review] 作品已保存,但上传会话 ${uploadIds[index]} 未能安全清理:`,
|
||
result.value,
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
async function withStoredImageSets<T>(input: {
|
||
userId: string
|
||
projectId: string
|
||
versionId: string
|
||
processedImages: unknown
|
||
processedReferenceImages: unknown
|
||
task: (
|
||
imageGroups: PerformanceSurveyStoredImageGroup[],
|
||
referenceImageGroups: PerformanceSurveyStoredImageGroup[],
|
||
) => Promise<T>
|
||
confirmCommitted: (
|
||
imageGroups: PerformanceSurveyStoredImageGroup[],
|
||
referenceImageGroups: PerformanceSurveyStoredImageGroup[],
|
||
) => Promise<T | null>
|
||
}) {
|
||
const workGroups = validateStoredImages(input.processedImages)
|
||
const referenceGroups = validateStoredImages(input.processedReferenceImages, { optional: true })
|
||
const selectionError = getWorkReviewImageSelectionError(
|
||
Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, workGroups.filter((group) => group.assetType === type).length])) as Record<WorkReviewImageType, number>,
|
||
Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, referenceGroups.filter((group) => group.assetType === type).length])) as Record<WorkReviewImageType, number>,
|
||
)
|
||
if (selectionError) throw new WorkReviewApiError(selectionError)
|
||
const uploadIds = getLockedUploadIds(workGroups, referenceGroups)
|
||
|
||
const uploadedUrls = new Set<string>()
|
||
let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null
|
||
const persistenceOutcome: { state: WorkReviewPersistenceOutcome } = { state: 'not_attempted' }
|
||
try {
|
||
releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds)
|
||
const resolvedWorkImages = await resolveStoredImageGroups(workGroups, input.userId)
|
||
const resolvedReferenceImages = await resolveStoredImageGroups(referenceGroups, input.userId)
|
||
const imageGroups = await uploadStoredImageGroups({
|
||
groups: workGroups,
|
||
resolvedByUploadId: resolvedWorkImages,
|
||
projectId: input.projectId,
|
||
versionId: input.versionId,
|
||
assetKind: 'work',
|
||
uploadedUrls,
|
||
})
|
||
const referenceImageGroups = await uploadStoredImageGroups({
|
||
groups: referenceGroups,
|
||
resolvedByUploadId: resolvedReferenceImages,
|
||
projectId: input.projectId,
|
||
versionId: input.versionId,
|
||
assetKind: 'reference',
|
||
uploadedUrls,
|
||
})
|
||
|
||
let result: T
|
||
persistenceOutcome.state = 'attempted'
|
||
releaseUploadSessionLocks.assertHeld()
|
||
try {
|
||
result = await input.task(imageGroups, referenceImageGroups)
|
||
persistenceOutcome.state = 'committed'
|
||
} catch (persistenceError) {
|
||
let confirmed: T | null
|
||
try {
|
||
confirmed = await input.confirmCommitted(imageGroups, referenceImageGroups)
|
||
} catch (inspectionError) {
|
||
persistenceOutcome.state = 'unknown'
|
||
console.error('[work-review] 作品保存异常后无法确认数据库最终状态:', inspectionError)
|
||
throw new WorkReviewApiError(
|
||
'作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交',
|
||
503,
|
||
)
|
||
}
|
||
if (!confirmed) {
|
||
persistenceOutcome.state = 'not_committed'
|
||
throw persistenceError
|
||
}
|
||
persistenceOutcome.state = 'committed'
|
||
result = confirmed
|
||
}
|
||
|
||
try {
|
||
releaseUploadSessionLocks.assertHeld()
|
||
await cleanupConsumedWorkReviewUploadSessions(uploadIds)
|
||
} catch (cleanupError) {
|
||
console.warn('[work-review] 作品已保存,但上传会话清理阶段异常:', cleanupError)
|
||
}
|
||
return result
|
||
} catch (error) {
|
||
if (
|
||
persistenceOutcome.state === 'not_attempted'
|
||
|| persistenceOutcome.state === 'not_committed'
|
||
) {
|
||
if (uploadedUrls.size > 0) {
|
||
try {
|
||
releaseUploadSessionLocks?.assertHeld()
|
||
} catch (lockError) {
|
||
console.error('[work-review] 无法确认上传会话锁仍有效,保留本次 S3 图片:', lockError)
|
||
throw new WorkReviewApiError(
|
||
'作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交',
|
||
503,
|
||
)
|
||
}
|
||
await Promise.allSettled(Array.from(uploadedUrls, (url) => deleteFromS3(url)))
|
||
}
|
||
}
|
||
throw error
|
||
} finally {
|
||
try {
|
||
await releaseUploadSessionLocks?.()
|
||
} catch (releaseError) {
|
||
console.error('[work-review] 释放图片上传会话锁失败:', releaseError)
|
||
}
|
||
}
|
||
}
|
||
|
||
function hasCompleteCommittedWorkReviewVersion(
|
||
project: any,
|
||
expected: {
|
||
projectId: string
|
||
versionId: string
|
||
submitterUserId: string
|
||
versionNumber: number
|
||
imageGroups: PerformanceSurveyStoredImageGroup[]
|
||
referenceImageGroups: PerformanceSurveyStoredImageGroup[]
|
||
},
|
||
) {
|
||
if (
|
||
!project
|
||
|| project.id !== expected.projectId
|
||
|| project.submitterUserId !== expected.submitterUserId
|
||
|| project.status !== VERSION_STATUS.PENDING_REVIEW
|
||
|| project.latestVersion !== expected.versionNumber
|
||
) {
|
||
return false
|
||
}
|
||
const version = Array.isArray(project.versions)
|
||
? project.versions.find((item: any) => item?.id === expected.versionId)
|
||
: null
|
||
if (
|
||
!version
|
||
|| version.projectId !== expected.projectId
|
||
|| version.version !== expected.versionNumber
|
||
|| version.status !== VERSION_STATUS.PENDING_REVIEW
|
||
|| !version.submittedAt
|
||
) {
|
||
return false
|
||
}
|
||
|
||
const actualImages = serializeWorkReviewImagePayload(version.imageUrls)
|
||
const expectedImages = serializeWorkReviewImagePayload(
|
||
buildWorkReviewImagePayload(expected.imageGroups, expected.referenceImageGroups),
|
||
)
|
||
return (
|
||
JSON.stringify(actualImages.imageGroups) === JSON.stringify(expectedImages.imageGroups)
|
||
&& JSON.stringify(actualImages.referenceImageGroups)
|
||
=== JSON.stringify(expectedImages.referenceImageGroups)
|
||
)
|
||
}
|
||
|
||
function assertCompleteCommittedWorkReviewVersion(
|
||
project: any,
|
||
expected: Parameters<typeof hasCompleteCommittedWorkReviewVersion>[1],
|
||
) {
|
||
if (!hasCompleteCommittedWorkReviewVersion(project, expected)) {
|
||
throw new Error('作品落库记录与本次提交的项目、版本或完整图片组不一致')
|
||
}
|
||
}
|
||
|
||
async function queueSubmittedNotifications(project: any, version: any) {
|
||
const actionUrl = getWorkReviewActionUrl(project.id)
|
||
const isInitialVersion = Number(version.version) === 1
|
||
const submittedDate = isInitialVersion
|
||
? new Date(project.workDate).toISOString().slice(0, 10)
|
||
: new Intl.DateTimeFormat('sv-SE', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
}).format(new Date(version.submittedAt || Date.now()))
|
||
const content = [
|
||
'### ◆ 作品待评审',
|
||
'',
|
||
`**${escapeDingtalkMarkdown(project.title)}**`,
|
||
'',
|
||
`**提交人:** ${escapeDingtalkMarkdown(project.submitterName)} `,
|
||
`**${isInitialVersion ? '作品日期' : '修改提交日期'}:** ${submittedDate} `,
|
||
`**版本:** V${version.version} `,
|
||
'',
|
||
'> 请逐张查看作品图片并填写评审修改建议。',
|
||
].join('\n')
|
||
|
||
const notifications = [
|
||
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,
|
||
}),
|
||
]
|
||
if (getRequiredReviewRoles(project).includes(REVIEW_ROLES.FINAL_REVIEWER)) {
|
||
notifications.push(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,
|
||
}))
|
||
}
|
||
return Promise.all(notifications)
|
||
}
|
||
|
||
async function queueCompletedNotification(project: any, version: any) {
|
||
const approved = version.status === VERSION_STATUS.APPROVED
|
||
const singleReviewerTest = getWorkReviewReviewMode(project) === 'single_reviewer_test'
|
||
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
|
||
? (singleReviewerTest ? '指定评审人已通过' : '指定评审人与元泽均已通过')
|
||
: '请按评审建议修改后再次提交'} `,
|
||
'',
|
||
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)
|
||
}
|
||
|
||
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 brand = clean(body?.brand)
|
||
if (!brand) throw new WorkReviewApiError('请填写品牌')
|
||
if (brand.length > 80) throw new WorkReviewApiError('品牌不能超过 80 个字')
|
||
const description = clean(body?.description)
|
||
const submissionNote = clean(body?.submissionNote)
|
||
const workDate = parseWorkDate(body?.workDate)
|
||
const finalReviewer = await resolveFinalReviewer()
|
||
const manager = await resolveSelectedReviewer(
|
||
body?.reviewerEmployeeId,
|
||
ctx.employee,
|
||
ctx.user.id,
|
||
finalReviewer,
|
||
)
|
||
const projectId = randomUUID()
|
||
const versionId = randomUUID()
|
||
|
||
const created = await withStoredImageSets({
|
||
userId: ctx.user.id,
|
||
projectId,
|
||
versionId,
|
||
processedImages: body?.processedImages,
|
||
processedReferenceImages: body?.processedReferenceImages,
|
||
task: async (imageGroups, referenceImageGroups) => prisma.workReviewProject.create({
|
||
data: {
|
||
id: projectId,
|
||
title,
|
||
brand,
|
||
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: buildWorkReviewImagePayload(imageGroups, referenceImageGroups) as any,
|
||
},
|
||
},
|
||
},
|
||
include: {
|
||
submitterEmployee: true,
|
||
versions: { include: { reviews: true } },
|
||
},
|
||
}),
|
||
confirmCommitted: async (imageGroups, referenceImageGroups) => {
|
||
const persisted = await prisma.workReviewProject.findUnique({
|
||
where: { id: projectId },
|
||
include: {
|
||
submitterEmployee: true,
|
||
versions: {
|
||
include: { reviews: true },
|
||
orderBy: { version: 'desc' },
|
||
},
|
||
},
|
||
})
|
||
if (!persisted) return null
|
||
assertCompleteCommittedWorkReviewVersion(persisted, {
|
||
projectId,
|
||
versionId,
|
||
submitterUserId: ctx.user.id,
|
||
versionNumber: 1,
|
||
imageGroups,
|
||
referenceImageGroups,
|
||
})
|
||
return persisted
|
||
},
|
||
})
|
||
|
||
const createdVersion = created.versions.find((item: any) => item.id === versionId)
|
||
if (!createdVersion) throw new WorkReviewApiError('作品版本创建失败', 500)
|
||
await queueNotificationsSafely(() => queueSubmittedNotifications(created, createdVersion))
|
||
return serializeProject(ctx, created)
|
||
}
|
||
|
||
export async function createWorkReviewVersion(session: Session | null, projectId: string, body: any) {
|
||
const ctx = await getContext(session)
|
||
ensureCanSubmit(ctx)
|
||
const expectedLatestVersion = Number(body?.expectedLatestVersion)
|
||
if (!Number.isInteger(expectedLatestVersion) || expectedLatestVersion < 1) {
|
||
throw new WorkReviewApiError('修改版本信息无效,请刷新后重试')
|
||
}
|
||
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.latestVersion !== expectedLatestVersion) {
|
||
throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409)
|
||
}
|
||
if (existing.status !== VERSION_STATUS.REVISION_REQUIRED) {
|
||
throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409)
|
||
}
|
||
|
||
const versionId = randomUUID()
|
||
const submissionNote = clean(body?.submissionNote)
|
||
const expectedVersionNumber = expectedLatestVersion + 1
|
||
const updated = await withStoredImageSets({
|
||
userId: ctx.user.id,
|
||
projectId,
|
||
versionId,
|
||
processedImages: body?.processedImages,
|
||
processedReferenceImages: body?.processedReferenceImages,
|
||
task: async (imageGroups, referenceImageGroups) => 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)
|
||
}
|
||
if (locked[0].latest_version !== expectedLatestVersion) {
|
||
throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409)
|
||
}
|
||
const version = locked[0].latest_version + 1
|
||
await tx.workReviewVersion.create({
|
||
data: {
|
||
id: versionId,
|
||
projectId,
|
||
version,
|
||
submissionNote,
|
||
imageUrls: buildWorkReviewImagePayload(imageGroups, referenceImageGroups) 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' },
|
||
},
|
||
},
|
||
})
|
||
}),
|
||
confirmCommitted: async (imageGroups, referenceImageGroups) => {
|
||
const persisted = await prisma.workReviewProject.findUnique({
|
||
where: { id: projectId },
|
||
include: {
|
||
submitterEmployee: true,
|
||
versions: {
|
||
include: { reviews: true },
|
||
orderBy: { version: 'desc' },
|
||
},
|
||
},
|
||
})
|
||
if (!persisted) return null
|
||
const persistedVersion = Array.isArray(persisted.versions)
|
||
? persisted.versions.find((item: any) => item?.id === versionId)
|
||
: null
|
||
if (!persistedVersion) return null
|
||
assertCompleteCommittedWorkReviewVersion(persisted, {
|
||
projectId,
|
||
versionId,
|
||
submitterUserId: ctx.user.id,
|
||
versionNumber: expectedVersionNumber,
|
||
imageGroups,
|
||
referenceImageGroups,
|
||
})
|
||
return persisted
|
||
},
|
||
})
|
||
|
||
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)
|
||
}
|
||
|
||
let annotationCount = 0
|
||
return imageGroups.map((group, imageIndex): ImageCommentInput & {
|
||
annotations: NormalizedWorkReviewAnnotation[]
|
||
} => {
|
||
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 个字')
|
||
const annotationResult = validateWorkReviewAnnotations(item.annotations)
|
||
if (!annotationResult.ok) throw new WorkReviewApiError(annotationResult.error)
|
||
annotationCount += annotationResult.annotations.length
|
||
if (annotationCount > 200) throw new WorkReviewApiError('单次评审最多添加 200 个批注')
|
||
return {
|
||
imageId: group.imageId || `image-${imageIndex + 1}`,
|
||
imageIndex,
|
||
sourceName: group.sourceName,
|
||
decision,
|
||
comment,
|
||
annotations: annotationResult.annotations,
|
||
}
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
const requiredReviewRoles = getRequiredReviewRoles(version.project)
|
||
if (!requiredReviewRoles.includes(role)) {
|
||
throw new WorkReviewApiError('当前测试流程已跳过元泽评审', 409)
|
||
}
|
||
if (!canReviewRole(ctx, version.project, role)) {
|
||
throw new WorkReviewApiError(role === REVIEW_ROLES.MANAGER ? '无权填写指定评审人区域' : '无权填写元泽评审', 403)
|
||
}
|
||
|
||
const imageGroups = serializeWorkReviewImagePayload(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'
|
||
}>, requiredReviewRoles)
|
||
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)
|
||
}
|