|
|
@@ -0,0 +1,243 @@
|
|
|
+package com.emoon.okr.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.emoon.okr.dto.PeerReviewDto;
|
|
|
+import com.emoon.okr.entity.AssessmentPeriod;
|
|
|
+import com.emoon.okr.entity.PeerReviewInvitation;
|
|
|
+import com.emoon.okr.entity.PeerReviewRequest;
|
|
|
+import com.emoon.okr.entity.PerformanceScore;
|
|
|
+import com.emoon.okr.entity.PeriodParticipant;
|
|
|
+import com.emoon.okr.entity.SysUser;
|
|
|
+import com.emoon.okr.enums.PeriodStatus;
|
|
|
+import com.emoon.okr.enums.ScoreType;
|
|
|
+import com.emoon.okr.exception.BusinessException;
|
|
|
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
|
|
|
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
|
|
|
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
|
|
|
+import com.emoon.okr.mapper.PerformanceScoreMapper;
|
|
|
+import com.emoon.okr.mapper.PeriodParticipantMapper;
|
|
|
+import com.emoon.okr.mapper.SysUserMapper;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Objects;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.function.Function;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class PeerReviewService {
|
|
|
+
|
|
|
+ private static final String ACTIVE = "ACTIVE";
|
|
|
+ private static final String REQUEST_PENDING_APPROVAL = "PENDING_APPROVAL";
|
|
|
+ private static final String INVITATION_PROPOSED = "PROPOSED";
|
|
|
+ private static final String INVITATION_DECLINED = "DECLINED";
|
|
|
+ private static final String INVITATION_SUBMITTED = "SUBMITTED";
|
|
|
+ private static final String SAME_DEPARTMENT = "SAME_DEPARTMENT";
|
|
|
+ private static final String CROSS_DEPARTMENT = "CROSS_DEPARTMENT";
|
|
|
+
|
|
|
+ private final AssessmentPeriodMapper periodMapper;
|
|
|
+ private final PeriodParticipantMapper participantMapper;
|
|
|
+ private final PerformanceScoreMapper scoreMapper;
|
|
|
+ private final PeerReviewRequestMapper requestMapper;
|
|
|
+ private final PeerReviewInvitationMapper invitationMapper;
|
|
|
+ private final SysUserMapper userMapper;
|
|
|
+
|
|
|
+ public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId) {
|
|
|
+ AssessmentPeriod period = requireAssessingPeriod(periodId);
|
|
|
+ requireInvitationOpen(period);
|
|
|
+ List<PeriodParticipant> participants = periodParticipants(periodId);
|
|
|
+ PeriodParticipant subject = requireActiveParticipant(participants, subjectUserId);
|
|
|
+
|
|
|
+ List<PeriodParticipant> eligible = participants.stream()
|
|
|
+ .filter(item -> isEligible(item, subject))
|
|
|
+ .toList();
|
|
|
+ Map<Long, SysUser> users = usersById(eligible.stream().map(PeriodParticipant::getUserId).toList());
|
|
|
+
|
|
|
+ return eligible.stream().map(item -> {
|
|
|
+ PeerReviewDto.Candidate candidate = new PeerReviewDto.Candidate();
|
|
|
+ candidate.setUserId(item.getUserId());
|
|
|
+ candidate.setRealName(users.containsKey(item.getUserId())
|
|
|
+ ? users.get(item.getUserId()).getRealName() : null);
|
|
|
+ candidate.setDepartmentId(item.getDepartmentIdSnapshot());
|
|
|
+ candidate.setRelationType(relationType(subject, item));
|
|
|
+ return candidate;
|
|
|
+ }).toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Transactional
|
|
|
+ public PeerReviewDto.EmployeeProgress submitReviewerList(Long periodId, Long subjectUserId,
|
|
|
+ List<Long> reviewerIds) {
|
|
|
+ AssessmentPeriod period = requireAssessingPeriod(periodId);
|
|
|
+ requireInvitationOpen(period);
|
|
|
+ List<PeriodParticipant> participants = periodParticipants(periodId);
|
|
|
+ PeriodParticipant subject = requireActiveParticipant(participants, subjectUserId);
|
|
|
+ requireSubmittedSelfScore(periodId, subjectUserId);
|
|
|
+
|
|
|
+ if (reviewerIds == null || reviewerIds.size() < 3 || reviewerIds.size() > 5) {
|
|
|
+ throw new BusinessException("评价人须为 3 至 5 人");
|
|
|
+ }
|
|
|
+ Set<Long> distinctIds = new HashSet<>(reviewerIds);
|
|
|
+ if (distinctIds.size() != reviewerIds.size()) {
|
|
|
+ throw new BusinessException("评价人名单不能重复");
|
|
|
+ }
|
|
|
+ if (requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
|
|
|
+ .eq(PeerReviewRequest::getPeriodId, periodId)
|
|
|
+ .eq(PeerReviewRequest::getSubjectUserId, subjectUserId)) != null) {
|
|
|
+ throw new BusinessException("本周期已提交评价人名单");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<Long, PeriodParticipant> byUserId = participants.stream().collect(Collectors.toMap(
|
|
|
+ PeriodParticipant::getUserId, Function.identity(), (left, right) -> left, LinkedHashMap::new));
|
|
|
+ List<PeriodParticipant> reviewers = new ArrayList<>();
|
|
|
+ for (Long reviewerId : reviewerIds) {
|
|
|
+ PeriodParticipant reviewer = byUserId.get(reviewerId);
|
|
|
+ if (Objects.equals(reviewerId, subjectUserId)
|
|
|
+ || Objects.equals(reviewerId, subject.getEvaluatorId())
|
|
|
+ || reviewer != null && Objects.equals(reviewer.getSuperiorIdSnapshot(), subjectUserId)) {
|
|
|
+ throw new BusinessException("评价人不能是本人、直属 Leader 或直属下属");
|
|
|
+ }
|
|
|
+ if (reviewer == null || !ACTIVE.equals(reviewer.getStatus())) {
|
|
|
+ throw new BusinessException("评价人必须是本周期有效参与人");
|
|
|
+ }
|
|
|
+ reviewers.add(reviewer);
|
|
|
+ }
|
|
|
+
|
|
|
+ boolean hasSameDepartment = reviewers.stream()
|
|
|
+ .anyMatch(item -> Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
|
|
|
+ boolean hasCrossDepartment = reviewers.stream()
|
|
|
+ .anyMatch(item -> !Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
|
|
|
+ if (!hasSameDepartment) {
|
|
|
+ throw new BusinessException("评价人名单至少包含 1 名同部门同事");
|
|
|
+ }
|
|
|
+ if (!hasCrossDepartment) {
|
|
|
+ throw new BusinessException("评价人名单至少包含 1 名跨部门同事");
|
|
|
+ }
|
|
|
+
|
|
|
+ LocalDateTime now = LocalDateTime.now();
|
|
|
+ PeerReviewRequest request = new PeerReviewRequest();
|
|
|
+ request.setPeriodId(periodId);
|
|
|
+ request.setSubjectUserId(subjectUserId);
|
|
|
+ request.setLeaderId(subject.getEvaluatorId());
|
|
|
+ request.setStatus(REQUEST_PENDING_APPROVAL);
|
|
|
+ request.setSubmittedAt(now);
|
|
|
+ requestMapper.insert(request);
|
|
|
+
|
|
|
+ List<PeerReviewInvitation> invitations = reviewers.stream().map(reviewer -> {
|
|
|
+ PeerReviewInvitation invitation = new PeerReviewInvitation();
|
|
|
+ invitation.setRequestId(request.getId());
|
|
|
+ invitation.setReviewerId(reviewer.getUserId());
|
|
|
+ invitation.setReviewerDepartmentIdSnapshot(reviewer.getDepartmentIdSnapshot());
|
|
|
+ invitation.setRelationTypeSnapshot(relationType(subject, reviewer));
|
|
|
+ invitation.setStatus(INVITATION_PROPOSED);
|
|
|
+ invitationMapper.insert(invitation);
|
|
|
+ return invitation;
|
|
|
+ }).toList();
|
|
|
+ return employeeProgress(request, invitations);
|
|
|
+ }
|
|
|
+
|
|
|
+ public PeerReviewDto.EmployeeProgress getEmployeeProgress(Long periodId, Long subjectUserId) {
|
|
|
+ PeerReviewRequest request = requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
|
|
|
+ .eq(PeerReviewRequest::getPeriodId, periodId)
|
|
|
+ .eq(PeerReviewRequest::getSubjectUserId, subjectUserId));
|
|
|
+ if (request == null) {
|
|
|
+ PeerReviewDto.EmployeeProgress progress = new PeerReviewDto.EmployeeProgress();
|
|
|
+ progress.setStatus("NOT_STARTED");
|
|
|
+ return progress;
|
|
|
+ }
|
|
|
+ List<PeerReviewInvitation> invitations = invitationMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<PeerReviewInvitation>()
|
|
|
+ .eq(PeerReviewInvitation::getRequestId, request.getId()));
|
|
|
+ return employeeProgress(request, invitations);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AssessmentPeriod requireAssessingPeriod(Long periodId) {
|
|
|
+ AssessmentPeriod period = periodMapper.selectById(periodId);
|
|
|
+ if (period == null || period.getStatus() != PeriodStatus.ASSESSING) {
|
|
|
+ throw new BusinessException("仅绩效考评阶段可发起 360 环评");
|
|
|
+ }
|
|
|
+ return period;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireInvitationOpen(AssessmentPeriod period) {
|
|
|
+ if (period.getPeerInvitationDeadline() != null
|
|
|
+ && LocalDateTime.now().isAfter(period.getPeerInvitationDeadline())) {
|
|
|
+ throw new BusinessException("评价人邀请已截止");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<PeriodParticipant> periodParticipants(Long periodId) {
|
|
|
+ return participantMapper.selectList(new LambdaQueryWrapper<PeriodParticipant>()
|
|
|
+ .eq(PeriodParticipant::getPeriodId, periodId));
|
|
|
+ }
|
|
|
+
|
|
|
+ private PeriodParticipant requireActiveParticipant(List<PeriodParticipant> participants, Long userId) {
|
|
|
+ return participants.stream()
|
|
|
+ .filter(item -> Objects.equals(item.getUserId(), userId) && ACTIVE.equals(item.getStatus()))
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new BusinessException("当前员工不是本周期有效参与人"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireSubmittedSelfScore(Long periodId, Long subjectUserId) {
|
|
|
+ PerformanceScore score = scoreMapper.selectOne(new LambdaQueryWrapper<PerformanceScore>()
|
|
|
+ .eq(PerformanceScore::getPeriodId, periodId)
|
|
|
+ .eq(PerformanceScore::getUserId, subjectUserId)
|
|
|
+ .eq(PerformanceScore::getType, ScoreType.SELF));
|
|
|
+ if (score == null || !"SELF_SUBMITTED".equals(score.getStatus())) {
|
|
|
+ throw new BusinessException("请先完成并提交自评");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isEligible(PeriodParticipant candidate, PeriodParticipant subject) {
|
|
|
+ return ACTIVE.equals(candidate.getStatus())
|
|
|
+ && !Objects.equals(candidate.getUserId(), subject.getUserId())
|
|
|
+ && !Objects.equals(candidate.getUserId(), subject.getEvaluatorId())
|
|
|
+ && !Objects.equals(candidate.getSuperiorIdSnapshot(), subject.getUserId());
|
|
|
+ }
|
|
|
+
|
|
|
+ private String relationType(PeriodParticipant subject, PeriodParticipant reviewer) {
|
|
|
+ return Objects.equals(subject.getDepartmentIdSnapshot(), reviewer.getDepartmentIdSnapshot())
|
|
|
+ ? SAME_DEPARTMENT : CROSS_DEPARTMENT;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<Long, SysUser> usersById(List<Long> userIds) {
|
|
|
+ if (userIds.isEmpty()) {
|
|
|
+ return Map.of();
|
|
|
+ }
|
|
|
+ return userMapper.selectBatchIds(userIds).stream()
|
|
|
+ .collect(Collectors.toMap(SysUser::getId, Function.identity()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private PeerReviewDto.EmployeeProgress employeeProgress(PeerReviewRequest request,
|
|
|
+ List<PeerReviewInvitation> invitations) {
|
|
|
+ Map<Long, SysUser> users = usersById(invitations.stream()
|
|
|
+ .map(PeerReviewInvitation::getReviewerId).toList());
|
|
|
+ PeerReviewDto.EmployeeProgress progress = new PeerReviewDto.EmployeeProgress();
|
|
|
+ progress.setRequestId(request.getId());
|
|
|
+ progress.setStatus(request.getStatus());
|
|
|
+ progress.setRejectionReason(request.getRejectionReason());
|
|
|
+ progress.setInvitedCount(invitations.size());
|
|
|
+ progress.setSubmittedCount((int) invitations.stream()
|
|
|
+ .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
|
|
|
+ progress.setInvitations(invitations.stream().map(item -> {
|
|
|
+ PeerReviewDto.InvitationSummary summary = new PeerReviewDto.InvitationSummary();
|
|
|
+ summary.setInvitationId(item.getId());
|
|
|
+ summary.setReviewerId(item.getReviewerId());
|
|
|
+ summary.setReviewerName(users.containsKey(item.getReviewerId())
|
|
|
+ ? users.get(item.getReviewerId()).getRealName() : null);
|
|
|
+ summary.setRelationType(item.getRelationTypeSnapshot());
|
|
|
+ summary.setStatus(INVITATION_DECLINED.equals(item.getStatus()) ? INVITATION_DECLINED : "IN_PROGRESS");
|
|
|
+ summary.setDeclineReason(item.getDeclineReason());
|
|
|
+ return summary;
|
|
|
+ }).toList());
|
|
|
+ return progress;
|
|
|
+ }
|
|
|
+}
|