diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java index 67917a2bd..37c335bb8 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java @@ -5,6 +5,7 @@ import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; import com.gdschongik.gdsc.domain.study.dto.response.StudyCurriculumResponse; import com.gdschongik.gdsc.domain.study.dto.response.StudyMentorAttendanceResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStatisticsResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -80,4 +81,11 @@ public ResponseEntity> getAttendanceNumbers( List response = mentorStudyDetailService.getAttendanceNumbers(studyId); return ResponseEntity.ok(response); } + + @Operation(summary = "스터디 통계 조회", description = "멘토가 자신의 스터디 출석률, 과제 제출률, 수료율에 대한 통계를 조회합니다. 휴강 주차는 계산에서 제외합니다.") + @GetMapping("/statistics") + public ResponseEntity getStudyStatistics(@RequestParam(name = "studyId") Long studyId) { + StudyStatisticsResponse response = mentorStudyDetailService.getStudyStatistics(studyId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java index 21c975f61..e9a433f5c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java @@ -1,15 +1,25 @@ package com.gdschongik.gdsc.domain.study.application; +import static com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus.SUCCESS; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.AssignmentHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.AttendanceRepository; import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; import com.gdschongik.gdsc.domain.study.domain.StudyDetail; import com.gdschongik.gdsc.domain.study.domain.StudyDetailValidator; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyValidator; import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; import com.gdschongik.gdsc.domain.study.dto.response.StudyCurriculumResponse; import com.gdschongik.gdsc.domain.study.dto.response.StudyMentorAttendanceResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStatisticsResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyWeekStatisticsResponse; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.util.MemberUtil; import java.time.LocalDate; @@ -27,6 +37,11 @@ public class MentorStudyDetailService { private final MemberUtil memberUtil; private final StudyDetailRepository studyDetailRepository; private final StudyDetailValidator studyDetailValidator; + private final StudyHistoryRepository studyHistoryRepository; + private final AttendanceRepository attendanceRepository; + private final AssignmentHistoryRepository assignmentHistoryRepository; + private final StudyValidator studyValidator; + private final StudyRepository studyRepository; @Transactional(readOnly = true) public List getWeeklyAssignments(Long studyId) { @@ -108,4 +123,83 @@ public List getAttendanceNumbers(Long studyId) { .limit(2) .toList(); } + + @Transactional(readOnly = true) + public StudyStatisticsResponse getStudyStatistics(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + List studyHistories = studyHistoryRepository.findAllByStudyId(studyId); + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); + studyValidator.validateStudyMentor(currentMember, study); + + long totalStudentCount = studyHistories.size(); + long studyCompletedStudentCount = + studyHistories.stream().filter(StudyHistory::isCompleted).count(); + + List studyWeekStatisticsResponses = studyDetails.stream() + .map((studyDetail -> calculateWeekStatistics(studyDetail, totalStudentCount))) + .toList(); + + long averageAttendanceRate = calculateAverageWeekAttendanceRate(studyWeekStatisticsResponses); + long averageAssignmentSubmissionRate = + calculateAverageWeekAssignmentSubmissionRate(studyWeekStatisticsResponses); + + return StudyStatisticsResponse.of( + totalStudentCount, + studyCompletedStudentCount, + averageAttendanceRate, + averageAssignmentSubmissionRate, + studyWeekStatisticsResponses); + } + + private StudyWeekStatisticsResponse calculateWeekStatistics(StudyDetail studyDetail, Long totalStudentCount) { + boolean isNotOpenedCurriculum = !studyDetail.getCurriculum().isOpen(); + boolean isNotOpenedAssignment = !studyDetail.getAssignment().isOpen() || isNotOpenedCurriculum; + + if (totalStudentCount == 0) { + return StudyWeekStatisticsResponse.empty( + studyDetail.getWeek(), isNotOpenedAssignment, isNotOpenedCurriculum); + } + + if (isNotOpenedCurriculum) { + return StudyWeekStatisticsResponse.canceledWeek(studyDetail.getWeek()); + } + + long attendanceCount = attendanceRepository.countByStudyDetailId(studyDetail.getId()); + long attendanceRate = Math.round(attendanceCount / (double) totalStudentCount * 100); + + if (isNotOpenedAssignment) { + return StudyWeekStatisticsResponse.assignmentCanceled(studyDetail.getWeek(), attendanceRate); + } + + long successfullySubmittedAssignmentCount = + assignmentHistoryRepository.countByStudyDetailIdAndSubmissionStatusEquals(studyDetail.getId(), SUCCESS); + long assignmentSubmissionRate = + Math.round(successfullySubmittedAssignmentCount / (double) totalStudentCount * 100); + + return StudyWeekStatisticsResponse.opened(studyDetail.getWeek(), attendanceRate, assignmentSubmissionRate); + } + + private long calculateAverageWeekAttendanceRate(List studyWeekStatisticsResponses) { + + double averageAttendanceRate = studyWeekStatisticsResponses.stream() + .filter(weekStatisticsResponse -> !weekStatisticsResponse.isCurriculumCanceled()) + .mapToLong(StudyWeekStatisticsResponse::attendanceRate) + .average() + .orElse(0); + + return Math.round(averageAttendanceRate); + } + + private long calculateAverageWeekAssignmentSubmissionRate( + List studyWeekStatisticsResponses) { + + double averageAssignmentSubmissionRate = studyWeekStatisticsResponses.stream() + .filter(studyWeekStatistics -> !studyWeekStatistics.isAssignmentCanceled()) + .mapToLong(StudyWeekStatisticsResponse::assignmentSubmissionRate) + .average() + .orElse(0); + + return Math.round(averageAssignmentSubmissionRate); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java index fd5463d3f..404d20682 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java @@ -2,6 +2,7 @@ import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus; import com.gdschongik.gdsc.domain.study.domain.StudyDetail; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,6 @@ public interface AssignmentHistoryRepository extends JpaRepository, AssignmentHistoryCustomRepository { Optional findByMemberAndStudyDetail(Member member, StudyDetail studyDetail); + + long countByStudyDetailIdAndSubmissionStatusEquals(Long studyDetailId, AssignmentSubmissionStatus status); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java index d259ab9cd..0379c6565 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java @@ -5,4 +5,6 @@ public interface AttendanceRepository extends JpaRepository, AttendanceCustomRepository { boolean existsByStudentIdAndStudyDetailId(Long studentId, Long studyDetailId); + + long countByStudyDetailId(Long studyDetailId); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java index b2ca81efc..9ac737f9d 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java @@ -82,4 +82,8 @@ public void complete() { public boolean isWithinApplicationAndCourse() { return study.isWithinApplicationAndCourse(); } + + public boolean isCompleted() { + return studyHistoryStatus == COMPLETED; + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStatisticsResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStatisticsResponse.java new file mode 100644 index 000000000..3b481edfe --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStatisticsResponse.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record StudyStatisticsResponse( + @Schema(description = "스터디 전체 수강생 수") Long totalStudentCount, + @Schema(description = "스터디 수료 수강생 수") Long completeStudentCount, + @Schema(description = "평균 출석률") Long averageAttendanceRate, + @Schema(description = "평균 과제 제출률") Long averageAssignmentSubmissionRate, + @Schema(description = "스터디 수료율") Long studyCompleteRate, + @Schema(description = "주차별 출석률 및 과제 제출률") List studyWeekStatisticsResponses) { + + public static StudyStatisticsResponse of( + Long totalStudentCount, + Long completeStudentCount, + Long averageAttendanceRate, + Long averageAssignmentSubmissionRate, + List studyWeekStatisticsResponses) { + return new StudyStatisticsResponse( + totalStudentCount, + completeStudentCount, + averageAttendanceRate, + averageAssignmentSubmissionRate, + totalStudentCount == 0 ? 0 : Math.round(completeStudentCount / (double) totalStudentCount * 100), + studyWeekStatisticsResponses); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyWeekStatisticsResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyWeekStatisticsResponse.java new file mode 100644 index 000000000..4f8448783 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyWeekStatisticsResponse.java @@ -0,0 +1,28 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StudyWeekStatisticsResponse( + @Schema(description = "스터디 주차") Long week, + @Schema(description = "출석률") Long attendanceRate, + @Schema(description = "과제 제출률") Long assignmentSubmissionRate, + @Schema(description = "과제 휴강 여부") boolean isAssignmentCanceled, + @Schema(description = "수업 휴강 여부") boolean isCurriculumCanceled) { + + public static StudyWeekStatisticsResponse opened(Long week, Long attendanceRate, Long assignmentSubmissionRate) { + return new StudyWeekStatisticsResponse(week, attendanceRate, assignmentSubmissionRate, false, false); + } + + public static StudyWeekStatisticsResponse empty( + Long week, boolean isAssignmentCanceled, boolean isCurriculumCanceled) { + return new StudyWeekStatisticsResponse(week, 0L, 0L, isAssignmentCanceled, isCurriculumCanceled); + } + + public static StudyWeekStatisticsResponse canceledWeek(Long week) { + return StudyWeekStatisticsResponse.empty(week, true, true); + } + + public static StudyWeekStatisticsResponse assignmentCanceled(Long week, Long attendanceRate) { + return new StudyWeekStatisticsResponse(week, attendanceRate, 0L, true, false); + } +}