918 lines
31 KiB
TypeScript
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)
|
|
}
|