diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index ba78407b..83fd19f4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -98,7 +98,9 @@ public final class AihrPracticeDto { String strategy, String goal, Boolean enabled, - List rounds + List rounds, + String expectedContentVersion, + String expectedContentHash ) { } 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 d28bf5e7..89b9bc12 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 @@ -124,8 +124,10 @@ public class AihrPracticeSeedService { private static final Set SCENARIO_REVIEW_STATUSES = Set.of("草稿", "待业务审核", "已发布", "已下线"); private static final Set SCENARIO_RISK_LEVELS = Set.of("待评估", "常规", "高风险"); private static final List MANDATORY_DUAL_REVIEW_TERMS = List.of( - "投诉", "客诉", "纠纷", "收费", "催费", "费用", "停车费", "退款", "减免", - "安全", "事故", "消防", "燃气", "漏电", "电梯", "高空", "受伤", "人身伤害", "舆情" + "事故", "消防", "火灾", "起火", "爆炸", "燃气", "漏电", "触电", "带电", + "高空", "困人", "受伤", "人身伤害", "摔倒", "暴力", "肢体冲突", + "危险物品", "化学品", "医疗废弃", "针具", "锐器", "有限空间", "中毒", + "舆情", "赔偿", "退款", "退费", "减免", "免单", "豁免" ); private static final String UNCLASSIFIED_COMPETENCY = "待归类"; private static final String DEFAULT_CURRICULUM_VERSION = "住宅五岗-v1"; @@ -429,6 +431,7 @@ public class AihrPracticeSeedService { throw new ServiceException("训练场景不存在或内容未发布"); } + @Transactional(rollbackFor = Exception.class) public ScenarioResponse saveScenario(ScenarioSaveRequest request) { ensureScenarioTable(); LocalDateTime now = LocalDateTime.now(); @@ -437,6 +440,20 @@ public class AihrPracticeSeedService { String position = canonicalPracticePosition(request == null ? null : request.position()); String scenarioType = firstNonBlank(request == null ? null : request.scenarioType(), "投诉处理"); String projectType = firstNonBlank(request == null ? null : request.projectType(), "住宅"); + if (request != null && (!isBlank(request.expectedContentVersion()) || !isBlank(request.expectedContentHash()))) { + List current = jdbcTemplate.query(""" + SELECT content_version, content_hash + FROM aihr_practice_scenario + WHERE tenant_id = ? AND scenario_code = ? + FOR UPDATE + """, (rs, rowNum) -> new String[] {rs.getString("content_version"), rs.getString("content_hash")}, + tenantId(), code); + if (current.isEmpty() + || !Objects.equals(current.get(0)[0], request.expectedContentVersion()) + || !Objects.equals(current.get(0)[1], request.expectedContentHash())) { + throw new ServiceException("训练场景已发生变更,请刷新后重新编辑"); + } + } ScenarioCurriculumMetadata existingCurriculum = existingScenarioCurriculum(code); String growthLevel = normalizeGrowthLevel(firstNonBlank(request == null ? null : request.growthLevel(), existingCurriculum == null ? null : existingCurriculum.growthLevel())); @@ -7653,13 +7670,13 @@ public class AihrPracticeSeedService { return requiresMandatoryDualReview(content) ? "高风险" : riskLevel; } - private boolean requiresMandatoryDualReview(String... content) { + static boolean requiresMandatoryDualReviewContent(String... content) { for (String value : Optional.ofNullable(content).orElse(new String[0])) { - if (isBlank(value)) { + if (value == null || value.isBlank()) { continue; } String normalized = value.trim(); - if ("高风险".equals(normalized)) { + if (normalized.contains("高风险")) { return true; } for (String term : MANDATORY_DUAL_REVIEW_TERMS) { @@ -7671,6 +7688,10 @@ public class AihrPracticeSeedService { return false; } + private boolean requiresMandatoryDualReview(String... content) { + return requiresMandatoryDualReviewContent(content); + } + private String normalizeReviewStatus(String value) { String status = firstNonBlank(value, "草稿"); if (!SCENARIO_REVIEW_STATUSES.contains(status)) { 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 7287da9a..806022d5 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 @@ -513,6 +513,8 @@ public class AihrPracticeSeedServiceTest { assertFalse(source.contains("WHEN enabled = 1 THEN '已发布' ELSE '待业务审核' END")); assertTrue(source.contains("content_hash <=> ?")); assertTrue(source.contains("训练场景已发生变更,请刷新后重新审核")); + assertTrue(source.contains("expectedContentVersion")); + assertTrue(source.contains("训练场景已发生变更,请刷新后重新编辑")); assertTrue(source.contains("boolean reviewDecision = \"待业务审核\".equals(current)")); assertTrue(source.contains("SCENARIO_RISK_LEVELS")); assertTrue(source.contains("approveHighRiskScenario")); @@ -577,6 +579,16 @@ public class AihrPracticeSeedServiceTest { assertEquals("已发布", jdbcTemplate.reviewStatus); } + @Test + public void genericServiceTopicsDoNotRequireDualReviewWithoutMaterialRisk() { + assertFalse(AihrPracticeSeedService.requiresMandatoryDualReviewContent( + "投诉信息完整登记", "费用明细查询", "电梯轿厢日常保洁", "安全确认" + )); + assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("电梯困人信息收集与升级")); + assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("物业费减免申请")); + assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("高风险作业")); + } + @Test public void scenarioCannotPublishUntilContentRiskIsExplicitlyClassified() throws Exception { HighRiskScenarioJdbcTemplate jdbcTemplate = new HighRiskScenarioJdbcTemplate("待评估"); @@ -800,7 +812,7 @@ public class AihrPracticeSeedServiceTest { )); assertEquals("待业务审核", jdbcTemplate.reviewStatus); - assertEquals("高风险", jdbcTemplate.riskLevel); + assertEquals("常规", jdbcTemplate.riskLevel); assertNull(jdbcTemplate.reviewer); assertNull(jdbcTemplate.reviewerUserId); assertNull(jdbcTemplate.secondReviewer); diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index 4f8f2355..1f72fd68 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -87,6 +87,8 @@ export type PracticeScenarioSaveRequest = { goal: string; enabled: boolean; rounds: PracticeScenarioRound[]; + expectedContentVersion?: string; + expectedContentHash?: string; }; export type PracticeScenarioReviewStatusRequest = { diff --git a/frontend/src/views/train/scenarios.vue b/frontend/src/views/train/scenarios.vue index 5e715502..5dbcc8a9 100644 --- a/frontend/src/views/train/scenarios.vue +++ b/frontend/src/views/train/scenarios.vue @@ -2,13 +2,85 @@
-

场景库管理

-

住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护

+

试点场景运营

+

默认每岗位、每层级选 1 个核心场景;目标 15 个,现有内容不足时按实际可用数量展示。

+
+
+ {{ showAdvanced ? '收起完整场景库' : '展开完整场景库' }} + 新增场景
- 新增场景
-
+
+
+
+

{{ pilotRows.length }} 个试点场景

+

自动初审只建议能力项与风险级别,不代替业务审核,也不会自动发布。

+
+
+ + 批量确认初审建议({{ pilotSuggestionCount }}) + + + 批量提交审核({{ pilotReadyDrafts.length }}) + + + 批量审核并发布({{ pilotReadyReviews.length }}) + +
+
+ + + + + + + + + + + + + + + + + + +
+ +
@@ -29,7 +101,7 @@ 查询
-
+

矩阵缺口

@@ -52,7 +124,7 @@
-
+
@@ -178,7 +250,7 @@ :closable="false" type="info" show-icon - title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;收费、安全、投诉等受控内容会自动升级为高风险,需两位不同审核人。" + title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;人身、消防、燃气、带电、高空、舆情或退款减免等实质风险会自动升级为高风险,需两位不同审核人。" /> @@ -216,7 +288,7 @@