Эх сурвалжийг харах

feat: add multi-level OKR hierarchy with tree, orphans, and progress recalculation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wangkangyjy 2 сар өмнө
parent
commit
bedf38775c

+ 6 - 0
backend/src/main/java/com/yimeng/okr/config/DatabaseInitializer.java

@@ -63,6 +63,12 @@ public class DatabaseInitializer implements CommandLineRunner {
             } catch (Exception ignored) {
                 // column already exists
             }
+
+            // Migration: multi-level OKR hierarchy
+            try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN level VARCHAR(16) NOT NULL DEFAULT 'INDIVIDUAL'"); } catch (Exception ignored) {}
+            try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN parent_objective_id INTEGER DEFAULT NULL"); } catch (Exception ignored) {}
+            try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN sort_order INTEGER DEFAULT 0"); } catch (Exception ignored) {}
+            try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN progress REAL DEFAULT 0"); } catch (Exception ignored) {}
         }
     }
 }

+ 59 - 0
backend/src/main/java/com/yimeng/okr/controller/OkrController.java

@@ -1,10 +1,14 @@
 package com.yimeng.okr.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.yimeng.okr.dto.ApiResult;
 import com.yimeng.okr.dto.OkrDetailDto;
 import com.yimeng.okr.entity.KrKeyResult;
 import com.yimeng.okr.entity.KrProgressLog;
+import com.yimeng.okr.entity.OkrObjective;
 import com.yimeng.okr.enums.KrStatus;
+import com.yimeng.okr.exception.BusinessException;
+import com.yimeng.okr.mapper.OkrObjectiveMapper;
 import com.yimeng.okr.security.SecurityUtils;
 import com.yimeng.okr.service.OkrService;
 import lombok.RequiredArgsConstructor;
