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 bfe35adf..c622f9ad 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 @@ -14,7 +14,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; +import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; @@ -198,6 +200,16 @@ public class AihrMobileController { return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername())); } + @GetMapping("/onboard/tasks") + public R> onboardTasks() { + return R.ok(mobileSeedService.onboardTasks(ownMobileExtPartyId(null))); + } + + @GetMapping("/qualification") + public R qualification() { + return R.ok(mobileSeedService.qualification(ownMobileExtPartyId(null))); + } + @PostMapping("/practice/assignments") public R createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) { return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())); 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 4ca720c9..1da894e9 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 @@ -277,6 +277,42 @@ public final class AihrPracticeDto { ) { } + public record OnboardTaskResponse( + Long id, + String projectCode, + String positionCode, + Long sopId, + Long courseId, + String taskType, + String status, + String assignBy, + String assignedTime, + String dueTime, + String completedTime, + String evidenceRef + ) { + } + + public record QualificationGateResponse( + Long id, + String projectCode, + String positionCode, + String certId, + Boolean passed, + String validThru, + String evidenceRef, + String reviewer, + String reviewedTime + ) { + } + + public record QualificationResponse( + String status, + String positionCode, + List gates + ) { + } + public record TeamMemberResponse(String extPartyId, String name) { } 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 f259cce2..626114e7 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 @@ -8,6 +8,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; +import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; @@ -166,6 +168,14 @@ public class AihrMobileSeedService { return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10); } + public List onboardTasks(String extPartyId) { + return practiceSeedService.onboardTasks(extPartyId, 20); + } + + public QualificationResponse qualification(String extPartyId) { + return practiceSeedService.qualification(extPartyId); + } + public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) { return practiceSeedService.createAssignment(request); } 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 1362b5d2..4dcdc49e 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 @@ -26,6 +26,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; +import org.dromara.aihr.domain.AihrPracticeDto.QualificationGateResponse; +import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.MistakeAggregateResponse; @@ -2036,6 +2039,96 @@ public class AihrPracticeSeedService { """.formatted(nameExpression), this::mapAssignment, tenantId(), party, party, normalizeLimit(limit)); } + /** + * Formal onboarding tasks are read-only in phase one. The employee identity is + * resolved from the authenticated APP identity to the external organization ID; + * no task is created from this endpoint. + */ + public List onboardTasks(String extPartyId, int limit) { + String identity = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (identity.isEmpty() || !formalPositionTablesExist("aihr_onboard_task")) { + return List.of(); + } + boolean hasOrgSnapshot = orgSnapshotTableExists() && orgSnapshotColumnExists("person_phone"); + String identityPredicate = hasOrgSnapshot + ? "(t.ext_party_id = ? OR t.ext_party_id IN (SELECT o.ext_party_id FROM aihr_org_snapshot o WHERE o.tenant_id = ? AND o.employment_status = 'active' AND (o.ext_party_id = ? OR o.person_phone = ?)))" + : "t.ext_party_id = ?"; + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(identity); + if (hasOrgSnapshot) { + args.add(tenantId()); + args.add(identity); + args.add(identity); + } + args.add(normalizeLimit(limit)); + return jdbcTemplate.query(""" + SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type, + t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref + FROM aihr_onboard_task t + WHERE t.tenant_id = ? AND %s + ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END, + COALESCE(t.due_time, t.create_time) ASC, t.id DESC + LIMIT ? + """.formatted(identityPredicate), (rs, rowNum) -> new OnboardTaskResponse( + rs.getLong("id"), + rs.getString("project_code"), + rs.getString("position_code"), + rs.getObject("sop_id") == null ? null : rs.getLong("sop_id"), + rs.getObject("course_id") == null ? null : rs.getLong("course_id"), + rs.getString("task_type"), + rs.getString("status"), + rs.getString("assign_by"), + formatTimestamp(rs.getTimestamp("assigned_time")), + formatTimestamp(rs.getTimestamp("due_time")), + formatTimestamp(rs.getTimestamp("completed_time")), + rs.getString("evidence_ref") + ), args.toArray()); + } + + /** + * Return qualification evidence for the current APP user without treating an + * empty rules table as a pass. Formal qualification remains NOT_CONFIGURED until + * HR supplies a gate row and its evidence. + */ + public QualificationResponse qualification(String extPartyId) { + String identity = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (identity.isEmpty() || !formalPositionTablesExist("aihr_qualification_gate")) { + return new QualificationResponse("NOT_CONFIGURED", "", List.of()); + } + boolean hasOrgSnapshot = orgSnapshotTableExists() && orgSnapshotColumnExists("person_phone"); + String identityPredicate = hasOrgSnapshot + ? "(q.ext_party_id = ? OR q.ext_party_id IN (SELECT o.ext_party_id FROM aihr_org_snapshot o WHERE o.tenant_id = ? AND o.employment_status = 'active' AND (o.ext_party_id = ? OR o.person_phone = ?)))" + : "q.ext_party_id = ?"; + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(identity); + if (hasOrgSnapshot) { + args.add(tenantId()); + args.add(identity); + args.add(identity); + } + List gates = jdbcTemplate.query(""" + SELECT q.id, q.project_code, q.position_code, q.cert_id, q.passed, q.valid_thru, + q.evidence_ref, q.reviewer, q.reviewed_time + FROM aihr_qualification_gate q + WHERE q.tenant_id = ? AND %s + ORDER BY q.position_code, q.cert_id, q.id DESC + """.formatted(identityPredicate), (rs, rowNum) -> new QualificationGateResponse( + rs.getLong("id"), + rs.getString("project_code"), + rs.getString("position_code"), + rs.getString("cert_id"), + rs.getBoolean("passed"), + rs.getDate("valid_thru") == null ? null : rs.getDate("valid_thru").toLocalDate().toString(), + rs.getString("evidence_ref"), + rs.getString("reviewer"), + formatTimestamp(rs.getTimestamp("reviewed_time")) + ), args.toArray()); + return new QualificationResponse(qualificationStatus(gates), + gates.isEmpty() ? "" : firstNonBlank(gates.get(0).positionCode(), ""), gates); + } + public List assignments(String extPartyId, String supervisorExtPartyId, int limit) { String party = isBlank(extPartyId) ? "" : extPartyId.trim(); String supervisor = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim(); @@ -3779,6 +3872,43 @@ public class AihrPracticeSeedService { return Math.min(limit, 200); } + private String formatTimestamp(Timestamp value) { + return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER); + } + + private String qualificationStatus(List gates) { + if (gates.isEmpty()) { + return "NOT_CONFIGURED"; + } + if (gates.stream().anyMatch(gate -> !Boolean.TRUE.equals(gate.passed()))) { + return "BLOCKED"; + } + LocalDate today = LocalDate.now(); + if (gates.stream().anyMatch(gate -> gate.validThru() != null + && !gate.validThru().isBlank() + && LocalDate.parse(gate.validThru()).isBefore(today))) { + return "EXPIRED"; + } + return "PASSED"; + } + + private boolean formalPositionTablesExist(String tableName) { + if (!Set.of("aihr_onboard_task", "aihr_qualification_gate").contains(tableName)) { + return false; + } + try { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + """, Integer.class, tableName); + return count != null && count > 0; + } catch (DataAccessException ex) { + log.warn("formal position table check failed for {}, returning empty result", tableName); + return false; + } + } + private void ensurePracticeTable() { if (practiceTableReady) { return; 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 36472a01..409e9566 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 @@ -659,6 +659,23 @@ public class AihrPracticeSeedServiceTest { assertFalse(controllerSource.contains("supervisor H5 keeps team-scope")); } + @Test + public void formalOnboardingReadsAreLoginScopedAndReadOnly() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); + } + String controllerSource = Files.readString(source); + + assertTrue(controllerSource.contains("@SaCheckLogin\npublic class AihrMobileController")); + assertTrue(controllerSource.contains("@GetMapping(\"/onboard/tasks\")")); + assertTrue(controllerSource.contains("@GetMapping(\"/qualification\")")); + assertTrue(controllerSource.contains("mobileSeedService.onboardTasks(ownMobileExtPartyId(null))")); + assertTrue(controllerSource.contains("mobileSeedService.qualification(ownMobileExtPartyId(null))")); + assertFalse(controllerSource.contains("@PostMapping(\"/onboard/tasks\")")); + assertFalse(controllerSource.contains("@PostMapping(\"/qualification\")")); + } + @Test public void dashboardControllerRequiresHrRole() throws Exception { Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrDashboardController.java"); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 70b97049..1698f2b6 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -13,7 +13,7 @@ | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | -| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 | +| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页只读读取当前 APP 身份对应的正式岗前/入职任务与资格证据,无正式数据时明确返回空任务和 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index d064b63c..5a98e0f3 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,9 @@ # BRD 功能审查与未完成项记录 +- 2026-07-15 BRD 4.1 候选人进度状态修正:候选人进度页此前用最近一份补充资料的审核状态作为“面试人工复核”状态,可能在面试已复核但资料未提交时误显示“人工复核中”;现改为面试状态读取 `InterviewRecord.reviewStatus`,并将补充资料审核单独展示为未提交/待审核/已通过/已驳回。仅修正移动端展示与类型契约,不改 API、数据库或审核结论;`mobile-uni` 类型检查、单元测试 `56/56` 和 H5 构建通过。 + +- 2026-07-15 BRD 4.2/P1 正式任务与资格只读桥接:复核确认 `aihr_onboard_task`、`aihr_qualification_gate` 已有结构但员工端没有读取入口;现新增登录 APP 用户范围内的 `GET /api/aihr/mobile/onboard/tasks` 与 `GET /api/aihr/mobile/qualification`,通过组织快照把手机号解析到正式 `ext_party_id`,不接受前端身份参数。`mobile-uni` 学习页新增正式岗前/入职任务和资格状态展示;空表明确显示“未配置”,不生成任务、不把 AI 训练分数当资格结论。该批次未写入生产数据,正式任务/资格规则仍需 HR 与组织系统提供。 + - 2026-07-15 发布预检重建复核:按当前 `HEAD=14adc3e6` 重新构建管理端生产包、`mobile-uni` H5 和 `ruoyi-admin.jar` 均成功,产物新鲜度检查通过;`RELEASE_VERIFY_REMOTE_MATCH=true ./scripts/release-preflight.sh` 随后仅因两份用户未提交的 Figma 文档阻断,尚未进入线上资源/后端 hash 对比。本轮未同步静态资源、迁移生产数据库、重启后端或写入生产业务数据;待 Figma 提交边界和正式发布窗口确认后再继续。 - 2026-07-15 BRD 4.5/P1 成长规则与权限续审:成长激励页继续明确“证据包待人工复核”和“学分规则待 HR 配置、当前不生效”,当前没有 `cert_rule`/`incentive_rule` 正式规则契约,不新增空壳配置 CRUD;候选人资料、组织同步、训练音频和 SOP 管理写入口均已复核为登录或 HR/管理员角色保护。AIHR 测试 `138/138`、`mobile-uni` 单测 `56/56`、类型检查、管理端构建和成长页 lint 均通过。线上根站、H5、租户接口和员工首页 API 只读返回 `200`,服务为 `active`,但线上仍加载旧 H5 资源 `index-CDn6TrX8.js`,本轮未发布、未迁移生产数据库或写入业务数据;下一步仍需 HR 规则/组织同步数据与正式发布窗口。 @@ -143,7 +147,7 @@ | 缺口 | BRD依据 | 当前事实 | 建议实现顺序 | |---|---|---|---| -| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 已补 `GET /api/knowledge/position-sop` 和员工学习页状态提示;新增 `position_responsibility`、`sop_applicability`、`onboard_task`、`qualification_gate` 四表结构及版本化迁移,接口在有正式适用范围数据时优先读取岗位/SOP映射,未迁移或无映射时回退生活顾问 seed。当前正式映射、入职任务和资格规则均为空,资格状态仍为 `NOT_CONFIGURED`,不能作为上岗资格判断 | 先由 HR/组织系统提供正式岗位、项目、SOP 适用范围与任务规则,再补任务查询/完成状态和资格判定;资格 gating 必须绑定明确规则、证据来源和权限,不能用空表或 seed 冒充 | +| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 已补 `GET /api/knowledge/position-sop`、员工端 `GET /api/aihr/mobile/onboard/tasks` 与 `GET /api/aihr/mobile/qualification`;四表结构及版本化迁移已在位,任务/资格读取按登录 APP 身份映射正式 `ext_party_id`。当前正式映射、入职任务和资格规则均为空,资格状态仍为 `NOT_CONFIGURED`,不能作为上岗资格判断 | 先由 HR/组织系统提供正式岗位、项目、SOP 适用范围与任务/资格规则;当前只读展示已完成,后续再接 HR 派发、员工完成回写和正式资格证据复核,不能用空表或 seed 冒充 | | BRD 全覆盖训练场景 | 3.1、4.3.4 | 仓库已补 `service-promotion-needs`、`daily-service-followup` 两条生活顾问场景及住宅 SOP 迁移,严格门禁也检查两类核心流程;生产当前仍未迁移场景/Rubric 表和补充内容,且两类成功条件、价格/取消规则、回访口径仍需内容负责人确认 | 先在发布窗口执行场景、Rubric、SOP 迁移,再由内容负责人确认正式成功条件、合规边界和 SOP 版本;不把仓库 seed 或本地样本直接算作正式验收素材 | | 认证等级与激励规则可配置 | 4.5、10.2-A、TechSpec M6 | 成长激励页已有初/中/高级证据包和学分口径展示,但规则仍是前端硬编码;原“排行榜”开关无实际业务作用,已移除并明确标记只读;尚无 `cert_rule`/`incentive_rule` 的持久化、版本、角色权限和生效记录 | 先由 HR 确认学分来源、分值、防刷和初/中/高级条件,再设计规则表与管理员 CRUD;在规则契约明确前,不把静态展示称为可配置 | | 智能错题本 | 3.1、4.4、TechSpec P1 | 已补员工本人错题本和主管 `/api/aihr/mobile/practice/team` 内的 `mistakes` 聚合:按当前项目权限统计场景、归因、次数、影响人数、平均分和最近发生时间;员工端可跳转待训练 retry assignment,retry 已完成时改为显示“已完成”,不再重复引导进入已完成任务;不新增独立错题事实表 | 继续用正式组织快照和试点窗口验证主管只看到本项目数据;后续可在真实错题量上来后补趋势/导出,不把本地演示样本当正式指标 | @@ -199,7 +203,7 @@ 1. 先完成正式组织快照 dry-run 与项目范围核对;不修改正式数据,不用手机号猜角色。 2. 在本地/测试环境实现“错题本只读聚合”,复用现有训练记录和 retry assignment,补 API、员工入口和主管查看边界。 -3. 已补岗位学习适配摘要、正式岗位/SOP 映射读取和空表回退;下一步接正式映射数据,再实现 `onboard_task` 培训任务状态;资格 gating 放在有真实资格规则后接入,避免先做一个无法运营的开关。 +3. 已补岗位学习适配摘要、正式岗位/SOP 映射读取、正式任务/资格只读展示和空表回退;下一步接正式映射数据,再实现 HR 派发、员工完成回写和有证据的资格 gating,避免先做一个无法运营的开关。 4. 在业务提供一期视频样片及字段口径后,继续扩案例详情数据契约;当前原始音频回放已完成,但不替代视频、学习点和主管点评。 5. 用正式试点窗口跑 BRD L1/M5,而不是继续增加视觉页面。 diff --git a/mobile-uni/src/pages/candidate/progress/index.vue b/mobile-uni/src/pages/candidate/progress/index.vue index f77c4b71..8f6db54b 100644 --- a/mobile-uni/src/pages/candidate/progress/index.vue +++ b/mobile-uni/src/pages/candidate/progress/index.vue @@ -25,7 +25,7 @@ {{ latestRecord.score }} 分 AI 初评 · {{ latestRecord.result }} - {{ reviewStatusLabel }} + {{ interviewReviewLabel }} {{ latestRecord.summary }} {{ questionRecords.length }} 道题 · 已保存真实回答 @@ -45,8 +45,13 @@ - 人工复核状态 - {{ reviewStatusDescription }} + 面试人工复核 + {{ interviewReviewDescription }} + + + + 补充资料审核 + {{ materialReviewDescription }} @@ -78,10 +83,16 @@ const message = ref(''); const latestRecord = computed(() => records.value[0] || null); const latestMaterial = computed(() => materials.value[0] || null); const questionRecords = computed(() => latestRecord.value?.questions || []); -const reviewStatusLabel = computed(() => latestMaterial.value?.status === '已通过' ? '人工复核完成' : '人工复核中'); -const reviewStatusDescription = computed(() => latestMaterial.value?.status === '已通过' - ? 'HR 已完成 AI 评分与补充资料复核,后续通知会同步更新。' - : 'HR 正在复核 AI 评分与补充资料,预计 1-2 个工作日更新。'); +const interviewReviewLabel = computed(() => latestRecord.value?.reviewStatus === '已复核' ? '面试已复核' : '面试待复核'); +const interviewReviewDescription = computed(() => latestRecord.value?.reviewStatus === '已复核' + ? 'HR 已完成本次 AI 面试的人工复核,最终采用分已写入记录。' + : 'AI 初评仅作辅助参考,HR 尚未完成本次面试的人工复核。'); +const materialReviewDescription = computed(() => { + if (!latestMaterial.value) return '暂无补充资料,完成上传后由 HR 进行审核。'; + if (latestMaterial.value.status === '已通过') return 'HR 已通过最近一份补充资料审核。'; + if (latestMaterial.value.status === '已驳回') return '最近一份补充资料未通过,请按审核意见重新提交。'; + return '最近一份补充资料正在等待 HR 审核。'; +}); const clearData = () => { records.value = []; diff --git a/mobile-uni/src/pages/user/learning/index.vue b/mobile-uni/src/pages/user/learning/index.vue index 6cb2ab69..b37c1e15 100644 --- a/mobile-uni/src/pages/user/learning/index.vue +++ b/mobile-uni/src/pages/user/learning/index.vue @@ -123,6 +123,51 @@ + + + + 岗前 / 入职任务 + 只展示组织系统已正式派发的任务 + + + + + + + {{ item.taskType }} · {{ item.positionCode || positionProfile.value }} + {{ item.status }}{{ item.projectCode ? ` · ${item.projectCode}` : '' }} + 截止:{{ item.dueTime }} + + {{ item.status }} + + + 暂无正式岗前/入职任务,等待 HR 或组织系统派发 + + + + + + 上岗资格 + 资格结论必须来自正式证据,不以 AI 训练分数代替 + + + + + {{ qualificationStatusLabel }} + {{ qualificationNote }} + + + + {{ gate.certId }} + {{ gate.passed ? '已通过' : '未通过' }}{{ gate.validThru ? ` · 有效至 ${gate.validThru}` : '' }} + + + + 岗位 SOP @@ -206,12 +251,14 @@ import { computed, ref } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import type { KnowledgeSearchResponse, + OnboardTask, PositionSopSummary, PracticeAssignment, PracticeMistake, - PracticePrepCard + PracticePrepCard, + QualificationSummary } from '@/types/api'; -import { getPracticeAssignments, getPracticeMistakes, getPracticePrepCard } from '@/services/practice'; +import { getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice'; import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge'; import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { buildLearningTopics, selectLearningTopic } from '@/services/learning'; @@ -237,6 +284,10 @@ const activeMistakeCause = ref('全部'); const prepLoading = ref(false); const knowledgeLoading = ref(false); const positionProfile = ref(getSelectedPositionProfile()); +const onboardTasks = ref([]); +const qualification = ref(null); +const onboardingLoading = ref(false); +const qualificationLoading = ref(false); const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`); const topics = computed(() => buildLearningTopics( @@ -258,6 +309,33 @@ const visibleMistakes = computed(() => activeMistakeCause.value === '全部' ? mistakes.value : mistakes.value.filter((item) => item.cause === activeMistakeCause.value)); +const qualificationStatusLabel = computed(() => { + switch (qualification.value?.status) { + case 'PASSED': return '已通过'; + case 'BLOCKED': return '未通过'; + case 'EXPIRED': return '已过期'; + default: return '未配置'; + } +}); + +const qualificationTone = computed(() => { + switch (qualification.value?.status) { + case 'PASSED': return 'passed'; + case 'BLOCKED': return 'blocked'; + case 'EXPIRED': return 'expired'; + default: return 'not-configured'; + } +}); + +const qualificationNote = computed(() => { + switch (qualification.value?.status) { + case 'PASSED': return '当前资格记录已通过正式复核。'; + case 'BLOCKED': return '存在未通过的资格门禁,请联系主管或 HR。'; + case 'EXPIRED': return '资格记录已过有效期,请重新提交证据复核。'; + default: return '当前没有正式资格规则或证据记录,不能据此判断已具备上岗资格。'; + } +}); + const refreshAuthState = () => { loggedIn.value = isLoggedIn(); positionProfile.value = getSelectedPositionProfile(); @@ -327,6 +405,32 @@ const loadMistakes = async () => { } }; +const loadOnboarding = async () => { + if (!loggedIn.value) return; + onboardingLoading.value = true; + try { + onboardTasks.value = await getOnboardTasks(); + } catch (error) { + onboardTasks.value = []; + handleRequestError(error, '岗前任务加载失败'); + } finally { + onboardingLoading.value = false; + } +}; + +const loadQualification = async () => { + if (!loggedIn.value) return; + qualificationLoading.value = true; + try { + qualification.value = await getQualification(); + } catch (error) { + qualification.value = { status: 'NOT_CONFIGURED', positionCode: '', gates: [] }; + handleRequestError(error, '上岗资格加载失败'); + } finally { + qualificationLoading.value = false; + } +}; + const loadPrepCard = async () => { if (!loggedIn.value) return; prepLoading.value = true; @@ -370,6 +474,8 @@ const loadPage = async () => { if (!loggedIn.value) { assignments.value = []; mistakes.value = []; + onboardTasks.value = []; + qualification.value = null; prepCard.value = null; knowledgeResult.value = null; positionSopSummary.value = null; @@ -379,7 +485,7 @@ const loadPage = async () => { return; } refreshAuthState(); - await Promise.all([loadAssignments(), loadMistakes()]); + await Promise.all([loadAssignments(), loadMistakes(), loadOnboarding(), loadQualification()]); activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value); await Promise.all([ loadPositionSopSummary(), @@ -465,6 +571,91 @@ onShow(() => { line-height: 1.5; } +.onboarding-list, +.qualification-list { + display: grid; + gap: 8px; +} + +.onboarding-row, +.qualification-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + background: #f7f9fc; +} + +.onboarding-copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.onboarding-title, +.qualification-cert { + color: #1f2d3d; + font-size: 13px; + font-weight: 700; +} + +.onboarding-meta, +.qualification-meta { + color: #7b8494; + font-size: 12px; + line-height: 1.45; +} + +.onboarding-status { + flex: none; + font-size: 12px; + font-weight: 700; +} + +.onboarding-status.pending { + color: #9a6700; +} + +.onboarding-status.done { + color: #16845b; +} + +.qualification-status { + display: grid; + gap: 3px; + margin-bottom: 10px; + padding: 10px 12px; + border-radius: 8px; +} + +.qualification-status.not-configured { + background: #fff8e6; + color: #8a5a00; +} + +.qualification-status.passed { + background: #ecf9f2; + color: #16764f; +} + +.qualification-status.blocked, +.qualification-status.expired { + background: #fff0f0; + color: #b42318; +} + +.qualification-label { + font-size: 13px; + font-weight: 700; +} + +.qualification-note { + font-size: 12px; + line-height: 1.5; +} + .mistake-list { display: grid; gap: 10px; diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index 68107a2c..4fe4d9c9 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -10,6 +10,8 @@ import type { ReviewAdviceRequest, ReviewDetail, PracticeAssignment, + OnboardTask, + QualificationSummary, PracticeAssignmentRequest, DailyDrillAnswerResponse, PracticeAlert, @@ -170,6 +172,20 @@ export const getPracticeAssignments = (ownOnly = false) => { }); }; +export const getOnboardTasks = () => + apiRequest({ + url: '/api/aihr/mobile/onboard/tasks', + method: 'GET', + timeout: 15000 + }); + +export const getQualification = () => + apiRequest({ + url: '/api/aihr/mobile/qualification', + method: 'GET', + timeout: 15000 + }); + export const createPracticeAssignment = (data: PracticeAssignmentRequest) => apiRequest({ url: '/api/aihr/mobile/practice/assignments', diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index c639ed1a..54515e62 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -315,6 +315,39 @@ export interface PracticeAssignment { audioOssId?: number | string; } +export interface OnboardTask { + id: number; + projectCode?: string | null; + positionCode?: string | null; + sopId?: number | null; + courseId?: number | null; + taskType: string; + status: string; + assignBy?: string | null; + assignedTime?: string | null; + dueTime?: string | null; + completedTime?: string | null; + evidenceRef?: string | null; +} + +export interface QualificationGate { + id: number; + projectCode?: string | null; + positionCode: string; + certId: string; + passed: boolean; + validThru?: string | null; + evidenceRef?: string | null; + reviewer?: string | null; + reviewedTime?: string | null; +} + +export interface QualificationSummary { + status: 'NOT_CONFIGURED' | 'BLOCKED' | 'EXPIRED' | 'PASSED' | string; + positionCode?: string | null; + gates: QualificationGate[]; +} + export interface PracticeAssignmentRequest { extPartyId: string; traineeName?: string; @@ -458,10 +491,16 @@ export interface InterviewDimension { } export interface InterviewRecord { + sessionId?: string; time: string; candidate: string; position: string; score: number; + reviewedScore?: number | null; + finalScore?: number; + reviewStatus?: string; + reviewer?: string; + reviewNote?: string; result: string; summary: string; questions?: InterviewQuestionRecord[]; diff --git a/mobile-uni/tests/candidate-view.test.mjs b/mobile-uni/tests/candidate-view.test.mjs index 1c6203be..c45e7f9b 100644 --- a/mobile-uni/tests/candidate-view.test.mjs +++ b/mobile-uni/tests/candidate-view.test.mjs @@ -116,6 +116,8 @@ test('面试、资料、进度和预习页各自只承载一类真实任务', as assert.match(progress, /getCandidateInterviewRecords/); assert.match(progress, /getCandidateMaterials/); + assert.match(progress, /latestRecord\.value\?\.reviewStatus/); + assert.match(progress, /补充资料审核/); assert.doesNotMatch(progress, /startCandidateInterview|uploadCandidateMaterial|searchKnowledge/); assert.match(study, /searchKnowledge/); diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index d82b3739..68e82643 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -637,6 +637,12 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/script/sql/aihr_practice_mysql8.sql "answer_audio_url" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/satisfaction" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/onboard/tasks" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/qualification" +contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/onboard/tasks" +contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/qualification" +contains mobile-uni/src/pages/user/learning/index.vue "岗前 / 入职任务" +contains mobile-uni/src/pages/user/learning/index.vue "当前没有正式资格规则或证据记录" contains backend/script/sql/aihr_practice_mysql8.sql "satisfaction_score" contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/satisfaction" contains mobile-uni/src/pages/user/practice/index.vue "本次训练体验如何"