From 9bd567a72ff36712da99eabd1fd08b9087dda46b Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 9 Jul 2026 12:43:16 +0800 Subject: [PATCH] feat(aihr): filter supervisor reviews by org scope --- .../aihr/controller/AihrMobileController.java | 6 +- .../aihr/service/AihrMobileSeedService.java | 4 + .../aihr/service/AihrPracticeSeedService.java | 50 ++++++--- .../service/AihrPracticeSeedServiceTest.java | 102 +++++++++++++++++- docs/PC管理端功能差异TODO.md | 5 +- frontend/src/api/aihr/mobile-admin.ts | 11 +- frontend/src/views/competency/growth.vue | 55 +++++++++- frontend/src/views/train/reviews.vue | 88 +++++++++++++-- 8 files changed, 281 insertions(+), 40 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 01d2c5d3..45ebe50d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -53,8 +53,10 @@ public class AihrMobileController { } @GetMapping("/practice/reviews") - public R> practiceReviews() { - return R.ok(mobileSeedService.practiceReviews(currentAppUsername())); + public R> practiceReviews(@RequestParam(required = false) String projectCode, + @RequestParam(required = false) String extPartyId, + @RequestParam(required = false) Integer limit) { + return R.ok(mobileSeedService.practiceReviews(currentAppUsername(), projectCode, extPartyId, limit)); } @GetMapping("/practice/reviews/{id}") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index 2e9f937a..f60b3fe4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -57,6 +57,10 @@ public class AihrMobileSeedService { return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5); } + public List practiceReviews(String supervisorExtPartyId, String projectCode, String extPartyId, Integer limit) { + return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit); + } + public ReviewDetailResponse practiceReview(Long id) { return practiceSeedService.reviewDetail(id); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 29036d4b..863e6592 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -478,37 +478,55 @@ public class AihrPracticeSeedService { } public List pendingReviewRecords(int limit) { - ensurePracticeTable(); - return jdbcTemplate.query(""" - SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id - FROM aihr_practice_session - WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' - ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC - LIMIT ? - """, this::mapRecord, TENANT_ID, normalizeLimit(limit)); + return pendingReviewRecords("", "", "", limit); } public List pendingReviewRecords(String supervisorExtPartyId, int limit) { + return pendingReviewRecords(supervisorExtPartyId, "", "", limit); + } + + public List pendingReviewRecords(String supervisorExtPartyId, String projectCode, String extPartyId, int limit) { TeamScope scope = teamScope(supervisorExtPartyId); - if (!scope.scoped()) { - return pendingReviewRecords(limit); + if (scope.scoped() && scope.extPartyIds().isEmpty()) { + return List.of(); } - if (scope.extPartyIds().isEmpty()) { + String project = isBlank(projectCode) ? "" : projectCode.trim(); + if (!project.isEmpty() && !orgSnapshotTableExists()) { return List.of(); } ensurePracticeTable(); List args = new ArrayList<>(); args.add(TENANT_ID); - args.addAll(scope.extPartyIds()); + StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'"); + String party = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (!party.isEmpty()) { + where.append(" AND ext_party_id = ?"); + args.add(party); + } + if (!project.isEmpty()) { + where.append(""" + AND ext_party_id IN ( + SELECT ext_party_id + FROM aihr_org_snapshot + WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active' + ) + """); + args.add(TENANT_ID); + args.add(project); + } + if (scope.scoped()) { + where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } args.add(normalizeLimit(limit)); - return jdbcTemplate.query(""" + String sql = """ SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id FROM aihr_practice_session - WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' - AND ext_party_id IN (%s) + """ + where + """ ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC LIMIT ? - """.formatted(inClause(scope.extPartyIds().size())), this::mapRecord, args.toArray()); + """; + return jdbcTemplate.query(sql, this::mapRecord, args.toArray()); } public String exportPilotCsv() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index f7223100..9032feaf 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -3,6 +3,7 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; import org.mockito.invocation.InvocationOnMock; @@ -11,17 +12,20 @@ import org.springframework.jdbc.core.RowMapper; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@Tag("dev") public class AihrPracticeSeedServiceTest { @Test @@ -64,13 +68,101 @@ public class AihrPracticeSeedServiceTest { assertEquals("只能派发给当前项目范围内员工", error.getMessage()); } + @Test + public void pendingReviewRecordsAppliesProjectAndAssigneeFilters() { + PracticeReviewJdbcTemplate jdbcTemplate = new PracticeReviewJdbcTemplate("主管", List.of("SUP-1", "EMP-1")); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + service.pendingReviewRecords("SUP-1", "P1", "EMP-1", 50); + + assertTrue(jdbcTemplate.reviewSql.contains("status = '待复盘'")); + assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id = ?")); + assertTrue(jdbcTemplate.reviewSql.contains("project_code = ?")); + assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?,?)")); + assertEquals(List.of("000000", "EMP-1", "000000", "P1", "SUP-1", "EMP-1", 20), jdbcTemplate.reviewArgs); + } + + @Test + public void pendingReviewRecordsLimitsNonSupervisorToSelf() { + PracticeReviewJdbcTemplate jdbcTemplate = new PracticeReviewJdbcTemplate("员工", List.of("SUP-1", "EMP-1")); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + service.pendingReviewRecords("SUP-1", "", "", 0); + + assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?)")); + assertEquals(List.of("000000", "SUP-1", 5), jdbcTemplate.reviewArgs); + } + @SuppressWarnings({"unchecked", "rawtypes"}) private static List mapOne(InvocationOnMock invocation, Map values) throws SQLException { RowMapper mapper = invocation.getArgument(1); - ResultSet resultSet = mock(ResultSet.class); - for (Map.Entry entry : values.entrySet()) { - when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue()); - } - return List.of((T) mapper.mapRow(resultSet, 0)); + return mapRows(mapper, List.of(values)); } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List mapRows(RowMapper mapper, List> rows) throws SQLException { + List mapped = new ArrayList<>(); + int index = 0; + for (Map values : rows) { + ResultSet resultSet = mock(ResultSet.class); + for (Map.Entry entry : values.entrySet()) { + when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue()); + } + mapped.add((T) mapper.mapRow(resultSet, index++)); + } + return mapped; + } + + private static final class PracticeReviewJdbcTemplate extends JdbcTemplate { + private final String ownerLevel; + private final List scopeIds; + private String reviewSql = ""; + private List reviewArgs = List.of(); + + private PracticeReviewJdbcTemplate(String ownerLevel, List scopeIds) { + this.ownerLevel = ownerLevel; + this.scopeIds = scopeIds; + } + + @Override + public T queryForObject(String sql, Class requiredType) { + return requiredType.cast(1); + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(1); + } + + @Override + public void execute(String sql) { + // Table creation is irrelevant for query assembly tests. + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + try { + if (sql.contains("FROM aihr_practice_session")) { + reviewSql = sql; + reviewArgs = List.of(args); + return List.of(); + } + if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) { + return mapRows(rowMapper, List.of(Map.of( + "project_code", "P1", + "position_level", ownerLevel + ))); + } + if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) { + return mapRows(rowMapper, scopeIds.stream() + .map(id -> Map.of("ext_party_id", id)) + .toList()); + } + return List.of(); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + } + } diff --git a/docs/PC管理端功能差异TODO.md b/docs/PC管理端功能差异TODO.md index b01640a2..d6ddbd82 100644 --- a/docs/PC管理端功能差异TODO.md +++ b/docs/PC管理端功能差异TODO.md @@ -6,8 +6,8 @@ | 优先级 | 差异项 | 当前状态 | 本次实现 | 验收口径 | |---|---|---|---|---| | P0 | 组织人员 / 岗位 / 项目权限运营页 | 组织同步 API 存在,侧栏未暴露运营视图 | 新增 `组织与权限` 页面,呈现组织人员快照、岗位映射、项目数据范围和同步预检入口 | PC 可查询人员快照,看到身份三轴、权限映射、同步结果边界 | -| P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 后端已有复盘、评分校准、派发接口;PC 只有散落入口 | 新增 `复盘校准` 页面,集中待复盘列表、详情、复盘建议、学分、下次训练派发 | 主管能从一页完成“看记录 -> 写建议 -> 派训练” | -| P0 | 能力画像 / 等级证据包 / 激励规则 | 后端已有画像和证据包接口;PC 无独立页 | 新增 `成长激励` 页面,展示画像、等级路径、证据包、预警、任务与最小规则表 | HR 能看到初/中/高级证据和学分规则口径 | +| P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 后端已有复盘、评分校准、派发接口;PC 只有散落入口 | 新增 `复盘校准` 页面,并由后端基于组织快照做项目/人员筛选 | 主管能从一页完成“看记录 -> 写建议 -> 派训练” | +| P0 | 能力画像 / 等级证据包 / 激励规则 | 后端已有画像和证据包接口;PC 无独立页 | 新增 `成长激励` 页面,并用组织快照选择员工查看画像和派发专项 | HR 能看到初/中/高级证据和学分规则口径 | | P1 | 学模块课程 / 考试后台 | 文档要求阶段二建设;当前无页面 | 暂不实现,保留 TODO | 阶段二专项设计后再建课程、课件、考试、成绩表 | | P1 | 大喇叭文件分发 | 总纲阶段二;当前无页面 | 暂不实现,保留 TODO | 半年会后按身份三轴做摘要、已读未读、补推 | | P1 | 开放问题榜 | 阶段一只埋 `knowledge_gap`;当前无 UI | 暂不实现,保留 TODO | 阶段二消费 gap 池、上榜、最佳答案、沉淀入库 | @@ -19,4 +19,5 @@ - 不新建数据库表。 - 不改登录、密钥、模型配置、支付、部署脚本。 - 优先复用已有 `/api/aihr/mobile/practice/*` 与 `/api/train/practice/*` 接口。 +- 组织人员快照在 `组织与权限` 页分页展示;业务页只取下拉候选,真实复盘范围由接口参数落到后端过滤。 - 规则配置中心先做运营口径展示;需要真实持久化时再补 `incentive_rule` 等表。 diff --git a/frontend/src/api/aihr/mobile-admin.ts b/frontend/src/api/aihr/mobile-admin.ts index 3ea4dd86..aca3f0ac 100644 --- a/frontend/src/api/aihr/mobile-admin.ts +++ b/frontend/src/api/aihr/mobile-admin.ts @@ -154,10 +154,17 @@ export type OrgSnapshotResponse = { rows: OrgPersonRow[]; }; -export function listPracticeReviews(): Promise> { +export type ReviewListParams = { + projectCode?: string; + extPartyId?: string; + limit?: number; +}; + +export function listPracticeReviews(params?: ReviewListParams): Promise> { return request({ url: '/api/aihr/mobile/practice/reviews', - method: 'get' + method: 'get', + params }); } diff --git a/frontend/src/views/competency/growth.vue b/frontend/src/views/competency/growth.vue index 48f5496a..3ac51b32 100644 --- a/frontend/src/views/competency/growth.vue +++ b/frontend/src/views/competency/growth.vue @@ -6,7 +6,17 @@

展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。

- + + + + + + 刷新
@@ -123,27 +133,31 @@