@@ -20,6 +24,7 @@ import java.util.Map;
 public class OkrController {
 
     private final OkrService okrService;
+    private final OkrObjectiveMapper objectiveMapper;
 
     @PostMapping("/submit")
     public ApiResult<?> submitOkr(@RequestBody Map<String, Object> body) {
@@ -139,4 +144,58 @@ public class OkrController {
     public ApiResult<OkrDetailDto> getDetail(@PathVariable Long id) {
         return ApiResult.success(okrService.getOkrDetail(id));
     }
+
+    @PostMapping("/submit-hierarchy")
+    public ApiResult<?> submitHierarchyOkr(@RequestBody Map<String, Object> body) {
+        Long periodId = Long.valueOf(body.get("periodId").toString());
+        Long userId = SecurityUtils.getCurrentUserId();
+        String title = body.get("title").toString();
+        String level = body.getOrDefault("level", "INDIVIDUAL").toString();
+        Long parentObjectiveId = body.get("parentObjectiveId") != null
+                ? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> krList = (List<Map<String, Object>>) body.get("keyResults");
+        List<KrKeyResult> krs = krList.stream().map(m -> {
+            KrKeyResult kr = new KrKeyResult();
+            kr.setTitle(m.get("title").toString());
+            kr.setTargetValue(Double.valueOf(m.get("targetValue").toString()));
+            kr.setWeight(m.get("weight") != null ? Integer.valueOf(m.get("weight").toString()) : 0);
+            return kr;
+        }).toList();
+        List<Long> alignedIds = new ArrayList<>();
+        Object ao = body.get("alignedObjectiveIds");
+        if (ao instanceof List<?> list) {
+            for (Object item : list) alignedIds.add(Long.valueOf(item.toString()));
+        }
+        return ApiResult.success(okrService.submitHierarchyOkr(periodId, userId, title, krs, level, parentObjectiveId, alignedIds));
+    }
+
+    @GetMapping("/period/{periodId}/tree")
+    public ApiResult<List<OkrDetailDto>> getOkrTree(@PathVariable Long periodId) {
+        return ApiResult.success(okrService.getOkrTree(periodId));
+    }
+
+    @GetMapping("/period/{periodId}/orphans")
+    public ApiResult<List<OkrDetailDto>> getOrphanOkrs(@PathVariable Long periodId) {
+        return ApiResult.success(okrService.getOrphanOkrs(periodId));
+    }
+
+    @PostMapping("/{id}/align")
+    public ApiResult<Void> setAlignment(@PathVariable Long id, @RequestBody Map<String, Object> body) {
+        OkrObjective obj = objectiveMapper.selectById(id);
+        if (obj == null) throw new BusinessException(404, "OKR不存在");
+        Long parentId = body.get("parentObjectiveId") != null
+                ? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
+        obj.setParentObjectiveId(parentId);
+        objectiveMapper.updateById(obj);
+        return ApiResult.success();
+    }
+
+    @GetMapping("/{id}/children")
+    public ApiResult<List<OkrDetailDto>> getChildren(@PathVariable Long id) {
+        List<OkrObjective> children = objectiveMapper.selectList(
+                new LambdaQueryWrapper<OkrObjective>()
+                        .eq(OkrObjective::getParentObjectiveId, id));
+        return ApiResult.success(okrService.enrichObjectives(children));
+    }
 }

+ 11 - 0
backend/src/main/java/com/yimeng/okr/dto/OkrDetailDto.java

@@ -27,6 +27,13 @@ public class OkrDetailDto {
     private LocalDateTime reviewedAt;
     private LocalDateTime createdAt;
 
+    private String level;
+    private Long parentObjectiveId;
+    private String parentObjectiveTitle;
+    private Integer sortOrder;
+    private Double progress;
+    private List<OkrDetailDto> children;
+
     @Data
     public static class KrWithProgress {
         private Long id;
@@ -56,6 +63,10 @@ public class OkrDetailDto {
         dto.setReviewedBy(obj.getReviewedBy());
         dto.setReviewedAt(obj.getReviewedAt());
         dto.setCreatedAt(obj.getCreatedAt());
+        dto.setLevel(obj.getLevel());
+        dto.setParentObjectiveId(obj.getParentObjectiveId());
+        dto.setSortOrder(obj.getSortOrder());
+        dto.setProgress(obj.getProgress());
         dto.setKeyResults(krs);
         return dto;
     }

+ 5 - 0
backend/src/main/java/com/yimeng/okr/entity/OkrObjective.java

@@ -24,6 +24,11 @@ public class OkrObjective {
     private Long reviewedBy;
     private LocalDateTime reviewedAt;
 
+    private String level;            // COMPANY / DEPARTMENT / TEAM / INDIVIDUAL
+    private Long parentObjectiveId;  // 对齐的上级目标ID
+    private Integer sortOrder;       // 同级排序
+    private Double progress;         // 汇总进度 0-100
+
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createdAt;
 

+ 126 - 1
backend/src/main/java/com/yimeng/okr/service/OkrService.java

@@ -6,9 +6,11 @@ import com.yimeng.okr.dto.OkrDetailDto.KrWithProgress;
 import com.yimeng.okr.entity.*;
 import com.yimeng.okr.enums.KrStatus;
 import com.yimeng.okr.enums.PeriodStatus;
+import com.yimeng.okr.enums.UserRole;
 import com.yimeng.okr.event.NotificationEvent;
 import com.yimeng.okr.exception.BusinessException;
 import com.yimeng.okr.mapper.*;
+import com.yimeng.okr.security.SecurityUtils;
 import lombok.RequiredArgsConstructor;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
@@ -128,6 +130,67 @@ public class OkrService {
         return obj;
     }
 
+    @Transactional
+    public OkrObjective submitHierarchyOkr(Long periodId, Long userId, String title,
+                                            List<KrKeyResult> krs, String level,
+                                            Long parentObjectiveId, List<Long> alignedObjectiveIds) {
+        AssessmentPeriod period = periodMapper.selectById(periodId);
+        if (period == null || period.getStatus() != PeriodStatus.OKR_ALIGN) {
+            throw new BusinessException("当前周期不在OKR对齐阶段");
+        }
+        SysUser user = userMapper.selectById(userId);
+        if (user == null) throw new BusinessException(404, "用户不存在");
+
+        if ("COMPANY".equals(level) && !SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
+            throw new BusinessException(403, "仅HR管理员可创建公司级OKR");
+        }
+        if ("DEPARTMENT".equals(level) && !SecurityUtils.hasRole(UserRole.DEPT_LEADER)) {
+            throw new BusinessException(403, "仅部门负责人可创建部门级OKR");
+        }
+
+        if ("INDIVIDUAL".equals(level)) {
+            if (krs == null || krs.size() < 2 || krs.size() > 5) {
+                throw new BusinessException("个人OKR需配置2~5条KR");
+            }
+        }
+
+        int weightSum = krs.stream().mapToInt(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
+        if (weightSum != 100) throw new BusinessException("KR权重之和必须为100");
+
+        if (parentObjectiveId != null) {
+            OkrObjective parent = objectiveMapper.selectById(parentObjectiveId);
+            if (parent == null) throw new BusinessException(404, "对齐目标不存在");
+        }
+
+        OkrObjective obj = new OkrObjective();
+        obj.setPeriodId(periodId);
+        obj.setUserId(userId);
+        obj.setDeptId(user.getDepartmentId());
+        obj.setTitle(title);
+        obj.setLevel(level);
+        obj.setParentObjectiveId(parentObjectiveId);
+        obj.setSortOrder(0);
+        obj.setProgress(0.0);
+        obj.setVersion(1);
+        obj.setStatus("INDIVIDUAL".equals(level) ? "SUBMITTED" : "APPROVED");
+        if (!"INDIVIDUAL".equals(level)) {
+            obj.setReviewedBy(userId);
+            obj.setReviewedAt(LocalDateTime.now());
+        }
+        if (alignedObjectiveIds != null && !alignedObjectiveIds.isEmpty()) {
+            obj.setAlignedObjectiveIds(alignedObjectiveIds.stream()
+                    .map(String::valueOf).collect(Collectors.joining(",")));
+        }
+        objectiveMapper.insert(obj);
+
+        for (KrKeyResult kr : krs) {
+            kr.setObjectiveId(obj.getId());
+            kr.setStatus(KrStatus.NOT_STARTED);
+            krMapper.insert(kr);
+        }
+        return obj;
+    }
+
     @Transactional
     public OkrObjective updateMyOkr(Long objectiveId, Long userId, String title,
                                       List<KrKeyResult> krs, List<Long> alignedObjectiveIds) {
@@ -226,6 +289,21 @@ public class OkrService {
         kr.setCurrentValue(newValue);
         kr.setStatus(newStatus);
         krMapper.updateById(kr);
+
+        // Recalculate O progress
+        List<KrKeyResult> allKrs = krMapper.selectList(
+                new LambdaQueryWrapper<KrKeyResult>().eq(KrKeyResult::getObjectiveId, kr.getObjectiveId()));
+        double totalWeight = allKrs.stream().mapToDouble(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
+        double weightedProgress = 0;
+        if (totalWeight > 0) {
+            for (KrKeyResult k : allKrs) {
+                double krProgress = k.getTargetValue() > 0
+                        ? (k.getCurrentValue() / k.getTargetValue()) * 100 : 0;
+                weightedProgress += krProgress * (k.getWeight() / totalWeight);
+            }
+        }
+        obj.setProgress(Math.min(100, Math.round(weightedProgress * 10.0) / 10.0));
+        objectiveMapper.updateById(obj);
     }
 
     // --- Queries ---
@@ -351,9 +429,43 @@ public class OkrService {
         return List.of(); // deprecated
     }
 
+    public List<OkrDetailDto> getOkrTree(Long periodId) {
+        List<OkrObjective> all = objectiveMapper.selectList(
+                new LambdaQueryWrapper<OkrObjective>().eq(OkrObjective::getPeriodId, periodId));
+        List<OkrDetailDto> enriched = enrichObjectives(all);
+
+        Map<Long, OkrDetailDto> map = enriched.stream()
+                .collect(Collectors.toMap(OkrDetailDto::getId, o -> o, (a, b) -> a));
+        List<OkrDetailDto> roots = new ArrayList<>();
+
+        for (OkrDetailDto dto : enriched) {
+            if (dto.getParentObjectiveId() == null || "COMPANY".equals(dto.getLevel())) {
+                roots.add(dto);
+            } else {
+                OkrDetailDto parent = map.get(dto.getParentObjectiveId());
+                if (parent != null) {
+                    if (parent.getChildren() == null) parent.setChildren(new ArrayList<>());
+                    parent.getChildren().add(dto);
+                } else {
+                    roots.add(dto);
+                }
+            }
+        }
+        return roots;
+    }
+
+    public List<OkrDetailDto> getOrphanOkrs(Long periodId) {
+        List<OkrObjective> individuals = objectiveMapper.selectList(
+                new LambdaQueryWrapper<OkrObjective>()
+                        .eq(OkrObjective::getPeriodId, periodId)
+                        .eq(OkrObjective::getLevel, "INDIVIDUAL")
+                        .isNull(OkrObjective::getParentObjectiveId));
+        return enrichObjectives(individuals);
+    }
+
     // --- Helper ---
 
-    private List<OkrDetailDto> enrichObjectives(List<OkrObjective> objectives) {
+    public List<OkrDetailDto> enrichObjectives(List<OkrObjective> objectives) {
         if (objectives.isEmpty()) return List.of();
         List<Long> objIds = objectives.stream().map(OkrObjective::getId).toList();
         Map<Long, List<KrKeyResult>> krMap = krMapper.selectList(
@@ -400,6 +512,16 @@ public class OkrService {
             }
         }
 
+        // Collect all parent objective IDs for title lookup
+        Set<Long> parentIds = objectives.stream()
+                .map(OkrObjective::getParentObjectiveId)
+                .filter(Objects::nonNull).collect(Collectors.toSet());
+        Map<Long, String> parentTitleMap = new HashMap<>();
+        if (!parentIds.isEmpty()) {
+            objectiveMapper.selectBatchIds(parentIds)
+                    .forEach(p -> parentTitleMap.put(p.getId(), p.getTitle()));
+        }
+
         List<OkrDetailDto> result = new ArrayList<>();
         for (OkrObjective obj : objectives) {
             List<KrKeyResult> krs = krMap.getOrDefault(obj.getId(), List.of());
@@ -432,6 +554,9 @@ public class OkrService {
             dto.setUserName(userNames.get(obj.getUserId()));
             dto.setDeptName(deptNames.get(obj.getDeptId()));
             if (obj.getReviewedBy() != null) dto.setReviewerName(userNames.get(obj.getReviewedBy()));
+            if (obj.getParentObjectiveId() != null) {
+                dto.setParentObjectiveTitle(parentTitleMap.get(obj.getParentObjectiveId()));
+            }
             if (obj.getAlignedObjectiveIds() != null && !obj.getAlignedObjectiveIds().isEmpty()) {
                 List<OkrDetailDto> aligned = Arrays.stream(obj.getAlignedObjectiveIds().split(","))
                         .map(String::trim).filter(s -> !s.isEmpty())