diff options
Diffstat (limited to 'lib/evaluation-submit/service.ts')
| -rw-r--r-- | lib/evaluation-submit/service.ts | 385 |
1 files changed, 341 insertions, 44 deletions
diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts index 84d356e7..99c5cb5e 100644 --- a/lib/evaluation-submit/service.ts +++ b/lib/evaluation-submit/service.ts @@ -12,11 +12,14 @@ import { evaluationTargetReviewers, evaluationTargets, regEvalCriteria, - periodicEvaluations + periodicEvaluations, + reviewerEvaluationAttachments, + users } from "@/db/schema"; import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; -import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, EvaluationQuestionItem, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; +import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; +import { AttachmentInfo, EvaluationQuestionItem } from "@/types/evaluation-form"; // =============================================================================== @@ -128,7 +131,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType); // 2. 부서에 따른 카테고리 필터링 로직 - // const categoryFilter = getCategoryFilterByDepartment("admin"); const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode); // 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회 @@ -179,15 +181,90 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi updatedAt: reviewerEvaluationDetails.updatedAt, }) .from(reviewerEvaluationDetails) - .where( - and( - eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), - // ✅ null이 아닌 실제 응답만 조회 - isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId) - ) - ); + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)); + + // 📎 5. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + // 첨부파일 정보 + attachmentId: reviewerEvaluationAttachments.id, + originalFileName: reviewerEvaluationAttachments.originalFileName, + storedFileName: reviewerEvaluationAttachments.storedFileName, + publicPath: reviewerEvaluationAttachments.publicPath, + fileSize: reviewerEvaluationAttachments.fileSize, + mimeType: reviewerEvaluationAttachments.mimeType, + fileExtension: reviewerEvaluationAttachments.fileExtension, + description: reviewerEvaluationAttachments.description, + uploadedBy: reviewerEvaluationAttachments.uploadedBy, + attachmentCreatedAt: reviewerEvaluationAttachments.createdAt, + attachmentUpdatedAt: reviewerEvaluationAttachments.updatedAt, + + // 업로드한 사용자 정보 + uploadedByName: users.name, + + // 평가 세부사항 정보 (어떤 질문에 대한 첨부파일인지 확인) + evaluationDetailId: reviewerEvaluationDetails.id, + regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + + // 평가 기준 정보 (질문 식별용) + criteriaId: regEvalCriteriaDetails.criteriaId, + category: regEvalCriteria.category, + }) + .from(reviewerEvaluationAttachments) + .innerJoin( + reviewerEvaluationDetails, + eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id) + ) + .leftJoin( + regEvalCriteriaDetails, + eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id) + ) + .leftJoin( + regEvalCriteria, + eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id) + ) + .leftJoin( + users, + eq(reviewerEvaluationAttachments.uploadedBy, users.id) + ) + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)) + .orderBy(desc(reviewerEvaluationAttachments.createdAt)); + + // 📎 6. 첨부파일을 질문별로 그룹화 + const attachmentsByQuestion = new Map<number, AttachmentInfo[]>(); + const attachmentsByCategory = new Map<string, number>(); + + attachmentsData.forEach(attachment => { + // Variable 타입 질문의 경우 criteriaId가 null일 수 있으므로 처리 + const questionKey = attachment.criteriaId || attachment.evaluationDetailId; + + if (!attachmentsByQuestion.has(questionKey)) { + attachmentsByQuestion.set(questionKey, []); + } + + const attachmentInfo: AttachmentInfo = { + id: attachment.attachmentId, + originalFileName: attachment.originalFileName, + storedFileName: attachment.storedFileName, + publicPath: attachment.publicPath, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType || undefined, + fileExtension: attachment.fileExtension || undefined, + description: attachment.description || undefined, + uploadedBy: attachment.uploadedBy, + uploadedByName: attachment.uploadedByName || undefined, + createdAt: new Date(attachment.attachmentCreatedAt), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }; + + attachmentsByQuestion.get(questionKey)!.push(attachmentInfo); + + // 카테고리별 파일 수 집계 + const category = attachment.category || '기타'; + attachmentsByCategory.set(category, (attachmentsByCategory.get(category) || 0) + 1); + }); - // 5. 질문별로 그룹화하고 답변 옵션들 정리 + // 7. 질문별로 그룹화하고 답변 옵션들 정리 const questionsMap = new Map<number, EvaluationQuestionItem>(); criteriaWithDetails.forEach(record => { @@ -201,6 +278,8 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi // 질문이 이미 존재하는지 확인 if (!questionsMap.has(criteriaId)) { + const questionAttachments = attachmentsByQuestion.get(criteriaId) || []; + questionsMap.set(criteriaId, { criteriaId: record.criteriaId, category: record.category, @@ -215,6 +294,11 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음) currentScore: null, currentComment: null, + + // 📎 첨부파일 정보 추가 + attachments: questionAttachments, + attachmentCount: questionAttachments.length, + attachmentTotalSize: questionAttachments.reduce((sum, att) => sum + att.fileSize, 0), }); } @@ -228,14 +312,45 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi }); }); - // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성 + // 8. ✅ Variable 타입 질문 처리 (첨부파일은 있지만 criteriaId가 null인 경우) + const variableTypeAttachments = new Map<number, AttachmentInfo[]>(); + attachmentsData.forEach(attachment => { + if (!attachment.criteriaId && attachment.regEvalCriteriaDetailsId === null) { + // Variable 타입 질문의 첨부파일 + if (!variableTypeAttachments.has(attachment.evaluationDetailId)) { + variableTypeAttachments.set(attachment.evaluationDetailId, []); + } + variableTypeAttachments.get(attachment.evaluationDetailId)!.push({ + id: attachment.attachmentId, + originalFileName: attachment.originalFileName, + storedFileName: attachment.storedFileName, + publicPath: attachment.publicPath, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType || undefined, + fileExtension: attachment.fileExtension || undefined, + description: attachment.description || undefined, + uploadedBy: attachment.uploadedBy, + uploadedByName: attachment.uploadedByName || undefined, + createdAt: new Date(attachment.attachmentCreatedAt), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }); + } + }); - // 7. 기존 응답 데이터를 질문에 매핑 - const existingResponsesMap = new Map( - existingResponses.map(r => [r.regEvalCriteriaDetailsId, r]) - ); + // 9. 기존 응답 데이터를 질문에 매핑 + const existingResponsesMap = new Map<number | null, typeof existingResponses[0]>(); + const responseDetailMap = new Map<number, typeof existingResponses[0]>(); - // 8. 각 질문에 현재 응답 정보 매핑 + existingResponses.forEach(response => { + if (response.regEvalCriteriaDetailsId) { + existingResponsesMap.set(response.regEvalCriteriaDetailsId, response); + } else { + // Variable 타입 응답 (regEvalCriteriaDetailsId가 null) + responseDetailMap.set(response.id, response); + } + }); + + // 10. 각 질문에 현재 응답 정보 매핑 const questions: EvaluationQuestionItem[] = []; questionsMap.forEach(question => { // 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만) @@ -251,7 +366,7 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi if (selectedResponse) { question.responseId = selectedResponse.id; - question.currentScore = selectedResponse.score; + question.currentScore = selectedResponse.score ? Number(selectedResponse.score) : null; question.currentComment = selectedResponse.comment; } // ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null) @@ -259,12 +374,21 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi questions.push(question); }); + // 📎 11. 전체 첨부파일 통계 계산 + const attachmentStats = { + totalFiles: attachmentsData.length, + totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0), + questionsWithAttachments: attachmentsByQuestion.size + variableTypeAttachments.size, + filesByCategory: Object.fromEntries(attachmentsByCategory), + }; + return { evaluationInfo: { ...evaluation, reviewerType }, questions, + attachmentStats, }; } catch (err) { @@ -274,7 +398,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi } - /** * 평가 제출 목록을 조회합니다 */ @@ -395,22 +518,33 @@ export async function getSHIEvaluationSubmissionById(id: number) { /** * 평가 응답을 업데이트합니다 */ +// 기존 updateEvaluationResponse 함수를 확장하여 첨부파일 처리 지원 + export async function updateEvaluationResponse( reviewerEvaluationId: number, selectedDetailId: number, - comment?: string + comment?: string, + customScore?: number ) { try { - await db.transaction(async (tx) => { - // 1. 선택된 답변 옵션의 정보 조회 - const selectedDetail = await tx - .select() - .from(regEvalCriteriaDetails) - .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) - .limit(1); + let reviewerEvaluationDetailId: number | null = null; - if (selectedDetail.length === 0) { - throw new Error('Selected detail not found'); + await db.transaction(async (tx) => { + // 1. 선택된 답변 옵션의 정보 조회 (variable 타입이 아닌 경우) + let selectedDetail = null; + let score: number; + + if (selectedDetailId !== -1) { + const detailResult = await tx + .select() + .from(regEvalCriteriaDetails) + .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) + .limit(1); + + if (detailResult.length === 0) { + throw new Error('Selected detail not found'); + } + selectedDetail = detailResult[0]; } // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함) @@ -447,20 +581,37 @@ export async function updateEvaluationResponse( .where(eq(periodicEvaluations.id, periodicEvaluationId)); } - // 4. 리뷰어 타입 정보 조회 - const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); - if (!evaluationInfo) { - throw new Error('Evaluation not found'); + // 4. 점수 결정 + if (selectedDetailId === -1) { + // variable 타입인 경우 customScore 사용 + if (customScore === undefined) { + throw new Error('Custom score is required for variable type'); + } + score = customScore; + } else { + // 일반 타입인 경우 리뷰어 타입에 맞는 점수 가져오기 + const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); + if (!evaluationInfo) { + throw new Error('Evaluation not found'); + } + + const calculatedScore = getScoreByReviewerType(selectedDetail!, evaluationInfo.evaluationInfo.reviewerType); + if (calculatedScore === null) { + throw new Error('Score not found for this reviewer type'); + } + score = calculatedScore; } - // 5. 해당 리뷰어 타입에 맞는 점수 가져오기 - const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType); - if (score === null) { - throw new Error('Score not found for this reviewer type'); + // 5. 해당 평가 기준에 대한 기존 응답들 삭제 + let criteriaId: number; + if (selectedDetailId === -1) { + // variable 타입인 경우, criteriaId를 별도로 조회해야 함 + // 이 부분은 실제 데이터 구조에 따라 조정 필요 + throw new Error('Variable type criteria ID lookup not implemented'); + } else { + criteriaId = selectedDetail!.criteriaId; } - // 6. 같은 질문에 대한 기존 응답들 삭제 - const criteriaId = selectedDetail[0].criteriaId; await tx .delete(reviewerEvaluationDetails) .where( @@ -472,27 +623,173 @@ export async function updateEvaluationResponse( ) ); - // 7. 새로운 응답 생성 - await tx + // 6. 새로운 응답 생성 + const [newDetail] = await tx .insert(reviewerEvaluationDetails) .values({ reviewerEvaluationId, - regEvalCriteriaDetailsId: selectedDetailId, + regEvalCriteriaDetailsId: selectedDetailId === -1 ? null : selectedDetailId, score: score.toString(), comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, }); - // 8. 카테고리별 점수 계산 및 총점 업데이트 + reviewerEvaluationDetailId = newDetail.id; + + // 7. 카테고리별 점수 계산 및 총점 업데이트 await recalculateEvaluationScores(tx, reviewerEvaluationId); }); - return { success: true }; + return { + success: true, + reviewerEvaluationDetailId + }; } catch (err) { console.error('Error in updateEvaluationResponse:', err); throw err; } } +// variable 타입을 위한 별도 함수 +export async function updateVariableEvaluationResponse( + reviewerEvaluationId: number, + criteriaId: number, + score: number, + comment?: string +) { + try { + let reviewerEvaluationDetailId: number | null = null; + + await db.transaction(async (tx) => { + // 1. reviewerEvaluation 정보 조회 + const reviewerEvaluationInfo = await tx + .select({ + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + }) + .from(reviewerEvaluations) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)) + .limit(1); + + if (reviewerEvaluationInfo.length === 0) { + throw new Error('Reviewer evaluation not found'); + } + + const { periodicEvaluationId } = reviewerEvaluationInfo[0]; + + // 2. 상태 업데이트 + const currentStatus = await tx + .select({ + status: periodicEvaluations.status, + }) + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .limit(1); + + if (currentStatus.length > 0 && currentStatus[0].status !== "IN_REVIEW") { + await tx + .update(periodicEvaluations) + .set({ + status: "IN_REVIEW", + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, periodicEvaluationId)); + } + + // 3. 해당 평가 기준에 대한 기존 응답들 삭제 + await tx + .delete(reviewerEvaluationDetails) + .where( + and( + eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), + sql`${reviewerEvaluationDetails.regEvalCriteriaDetailsId} IN ( + SELECT id FROM reg_eval_criteria_details WHERE criteria_id = ${criteriaId} + )` + ) + ); + + // 4. 새로운 응답 생성 (variable 타입은 regEvalCriteriaDetailsId가 null) + const [newDetail] = await tx + .insert(reviewerEvaluationDetails) + .values({ + reviewerEvaluationId, + regEvalCriteriaDetailsId: null, // variable 타입은 null + score: score.toString(), + comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, + }); + + reviewerEvaluationDetailId = newDetail.id; + + // 5. 점수 재계산 + await recalculateEvaluationScores(tx, reviewerEvaluationId); + }); + + return { + success: true, + reviewerEvaluationDetailId + }; + } catch (err) { + console.error('Error in updateVariableEvaluationResponse:', err); + throw err; + } +} + +// 첨부파일과 함께 평가 응답 업데이트 +// export async function updateEvaluationResponseWithAttachment( +// reviewerEvaluationId: number, +// selectedDetailId: number, +// comment?: string, +// customScore?: number, +// attachmentFile?: File, +// attachmentDescription?: string +// ) { +// try { +// // 1. 먼저 평가 응답 업데이트 +// const updateResult = selectedDetailId === -1 && customScore !== undefined ? +// await updateVariableEvaluationResponse(reviewerEvaluationId, /* criteriaId 필요 */, customScore, comment) : +// await updateEvaluationResponse(reviewerEvaluationId, selectedDetailId, comment, customScore); + +// if (!updateResult.success || !updateResult.reviewerEvaluationDetailId) { +// throw new Error('Failed to update evaluation response'); +// } + +// // 2. 첨부파일이 있으면 저장 +// if (attachmentFile) { +// const fileResult = await saveFile({ +// file: attachmentFile, +// directory: "evaluation-attachments", +// originalName: attachmentFile.name, +// }); + +// if (!fileResult.success) { +// throw new Error(fileResult.error || "파일 저장에 실패했습니다."); +// } + +// // 3. DB에 첨부파일 정보 저장 +// await db.insert(reviewerEvaluationAttachments).values({ +// reviewerEvaluationDetailId: updateResult.reviewerEvaluationDetailId, +// originalFileName: attachmentFile.name, +// storedFileName: fileResult.fileName!, +// filePath: fileResult.filePath!, +// publicPath: fileResult.publicPath!, +// fileSize: attachmentFile.size, +// mimeType: attachmentFile.type, +// fileExtension: attachmentFile.name.split('.').pop()?.toLowerCase() || '', +// description: attachmentDescription || null, +// uploadedBy: /* session.user.id 필요 */, +// }); +// } + +// return { success: true }; +// } catch (err) { +// console.error('Error in updateEvaluationResponseWithAttachment:', err); +// throw err; +// } +// } /** * 평가 점수 재계산 |
