From 13230e6b9a9467fa90cb049469db3bbe9907aba8 Mon Sep 17 00:00:00 2001 From: let5sne Date: Fri, 10 Jul 2026 19:13:31 +0800 Subject: [PATCH] feat(aihr): complete phase two pilot readiness --- AGENTS.md | 1 + README.md | 5 +- .../aihr/controller/AihrMobileController.java | 14 +- .../controller/AihrPracticeController.java | 11 +- .../dromara/aihr/domain/AihrPracticeDto.java | 10 + .../aihr/service/AihrMobileSeedService.java | 9 + .../aihr/service/AihrOrgSyncService.java | 4 +- .../aihr/service/AihrPracticeSeedService.java | 466 ++++++- .../aihr/service/AihrSopSeedService.java | 16 + .../aihr/service/AihrOrgSyncServiceTest.java | 68 + .../service/AihrPracticeSeedServiceTest.java | 645 +++++++++ docs/20260708/uni-app用户侧前端重建计划.md | 37 +- docs/20260708/发布会演示候选题单.md | 1 + docs/AI陪练二期开发推进计划.md | 5 + docs/API_INTEGRATION.md | 7 +- docs/DEMO_ACCEPTANCE.md | 8 +- docs/DEV_SETUP.md | 8 + docs/README.md | 4 +- frontend/src/api/aihr/practice.ts | 3 +- frontend/src/views/train/pilot-export.test.ts | 16 + frontend/src/views/train/pilot-export.ts | 15 + frontend/src/views/train/practice.vue | 31 +- mobile-uni/src/pages.json | 48 + .../src/pages/candidate/index/index.vue | 834 ++++-------- .../src/pages/candidate/interview/index.vue | 437 ++++++ .../src/pages/candidate/materials/index.vue | 293 ++++ .../src/pages/candidate/progress/index.vue | 401 ++++++ .../src/pages/candidate/study/index.vue | 333 +++++ .../src/pages/supervisor/assign/index.vue | 432 ++++++ .../src/pages/supervisor/index/index.vue | 1204 ++++------------- .../src/pages/supervisor/review/index.vue | 639 +++++++++ .../src/pages/supervisor/reviews/index.vue | 284 ++++ .../src/pages/supervisor/team/index.vue | 366 +++++ mobile-uni/src/services/candidate-view.ts | 130 ++ mobile-uni/src/services/practice.ts | 8 + mobile-uni/src/services/supervisor-view.ts | 284 ++++ mobile-uni/src/styles/base.css | 25 + mobile-uni/src/types/api.ts | 11 + mobile-uni/tests/candidate-view.test.mjs | 138 ++ mobile-uni/tests/supervisor-view.test.mjs | 367 +++++ scripts/demo-check.sh | 202 ++- scripts/dev.sh | 8 +- scripts/lib/pilot-export-contract.mjs | 295 ++++ scripts/lib/process-tree.sh | 107 ++ scripts/tests/demo-check-pilot-gates.test.sh | 140 ++ scripts/tests/pilot-export-contract.test.mjs | 145 ++ scripts/tests/process-tree.test.sh | 79 ++ scripts/verify-demo-questions.mjs | 93 +- 48 files changed, 7040 insertions(+), 1647 deletions(-) create mode 100644 frontend/src/views/train/pilot-export.test.ts create mode 100644 frontend/src/views/train/pilot-export.ts create mode 100644 mobile-uni/src/pages/candidate/interview/index.vue create mode 100644 mobile-uni/src/pages/candidate/materials/index.vue create mode 100644 mobile-uni/src/pages/candidate/progress/index.vue create mode 100644 mobile-uni/src/pages/candidate/study/index.vue create mode 100644 mobile-uni/src/pages/supervisor/assign/index.vue create mode 100644 mobile-uni/src/pages/supervisor/review/index.vue create mode 100644 mobile-uni/src/pages/supervisor/reviews/index.vue create mode 100644 mobile-uni/src/pages/supervisor/team/index.vue create mode 100644 mobile-uni/src/services/candidate-view.ts create mode 100644 mobile-uni/src/services/supervisor-view.ts create mode 100644 mobile-uni/tests/candidate-view.test.mjs create mode 100644 mobile-uni/tests/supervisor-view.test.mjs create mode 100644 scripts/lib/pilot-export-contract.mjs create mode 100644 scripts/lib/process-tree.sh create mode 100644 scripts/tests/demo-check-pilot-gates.test.sh create mode 100644 scripts/tests/pilot-export-contract.test.mjs create mode 100755 scripts/tests/process-tree.test.sh diff --git a/AGENTS.md b/AGENTS.md index bf960bb3..b6c7379e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ - 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。 - 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。 - 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。 +- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。 - 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。 - 当前业务页已跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 已接真 LLM(`AihrPracticeLlmService`,评分 temperature=0 结构化输出),seed 剧本是剧情锚点与兜底,改对练逻辑时必须保留"未配置/失败回退 seed"的降级链,不要让演示依赖外部 API。 - 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。 diff --git a/README.md b/README.md index fc5d7557..9e49eb91 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。 +- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。 - 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 @@ -45,7 +46,7 @@ | [文档目录](docs/README.md) | docs 区完整索引 | | [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | -| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、移动端训练记录、候选资料上传/审核和 SOP 上传解析接口 | +| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | | [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 | | [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 | | [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 | @@ -58,4 +59,4 @@ ## 下一步 -现役演示验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。2026-07-07 立项会后按 [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) 三阶段推进:阶段一即 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)(时间锚点 2026-07-10 Alpha → 半年会发布 → 9 月收费季前试点可用);大喇叭信息平台、知识学习平台、北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。 +现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环已经就绪,当前唯一主线是补齐正式组织身份数据,完成 1–2 个项目、至少 20 名员工的真实训练、校准和 SOP 业务评审后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。 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 472072b8..db3910cc 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 @@ -10,6 +10,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; 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.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; @@ -63,6 +64,11 @@ public class AihrMobileController { return R.ok(mobileSeedService.practiceReviews(supervisorScopeExtPartyId(), projectCode, extPartyId, limit)); } + @GetMapping("/practice/team") + public R practiceTeam() { + return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId())); + } + @GetMapping("/practice/reviews/{id}") public R> practiceReview(@PathVariable Long id) { ReviewDetailResponse detail = mobileSeedService.practiceReview(id, supervisorScopeExtPartyId()); @@ -101,7 +107,7 @@ public class AihrMobileController { @PostMapping("/practice/assignments") public R createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) { - return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername())); + return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())); } @PostMapping("/practice/assignments/{id}/answer") @@ -117,15 +123,13 @@ public class AihrMobileController { private static String scopedAssignmentExtPartyId(String requested) { if (requested == null || requested.isBlank()) { - // ponytail: supervisor H5 keeps team-scope listing until mobile roles exist. return requested; } return ownMobileExtPartyId(requested); } - private static String supervisorScopeExtPartyId() { - // ponytail: supervisor H5 keeps team-scope reviews/alerts until mobile roles exist. - return ""; + private String supervisorScopeExtPartyId() { + return mobileSeedService.requireSupervisorIdentity(currentAppUsername()); } private static String currentAppUsername() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 855bd1a5..d22d448a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -26,6 +26,7 @@ import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.enums.UserType; import org.dromara.common.satoken.utils.LoginHelper; import org.springframework.http.HttpHeaders; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.RestController; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.List; /** @@ -86,12 +88,15 @@ public class AihrPracticeController { @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @GetMapping("/export") - public void export(HttpServletResponse response) throws IOException { - byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv()).getBytes(StandardCharsets.UTF_8); + public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + HttpServletResponse response) throws IOException { + byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv(startDate, endDate)).getBytes(StandardCharsets.UTF_8); response.reset(); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setContentType("text/csv;charset=UTF-8"); - response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=aihr-practice-pilot-" + startDate + "-" + endDate + ".csv"); response.setContentLength(body.length); response.getOutputStream().write(body); } 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 669c15cd..778c9e96 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 @@ -208,6 +208,16 @@ public final class AihrPracticeDto { ) { } + public record TeamMemberResponse(String extPartyId, String name) { + } + + public record PracticeTeamResponse( + List members, + List records, + List assignments + ) { + } + public record DailyDrillAnswerRequest(String answer) { } 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 74c7b876..8d17797c 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 @@ -6,6 +6,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse; 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.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; @@ -60,6 +61,14 @@ public class AihrMobileSeedService { return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit); } + public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) { + return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200); + } + + public String requireSupervisorIdentity(String authenticatedIdentity) { + return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity); + } + public ReviewDetailResponse practiceReview(Long id) { return practiceSeedService.reviewDetail(id); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index 40b2eb8a..b35cbc0d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -71,11 +71,13 @@ public class AihrOrgSyncService { if (baseUrl.isBlank()) { throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); } - requireSnapshotTable(); int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE); int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES); boolean dryRun = Boolean.TRUE.equals(req.dryRun()); + if (!dryRun) { + requireSnapshotTable(); + } boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting()); String token = accessToken(baseUrl); List warnings = new ArrayList<>(); 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 a43ac6a7..3ed66988 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 @@ -20,6 +20,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; 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.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; @@ -35,15 +36,22 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartRequest; import org.dromara.aihr.domain.AihrPracticeDto.StartResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; +import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse; import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; import org.dromara.common.core.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -70,6 +78,115 @@ public class AihrPracticeSeedService { "professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。" ); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + private static final String PILOT_SCOPE_CTE = """ + WITH pilot_params AS ( + SELECT ? AS tenant_id, ? AS start_time, ? AS end_time + ), active_org AS ( + SELECT DISTINCT o.ext_party_id, o.project_code, o.person_phone + FROM aihr_org_snapshot o + JOIN pilot_params p ON p.tenant_id = o.tenant_id + WHERE o.employment_status = 'active' + AND o.person_phone IS NOT NULL AND o.person_phone <> '' + ), all_org_keys AS ( + SELECT ext_party_id AS identity_key, ext_party_id + FROM active_org + WHERE ext_party_id IS NOT NULL AND ext_party_id <> '' + UNION ALL + SELECT person_phone AS identity_key, ext_party_id + FROM active_org + ), conflict_keys AS ( + SELECT identity_key + FROM all_org_keys + GROUP BY identity_key + HAVING COUNT(DISTINCT ext_party_id) > 1 + ), unsafe_org AS ( + SELECT DISTINCT k.ext_party_id + FROM all_org_keys k + JOIN conflict_keys c ON c.identity_key = k.identity_key + ), safe_org AS ( + SELECT o.ext_party_id, o.project_code, o.person_phone + FROM active_org o + LEFT JOIN unsafe_org u ON u.ext_party_id = o.ext_party_id + WHERE u.ext_party_id IS NULL + ), org_key_candidates AS ( + SELECT ext_party_id AS identity_key, ext_party_id + FROM safe_org + WHERE ext_party_id IS NOT NULL AND ext_party_id <> '' + UNION ALL + SELECT person_phone AS identity_key, ext_party_id + FROM safe_org + ), org_identity AS ( + SELECT identity_key, MIN(ext_party_id) AS ext_party_id + FROM org_key_candidates + GROUP BY identity_key + HAVING COUNT(DISTINCT ext_party_id) = 1 + ), pilot_keys AS ( + SELECT s.ext_party_id AS identity_key + FROM aihr_practice_session s + JOIN pilot_params p ON p.tenant_id = s.tenant_id + WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time + UNION + SELECT a.ext_party_id AS identity_key + FROM aihr_practice_assignment a + JOIN pilot_params p ON p.tenant_id = a.tenant_id + WHERE a.create_time >= p.start_time AND a.create_time < p.end_time + ), pilot_org AS ( + SELECT DISTINCT o.ext_party_id + FROM pilot_keys k + JOIN org_identity o ON o.identity_key = k.identity_key + ), formal_sessions AS ( + SELECT s.*, o.ext_party_id AS formal_ext_party_id + FROM aihr_practice_session s + JOIN pilot_params p ON p.tenant_id = s.tenant_id + JOIN org_identity o ON o.identity_key = s.ext_party_id + WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time + ), completed_org AS ( + SELECT formal_ext_party_id + FROM formal_sessions + GROUP BY formal_ext_party_id + HAVING COUNT(*) >= 10 + ), latest_calibration AS ( + SELECT c.session_id, MAX(c.id) AS id + FROM aihr_practice_calibration c + JOIN formal_sessions s ON s.session_id = c.session_id + JOIN pilot_params p ON p.tenant_id = c.tenant_id + WHERE c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL + AND c.create_time >= p.start_time AND c.create_time < p.end_time + GROUP BY c.session_id + ), calibration_rows AS ( + SELECT c.original_score, c.corrected_score + FROM aihr_practice_calibration c + JOIN latest_calibration l ON l.id = c.id + ) + """; + private static final String PILOT_METRICS_SQL = PILOT_SCOPE_CTE + """ + SELECT + (SELECT COUNT(*) FROM aihr_practice_scenario s JOIN pilot_params p ON p.tenant_id = s.tenant_id + WHERE s.position = '生活顾问' AND s.enabled = 1) AS scenario_count, + (SELECT COUNT(DISTINCT ext_party_id) FROM safe_org) AS org_login_count, + (SELECT COUNT(*) FROM pilot_org) AS pilot_people, + (SELECT COUNT(DISTINCT o.project_code) FROM pilot_org po JOIN safe_org o ON o.ext_party_id = po.ext_party_id + WHERE o.project_code IS NOT NULL AND o.project_code <> '') AS pilot_projects, + (SELECT COUNT(*) FROM formal_sessions) AS training_count, + (SELECT COUNT(*) FROM completed_org) AS completed_people, + (SELECT COUNT(*) FROM formal_sessions WHERE status = '已复盘') AS reviewed_count, + (SELECT COUNT(*) FROM formal_sessions WHERE total_score < 80 OR status = '待复盘') AS alert_count, + COALESCE((SELECT AVG(total_score) FROM formal_sessions), 0) AS average_score, + (SELECT COUNT(*) FROM calibration_rows) AS calibration_count, + COALESCE((SELECT SUM(CASE + WHEN (CASE WHEN original_score < 60 THEN 0 WHEN original_score < 75 THEN 1 WHEN original_score < 90 THEN 2 ELSE 3 END) + = (CASE WHEN corrected_score < 60 THEN 0 WHEN corrected_score < 75 THEN 1 WHEN corrected_score < 90 THEN 2 ELSE 3 END) + THEN 1 ELSE 0 END) FROM calibration_rows), 0) AS calibration_matched + """; + private static final String PILOT_SESSION_ROWS_SQL = PILOT_SCOPE_CTE + """ + SELECT s.formal_ext_party_id, + (SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';') + FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes, + s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status, + s.finished_time, s.summary, s.review_advice, s.incentive_point + FROM formal_sessions s + ORDER BY s.finished_time DESC, s.id DESC + """; private final Map scenarios = buildScenarios(); private final ConcurrentMap activeSessions = new ConcurrentHashMap<>(); @@ -77,6 +194,7 @@ public class AihrPracticeSeedService { private final JdbcTemplate jdbcTemplate; private final AihrPracticeLlmService practiceLlmService; private final AihrSopSeedService sopSeedService; + private final TransactionTemplate pilotExportTransaction; private volatile boolean practiceTableReady; private volatile boolean assignmentTableReady; private volatile boolean scenarioTableReady; @@ -85,11 +203,24 @@ public class AihrPracticeSeedService { private volatile boolean annotationTableReady; private volatile boolean audioTableReady; - public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, AihrSopSeedService sopSeedService) { + @Autowired + public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, + AihrSopSeedService sopSeedService, PlatformTransactionManager transactionManager) { + this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, new TransactionTemplate(transactionManager)); + } + + public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, + AihrSopSeedService sopSeedService) { + this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, (TransactionTemplate) null); + } + + AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, + AihrSopSeedService sopSeedService, TransactionTemplate pilotExportTransaction) { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; this.practiceLlmService = practiceLlmService; this.sopSeedService = sopSeedService; + this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction); } public List scenarios(String position, String scenarioType) { @@ -515,6 +646,131 @@ public class AihrPracticeSeedService { """, this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit); } + public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) { + TeamScope scope = teamScope(supervisorExtPartyId); + int safeLimit = normalizeTeamLimit(limit); + if (scope.scoped() && scope.extPartyIds().isEmpty()) { + return new PracticeTeamResponse(List.of(), List.of(), List.of()); + } + return new PracticeTeamResponse( + practiceTeamMembers(scope), + practiceTeamRecords(scope, safeLimit), + practiceTeamAssignments(scope, safeLimit) + ); + } + + private List practiceTeamMembers(TeamScope scope) { + if (!scope.scoped() || !orgSnapshotTableExists()) { + return practiceActivityMembers(scope); + } + + boolean hasPhone = orgSnapshotColumnExists("person_phone"); + String memberId = hasPhone + ? "COALESCE(NULLIF(person_phone, ''), ext_party_id)" + : "ext_party_id"; + List args = new ArrayList<>(); + args.add(TENANT_ID); + StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'"); + if (scope.scoped()) { + where.append(" AND (ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + if (hasPhone) { + where.append(" OR person_phone IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } + where.append(")"); + } + String sql = """ + SELECT %s AS ext_party_id, + COALESCE(NULLIF(person_name, ''), %s) AS name + FROM aihr_org_snapshot + %s + ORDER BY position_level, person_name, ext_party_id + """.formatted(memberId, memberId, where); + return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse( + rs.getString("ext_party_id"), + rs.getString("name") + ), args.toArray()); + } + + private List practiceActivityMembers(TeamScope scope) { + ensurePracticeTable(); + ensureAssignmentTable(); + List args = new ArrayList<>(); + args.add(TENANT_ID); + String sessionScope = ""; + String assignmentScope = ""; + if (scope.scoped()) { + sessionScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")"; + args.addAll(scope.extPartyIds()); + } + args.add(TENANT_ID); + if (scope.scoped()) { + assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")"; + args.addAll(scope.extPartyIds()); + } + String sql = """ + SELECT ext_party_id, MAX(name) AS name + FROM ( + SELECT ext_party_id, trainee_name AS name + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile'%s + UNION ALL + SELECT ext_party_id, trainee_name AS name + FROM aihr_practice_assignment + WHERE tenant_id = ?%s + ) real_team_members + WHERE ext_party_id IS NOT NULL AND ext_party_id <> '' + GROUP BY ext_party_id + ORDER BY name, ext_party_id + """.formatted(sessionScope, assignmentScope); + return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse( + rs.getString("ext_party_id"), + rs.getString("name") + ), args.toArray()); + } + + private List practiceTeamRecords(TeamScope scope, int limit) { + ensurePracticeTable(); + List args = new ArrayList<>(); + args.add(TENANT_ID); + StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile'"); + if (scope.scoped()) { + where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } + args.add(limit); + String sql = """ + SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id + FROM aihr_practice_session + %s + ORDER BY finished_time DESC, id DESC + LIMIT ? + """.formatted(where); + return jdbcTemplate.query(sql, this::mapRecord, args.toArray()); + } + + private List practiceTeamAssignments(TeamScope scope, int limit) { + ensureAssignmentTable(); + List args = new ArrayList<>(); + args.add(TENANT_ID); + StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND source <> 'daily'"); + if (scope.scoped()) { + where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")"); + args.addAll(scope.extPartyIds()); + } + args.add(limit); + String sql = """ + SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, + answer_text, feedback, correct + FROM aihr_practice_assignment + %s + ORDER BY create_time DESC, id DESC + LIMIT ? + """.formatted(where); + return jdbcTemplate.query(sql, this::mapAssignment, args.toArray()); + } + public List pendingReviewRecords(int limit) { return pendingReviewRecords("", "", "", limit); } @@ -567,62 +823,108 @@ public class AihrPracticeSeedService { return jdbcTemplate.query(sql, this::mapRecord, args.toArray()); } - public String exportPilotCsv() { + public String exportPilotCsv(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new ServiceException("请选择完整的试点开始和结束日期"); + } + if (startDate.isAfter(endDate)) { + throw new ServiceException("试点开始日期不能晚于结束日期"); + } ensurePracticeTable(); ensureAssignmentTable(); ensureCalibrationTable(); - int trainingCount = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile'", TENANT_ID); - int completedPeople = countCompletedPilotPeople(); - int pilotPeople = countPilotPeople(); - boolean completionReady = pilotPeople > 0 && completedPeople * 100 >= pilotPeople * 80; - int reviewed = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND status = '已复盘'", TENANT_ID); - CalibrationStats calibrationStats = calibrationStats(); - AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats(); - int alertCount = count(""" - SELECT COUNT(*) - FROM aihr_practice_session - WHERE tenant_id = ? AND mode = 'mobile' AND (total_score < 80 OR status = '待复盘') - """, TENANT_ID); - Double averageScore = jdbcTemplate.queryForObject(""" - SELECT COALESCE(AVG(total_score), 0) - FROM aihr_practice_session - WHERE tenant_id = ? AND mode = 'mobile' - """, Double.class, TENANT_ID); + ensureScenarioTable(); + sopSeedService.prepareSopReviewStats(); + if (pilotExportTransaction == null) { + return exportPilotCsvSnapshot(startDate, endDate); + } + String csv = pilotExportTransaction.execute(status -> exportPilotCsvSnapshot(startDate, endDate)); + if (csv == null) { + throw new ServiceException("正式试点指标查询无结果"); + } + return csv; + } + + private String exportPilotCsvSnapshot(LocalDate startDate, LocalDate endDate) { + Timestamp startTime = Timestamp.valueOf(startDate.atStartOfDay()); + Timestamp endTime = Timestamp.valueOf(endDate.plusDays(1).atStartOfDay()); + PilotReportMetrics metrics; + List> sessionRows; + try { + metrics = jdbcTemplate.queryForObject(PILOT_METRICS_SQL, (rs, rowNum) -> new PilotReportMetrics( + rs.getInt("scenario_count"), + rs.getInt("org_login_count"), + rs.getInt("pilot_people"), + rs.getInt("pilot_projects"), + rs.getInt("training_count"), + rs.getInt("completed_people"), + rs.getInt("reviewed_count"), + rs.getInt("alert_count"), + rs.getDouble("average_score"), + rs.getInt("calibration_count"), + rs.getInt("calibration_matched") + ), TENANT_ID, startTime, endTime); + sessionRows = jdbcTemplate.queryForList(PILOT_SESSION_ROWS_SQL, TENANT_ID, startTime, endTime); + } catch (DataAccessException e) { + throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化"); + } + if (metrics == null) { + throw new ServiceException("正式试点指标查询无结果"); + } + AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats( + startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay()); + int pilotPeople = metrics.pilotPeople(); + int completedPeople = metrics.completedPeople(); + boolean scopeReady = metrics.orgLoginCount() >= 20 + && pilotPeople >= 20 + && metrics.pilotProjects() >= 1 + && metrics.pilotProjects() <= 2; + boolean completionReady = scopeReady && completedPeople * 100 >= pilotPeople * 80; + boolean calibrationReady = metrics.calibrationCount() >= 20 + && metrics.calibrationMatched() * 100 >= metrics.calibrationCount() * 70; + boolean sopReady = sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80; StringBuilder csv = new StringBuilder(); appendCsvRow(csv, "metric", "value"); - appendCsvRow(csv, "training_count", trainingCount); + appendCsvRow(csv, "pilot_start_date", startDate); + appendCsvRow(csv, "pilot_end_date", endDate); + appendCsvRow(csv, "data_scope", "active_org_unique_identity"); + appendCsvRow(csv, "scenario_count", metrics.scenarioCount()); + appendCsvRow(csv, "scenario_count_target", 12); + appendCsvRow(csv, "formal_org_login_count", metrics.orgLoginCount()); + appendCsvRow(csv, "formal_pilot_people_count", pilotPeople); + appendCsvRow(csv, "formal_pilot_people_target", 20); + appendCsvRow(csv, "formal_pilot_project_count", metrics.pilotProjects()); + appendCsvRow(csv, "formal_pilot_project_target", "1-2"); + appendCsvRow(csv, "formal_pilot_scope_ready", scopeReady); + appendCsvRow(csv, "training_count", metrics.trainingCount()); + appendCsvRow(csv, "completion_definition", "at_least_10_sessions_per_person"); appendCsvRow(csv, "completion_people_count", completedPeople); appendCsvRow(csv, "completion_people_total", pilotPeople); appendCsvRow(csv, "completion_rate", percent(completedPeople, pilotPeople)); appendCsvRow(csv, "completion_rate_target", "80.0%"); appendCsvRow(csv, "completion_rate_ready", completionReady); - appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", averageScore == null ? 0 : averageScore)); - appendCsvRow(csv, "alert_count", alertCount); - appendCsvRow(csv, "review_rate", percent(reviewed, trainingCount)); - appendCsvRow(csv, "calibration_sample_count", calibrationStats.total()); + appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", metrics.averageScore())); + appendCsvRow(csv, "alert_count", metrics.alertCount()); + appendCsvRow(csv, "review_rate", percent(metrics.reviewedCount(), metrics.trainingCount())); + appendCsvRow(csv, "calibration_sample_count", metrics.calibrationCount()); + appendCsvRow(csv, "ai_human_band_matched_count", metrics.calibrationMatched()); appendCsvRow(csv, "calibration_sample_target", 20); - appendCsvRow(csv, "calibration_sample_ready", calibrationStats.total() >= 20); - appendCsvRow(csv, "ai_human_band_consistency_rate", percent(calibrationStats.matched(), calibrationStats.total())); + appendCsvRow(csv, "calibration_sample_ready", metrics.calibrationCount() >= 20); + appendCsvRow(csv, "ai_human_band_consistency_rate", percent(metrics.calibrationMatched(), metrics.calibrationCount())); appendCsvRow(csv, "ai_human_band_consistency_target", "70.0%"); - appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationStats.total() >= 20 && calibrationStats.matched() * 100 >= calibrationStats.total() * 70); + appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationReady); appendCsvRow(csv, "sop_review_sample_count", sopStats.total()); + appendCsvRow(csv, "sop_answer_usable_count", sopStats.usable()); appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total())); appendCsvRow(csv, "sop_answer_usable_target", "80.0%"); - appendCsvRow(csv, "sop_answer_usable_ready", sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80); - appendCsvRow(csv, "pilot_metric_ready", completionReady - && calibrationStats.total() >= 20 - && calibrationStats.matched() * 100 >= calibrationStats.total() * 70 - && sopStats.total() > 0 - && sopStats.usable() * 100 >= sopStats.total() * 80); + appendCsvRow(csv, "sop_answer_usable_ready", sopReady); + appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady); csv.append('\n'); - appendCsvRow(csv, "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point"); - jdbcTemplate.queryForList(""" - SELECT session_id, trainee_name, scenario_name, total_score, status, finished_time, summary, review_advice, incentive_point - FROM aihr_practice_session - WHERE tenant_id = ? AND mode = 'mobile' - ORDER BY finished_time DESC, id DESC - """, TENANT_ID).forEach(row -> appendCsvRow( + appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point"); + sessionRows.forEach(row -> appendCsvRow( csv, + row.get("formal_ext_party_id"), + row.get("project_codes"), row.get("session_id"), row.get("trainee_name"), row.get("scenario_name"), @@ -636,6 +938,15 @@ public class AihrPracticeSeedService { return csv.toString(); } + private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) { + if (transactionTemplate == null) { + return null; + } + transactionTemplate.setReadOnly(true); + transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + return transactionTemplate; + } + private int countCompletedPilotPeople() { ensurePracticeTable(); return count(""" @@ -686,22 +997,27 @@ public class AihrPracticeSeedService { return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail; } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id) { return markReviewed(id, null); } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id, ReviewAdviceRequest request) { ensurePracticeTable(); if (id == null) { return false; } + if (request != null && !isBlank(request.nextScenarioId())) { + ensureAssignmentTable(); + } int updated = jdbcTemplate.update(""" UPDATE aihr_practice_session SET status = '已复盘', review_advice = CASE WHEN ? = '' THEN review_advice ELSE ? END, incentive_point = CASE WHEN ? IS NULL THEN incentive_point ELSE ? END, update_time = ? - WHERE tenant_id = ? AND mode = 'mobile' AND id = ? + WHERE tenant_id = ? AND mode = 'mobile' AND id = ? AND status = '待复盘' """, adviceText(request), adviceText(request), @@ -711,7 +1027,16 @@ public class AihrPracticeSeedService { TENANT_ID, id ); - if (updated > 0 && request != null && !isBlank(request.nextScenarioId())) { + if (updated == 0) { + List statuses = jdbcTemplate.query(""" + SELECT status + FROM aihr_practice_session + WHERE tenant_id = ? AND mode = 'mobile' AND id = ? + FOR UPDATE + """, (rs, rowNum) -> rs.getString("status"), TENANT_ID, id); + return !statuses.isEmpty() && "已复盘".equals(statuses.get(0)); + } + if (request != null && !isBlank(request.nextScenarioId())) { ReviewDetailResponse detail = reviewDetail(id); String extPartyId = reviewExtPartyId(id); createAssignment(new PracticeAssignmentRequest( @@ -723,9 +1048,10 @@ public class AihrPracticeSeedService { firstNonBlank(request.nextReason(), "主管复盘后派发下一次训练") )); } - return updated > 0; + return true; } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) { if (reviewDetail(id, supervisorExtPartyId) == null) { return false; @@ -1457,11 +1783,41 @@ public class AihrPracticeSeedService { return !scope.scoped() || scope.extPartyIds().contains(extPartyId.trim()); } + public String requireSupervisorIdentity(String authenticatedIdentity) { + String identity = isBlank(authenticatedIdentity) ? "" : authenticatedIdentity.trim(); + if (identity.isEmpty() || !orgSnapshotTableExists()) { + throw new ServiceException("无主管权限,无法访问主管功能"); + } + boolean hasPhone = orgSnapshotColumnExists("person_phone"); + List args = new ArrayList<>(); + args.add(TENANT_ID); + args.add(identity); + String identityWhere = "ext_party_id = ?"; + if (hasPhone) { + identityWhere = "(ext_party_id = ? OR person_phone = ?)"; + args.add(identity); + } + List positionLevels = jdbcTemplate.query(""" + SELECT position_level + FROM aihr_org_snapshot + WHERE tenant_id = ? AND """ + identityWhere + """ + AND employment_status = 'active' + LIMIT 1 + """, (rs, rowNum) -> rs.getString("position_level"), args.toArray()); + if (positionLevels.isEmpty() || !canSeeProject(positionLevels.get(0))) { + throw new ServiceException("无主管权限,无法访问主管功能"); + } + return identity; + } + private TeamScope teamScope(String supervisorExtPartyId) { String owner = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim(); - if (owner.isEmpty() || !orgSnapshotTableExists()) { + if (owner.isEmpty()) { return TeamScope.unscoped(); } + if (!orgSnapshotTableExists()) { + return TeamScope.scoped(List.of(owner)); + } boolean hasPhone = orgSnapshotColumnExists("person_phone"); List ownerArgs = new ArrayList<>(); ownerArgs.add(TENANT_ID); @@ -2349,6 +2705,13 @@ public class AihrPracticeSeedService { return Math.min(limit, 20); } + private int normalizeTeamLimit(int limit) { + if (limit <= 0) { + return 200; + } + return Math.min(limit, 200); + } + private void ensurePracticeTable() { if (practiceTableReady) { return; @@ -2880,6 +3243,21 @@ public class AihrPracticeSeedService { private record CalibrationStats(int total, int matched) { } + private record PilotReportMetrics( + int scenarioCount, + int orgLoginCount, + int pilotPeople, + int pilotProjects, + int trainingCount, + int completedPeople, + int reviewedCount, + int alertCount, + double averageScore, + int calibrationCount, + int calibrationMatched + ) { + } + private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 7cf93d69..946df5fe 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -66,6 +66,7 @@ import java.sql.PreparedStatement; import java.sql.Statement; import java.sql.Timestamp; import java.time.Duration; +import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; @@ -1865,6 +1866,21 @@ public class AihrSopSeedService { """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID); } + public void prepareSopReviewStats() { + ensureSopReviewTable(); + } + + public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) { + ensureSopReviewTable(); + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable + FROM aihr_sop_answer_review + WHERE tenant_id = ? AND status = '已评审' + AND reviewed_time >= ? AND reviewed_time < ? + """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), + TENANT_ID, Timestamp.valueOf(startTime), Timestamp.valueOf(endTime)); + } + private void ensureSopReviewTable() { if (sopReviewTableReady) { return; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index 423151fb..828c5a4d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -1,12 +1,23 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Tag; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; @Tag("dev") public class AihrOrgSyncServiceTest { @@ -26,4 +37,61 @@ public class AihrOrgSyncServiceTest { assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"phone\":\"13900001111\"}"))); assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"name\":\"张三\"}"))); } + + @Test + public void dryRunNeverTouchesSnapshotSchemaWhenExternalFetchFails() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate); + ReflectionTestUtils.setField(service, "configuredBaseUrl", "http://127.0.0.1:1/api/open/v1"); + ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token"); + ReflectionTestUtils.setField(service, "configuredClientId", ""); + ReflectionTestUtils.setField(service, "configuredClientSecret", ""); + ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); + + assertThrows(IllegalStateException.class, () -> service.sync( + new SyncRequest(true, true, 1, 1, null, null, null) + )); + verifyNoInteractions(jdbcTemplate, transactionTemplate); + } + + @Test + public void successfulDryRunNeverTouchesSnapshotStorage() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/open/v1/sync/snapshot", exchange -> { + boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee"); + String items = employee + ? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]" + : "[]"; + byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0) + + ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + try { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate); + ReflectionTestUtils.setField(service, "configuredBaseUrl", + "http://127.0.0.1:" + server.getAddress().getPort() + "/api/open/v1"); + ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token"); + ReflectionTestUtils.setField(service, "configuredClientId", ""); + ReflectionTestUtils.setField(service, "configuredClientSecret", ""); + ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); + + var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null)); + + assertTrue(response.dryRun()); + assertEquals(1, response.employeeCount()); + assertEquals(0, response.syncedCount()); + assertEquals(1, response.phoneLinked()); + verifyNoInteractions(jdbcTemplate, transactionTemplate); + } finally { + server.stop(0); + } + } } 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 26bbbddf..498188bb 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.CompetencyResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; +import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -10,14 +11,28 @@ import org.mockito.ArgumentMatchers; import org.mockito.invocation.InvocationOnMock; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -29,6 +44,48 @@ import static org.mockito.Mockito.when; @Tag("dev") public class AihrPracticeSeedServiceTest { + @Test + public void pilotExportRequiresExplicitOrderedWindow() { + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); + LocalDate today = LocalDate.of(2026, 7, 10); + + assertThrows(ServiceException.class, () -> service.exportPilotCsv(null, today)); + assertThrows(ServiceException.class, () -> service.exportPilotCsv(today, null)); + ServiceException reversed = assertThrows(ServiceException.class, + () -> service.exportPilotCsv(today, today.minusDays(1))); + assertEquals("试点开始日期不能晚于结束日期", reversed.getMessage()); + } + + @Test + @SuppressWarnings("unchecked") + public void pilotExportUsesReadOnlyRepeatableReadSnapshotAndFormalRows() throws Exception { + RecordingTransactionManager transactionManager = new RecordingTransactionManager(); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + PilotExportJdbcTemplate jdbcTemplate = new PilotExportJdbcTemplate(transactionManager); + AihrSopSeedService sopSeedService = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null); + Constructor constructor = (Constructor) AihrPracticeSeedService.class + .getDeclaredConstructor(ObjectMapper.class, JdbcTemplate.class, AihrPracticeLlmService.class, + AihrSopSeedService.class, TransactionTemplate.class); + AihrPracticeSeedService service = constructor.newInstance( + new ObjectMapper(), jdbcTemplate, null, sopSeedService, transactionTemplate); + + String csv = service.exportPilotCsv(LocalDate.of(2026, 7, 7), LocalDate.of(2026, 7, 10)); + + assertNotNull(transactionManager.definition); + assertTrue(transactionManager.definition.isReadOnly()); + assertEquals(TransactionDefinition.ISOLATION_REPEATABLE_READ, transactionManager.definition.getIsolationLevel()); + assertEquals(1, transactionManager.commits); + assertFalse(transactionManager.active); + assertTrue(jdbcTemplate.scenarioPrepared); + assertTrue(jdbcTemplate.sopReviewPrepared); + assertTrue(jdbcTemplate.sopQueryInTransaction); + assertTrue(jdbcTemplate.metricsSql.contains("HAVING COUNT(*) >= 10")); + assertTrue(jdbcTemplate.metricsSql.contains("JOIN formal_sessions s ON s.session_id = c.session_id")); + assertTrue(jdbcTemplate.sessionRowsSql.contains("JOIN org_identity o ON o.identity_key = s.ext_party_id")); + assertTrue(csv.contains("\"training_count\",\"1\"")); + assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\"")); + } + @Test public void createAssignmentRequiresExtPartyId() { AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); @@ -134,6 +191,211 @@ public class AihrPracticeSeedServiceTest { assertTrue(scopedSql.contains(condition)); } + @Test + public void supervisorIdentityAcceptsActiveSupervisorAndProjectManager() { + SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of( + "SUP-EXT", "主管", + "13900001111", "项目经理" + )); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + assertEquals("SUP-EXT", service.requireSupervisorIdentity(" SUP-EXT ")); + assertEquals("13900001111", service.requireSupervisorIdentity("13900001111")); + + assertTrue(jdbcTemplate.authorizationSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.authorizationSql.contains("employment_status = 'active'")); + assertTrue(jdbcTemplate.authorizationSql.contains("ext_party_id = ? OR person_phone = ?")); + assertEquals(List.of("000000", "13900001111", "13900001111"), jdbcTemplate.authorizationArgs); + } + + @Test + public void supervisorIdentityRejectsFrontlineUnknownAndBlankIdentity() { + SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of( + "EMP-1", "一线" + )); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + ServiceException frontline = assertThrows(ServiceException.class, + () -> service.requireSupervisorIdentity("EMP-1")); + ServiceException unknown = assertThrows(ServiceException.class, + () -> service.requireSupervisorIdentity("UNKNOWN")); + ServiceException blank = assertThrows(ServiceException.class, + () -> service.requireSupervisorIdentity(" ")); + + assertTrue(frontline.getMessage().contains("无主管权限")); + assertTrue(unknown.getMessage().contains("无主管权限")); + assertTrue(blank.getMessage().contains("无主管权限")); + assertEquals(2, jdbcTemplate.authorizationQueryCount); + } + + @Test + public void markReviewedOnlyTransitionsPendingReview() { + MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(1, null); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + assertTrue(service.markReviewed(42L)); + + assertTrue(jdbcTemplate.sessionUpdateSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.sessionUpdateSql.contains("mode = 'mobile'")); + assertTrue(jdbcTemplate.sessionUpdateSql.contains("id = ?")); + assertTrue(jdbcTemplate.sessionUpdateSql.contains("status = '待复盘'")); + assertEquals(1, jdbcTemplate.sessionUpdateCount); + } + + @Test + public void markReviewedTreatsAlreadyReviewedAsSuccessWithoutAssignment() { + MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "已复盘"); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + ReviewAdviceRequest request = new ReviewAdviceRequest( + "主管", + "重复提交不应覆盖", + 10, + "complaint-water", + "重复提交不应派发" + ); + + assertTrue(service.markReviewed(42L, request)); + + assertTrue(jdbcTemplate.lockedStatusSql.contains("SELECT status")); + assertTrue(jdbcTemplate.lockedStatusSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.lockedStatusSql.contains("mode = 'mobile'")); + assertTrue(jdbcTemplate.lockedStatusSql.contains("id = ?")); + assertTrue(jdbcTemplate.lockedStatusSql.contains("FOR UPDATE")); + assertEquals(List.of("000000", 42L), jdbcTemplate.lockedStatusArgs); + assertEquals(1, jdbcTemplate.sessionUpdateCount); + assertEquals(0, jdbcTemplate.assignmentInsertCount); + } + + @Test + public void markReviewedReturnsFalseWhenRecordIsNeitherPendingNorReviewed() { + MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "训练完成"); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + assertFalse(service.markReviewed(404L)); + assertEquals(0, jdbcTemplate.assignmentInsertCount); + } + + @Test + public void markReviewedExternalOverloadsAreTransactional() throws NoSuchMethodException { + assertTransactional(AihrPracticeSeedService.class.getMethod( + "markReviewed", Long.class, ReviewAdviceRequest.class)); + assertTransactional(AihrPracticeSeedService.class.getMethod( + "markReviewed", Long.class, ReviewAdviceRequest.class, String.class)); + } + + @Test + public void practiceTeamSnapshotUsesActiveScopedMembersAndAllStatusRows() throws Exception { + TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200); + List members = snapshotItems(snapshot, "members"); + List records = snapshotItems(snapshot, "records"); + List assignments = snapshotItems(snapshot, "assignments"); + + assertEquals(1, members.size()); + assertEquals("稳定员工", recordValue(members.get(0), "name")); + assertEquals("已复盘", recordValue(records.get(0), "status")); + assertEquals("已完成", recordValue(assignments.get(0), "status")); + + assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'")); + assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)")); + assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'")); + assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)")); + assertFalse(jdbcTemplate.recordsSql.contains("status = '待复盘'")); + assertTrue(jdbcTemplate.recordsSql.contains("ORDER BY finished_time DESC")); + assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.recordsArgs); + assertTrue(jdbcTemplate.assignmentsSql.contains("tenant_id = ?")); + assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)")); + assertTrue(jdbcTemplate.assignmentsSql.contains("source <> 'daily'")); + assertTrue(jdbcTemplate.assignmentsSql.indexOf("source <> 'daily'") + < jdbcTemplate.assignmentsSql.indexOf("LIMIT ?")); + assertFalse(jdbcTemplate.assignmentsSql.contains("status = '待训练'")); + assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.assignmentsArgs); + } + + @Test + public void markReviewedInitializesAssignmentTableBeforeSessionUpdate() { + ReviewAssignmentOrderJdbcTemplate jdbcTemplate = new ReviewAssignmentOrderJdbcTemplate(); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + ReviewAdviceRequest request = new ReviewAdviceRequest( + "主管", + "需要补练", + null, + "complaint-water", + "复盘后专项" + ); + + assertTrue(service.markReviewed(42L, request)); + + int sessionUpdate = jdbcTemplate.events.indexOf("session-update"); + int assignmentInsert = jdbcTemplate.events.indexOf("assignment-insert"); + int lastAssignmentDdl = jdbcTemplate.events.lastIndexOf("assignment-ddl"); + assertTrue(lastAssignmentDdl >= 0); + assertTrue(lastAssignmentDdl < sessionUpdate, jdbcTemplate.events.toString()); + assertTrue(sessionUpdate < assignmentInsert, jdbcTemplate.events.toString()); + } + + @Test + public void supervisorControllerEndpointsRequireValidatedSupervisorScope() 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("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),")); + assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, request, supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())")); + assertTrue(controllerSource.contains("private String supervisorScopeExtPartyId()")); + assertTrue(controllerSource.contains("mobileSeedService.requireSupervisorIdentity(currentAppUsername())")); + assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()")); + assertFalse(controllerSource.contains("supervisorScopeExtPartyId() {\n return \"\";")); + assertFalse(controllerSource.contains("supervisor H5 keeps team-scope")); + } + + @Test + public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception { + TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + Object snapshot = invokePracticeTeamSnapshot(service, "", 200); + List members = snapshotItems(snapshot, "members"); + + assertEquals(1, members.size()); + assertEquals("真实训练员工", recordValue(members.get(0), "name")); + assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session")); + assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment")); + assertFalse(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot")); + assertEquals(List.of("000000", "000000"), jdbcTemplate.membersArgs); + } + + @Test + public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception { + TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200); + List members = snapshotItems(snapshot, "members"); + + assertEquals(1, members.size()); + assertEquals("真实训练员工", recordValue(members.get(0), "name")); + assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session")); + assertTrue(jdbcTemplate.membersSql.contains("mode = 'mobile'")); + assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment")); + assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?)")); + assertEquals(List.of("000000", "SUP-PHONE", "000000", "SUP-PHONE"), jdbcTemplate.membersArgs); + assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?)")); + assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.recordsArgs); + assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?)")); + assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.assignmentsArgs); + } + @Test public void competencyDoesNotSeedScoreForEmptyEmployee() { JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); @@ -208,6 +470,7 @@ public class AihrPracticeSeedServiceTest { try { int intValue = Integer.parseInt(entry.getValue()); when(resultSet.getInt(entry.getKey())).thenReturn(intValue); + when(resultSet.getLong(entry.getKey())).thenReturn((long) intValue); } catch (NumberFormatException ignored) { // String-only test column. } @@ -217,6 +480,127 @@ public class AihrPracticeSeedServiceTest { return mapped; } + private static void assertTransactional(Method method) { + Transactional annotation = method.getAnnotation(Transactional.class); + assertTrue(annotation != null, method + " must be transactional"); + assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class)); + } + + private static Object invokePracticeTeamSnapshot(AihrPracticeSeedService service, String supervisorExtPartyId, int limit) + throws Exception { + Method method = AihrPracticeSeedService.class.getMethod("practiceTeamSnapshot", String.class, int.class); + return method.invoke(service, supervisorExtPartyId, limit); + } + + @SuppressWarnings("unchecked") + private static List snapshotItems(Object snapshot, String accessor) throws Exception { + return (List) snapshot.getClass().getMethod(accessor).invoke(snapshot); + } + + private static Object recordValue(Object record, String accessor) throws Exception { + return record.getClass().getMethod(accessor).invoke(record); + } + + private static final class RecordingTransactionManager implements PlatformTransactionManager { + private TransactionDefinition definition; + private int starts; + private int commits; + private boolean active; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + this.definition = definition; + starts += 1; + active = true; + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + assertTrue(active); + commits += 1; + active = false; + } + + @Override + public void rollback(TransactionStatus status) { + active = false; + throw new AssertionError("pilot export should not roll back"); + } + } + + private static final class PilotExportJdbcTemplate extends JdbcTemplate { + private final RecordingTransactionManager transactionManager; + private String metricsSql = ""; + private String sessionRowsSql = ""; + private boolean scenarioPrepared; + private boolean sopReviewPrepared; + private boolean sopQueryInTransaction; + + private PilotExportJdbcTemplate(RecordingTransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + + @Override + public void execute(String sql) { + assertFalse(transactionManager.active); + if (sql.contains("aihr_practice_scenario")) { + scenarioPrepared = true; + } + if (sql.contains("aihr_sop_answer_review")) { + sopReviewPrepared = true; + } + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (requiredType == Integer.class) { + return requiredType.cast(1); + } + return null; + } + + @Override + public T queryForObject(String sql, RowMapper rowMapper, Object... args) { + assertTrue(transactionManager.active); + assertTrue(scenarioPrepared); + if (sql.contains("aihr_sop_answer_review")) { + assertTrue(sopReviewPrepared); + sopQueryInTransaction = true; + } else { + metricsSql = sql; + } + try { + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.getInt(ArgumentMatchers.anyString())).thenReturn(1); + when(resultSet.getDouble("average_score")).thenReturn(86.0d); + return rowMapper.mapRow(resultSet, 0); + } catch (SQLException e) { + throw new AssertionError(e); + } + } + + @Override + public List> queryForList(String sql, Object... args) { + assertTrue(transactionManager.active); + assertTrue(scenarioPrepared); + sessionRowsSql = sql; + return List.of(Map.ofEntries( + Map.entry("formal_ext_party_id", "FORMAL-1"), + Map.entry("project_codes", "P1"), + Map.entry("session_id", "session-1"), + Map.entry("trainee_name", "员工甲"), + Map.entry("scenario_name", "投诉处理"), + Map.entry("total_score", 86), + Map.entry("status", "已复盘"), + Map.entry("finished_time", Timestamp.valueOf("2026-07-08 10:00:00")), + Map.entry("summary", "正式试点记录"), + Map.entry("review_advice", "保持回访"), + Map.entry("incentive_point", 3) + )); + } + } + private static final class AssignmentJdbcTemplate extends JdbcTemplate { private final String ownerLevel; private final List scopeIds; @@ -326,4 +710,265 @@ public class AihrPracticeSeedServiceTest { } } + private static final class MarkReviewedJdbcTemplate extends JdbcTemplate { + private final int transitionResult; + private final String lockedStatus; + private String sessionUpdateSql = ""; + private int sessionUpdateCount; + private String lockedStatusSql = ""; + private List lockedStatusArgs = List.of(); + private int assignmentInsertCount; + + private MarkReviewedJdbcTemplate(int transitionResult, String lockedStatus) { + this.transitionResult = transitionResult; + this.lockedStatus = lockedStatus; + } + + @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 List query(String sql, RowMapper rowMapper, Object... args) { + if (!sql.contains("FOR UPDATE")) { + return List.of(); + } + lockedStatusSql = sql; + lockedStatusArgs = List.of(args); + if (lockedStatus == null) { + return List.of(); + } + try { + return mapRows(rowMapper, List.of(Map.of("status", lockedStatus))); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int update(String sql, Object... args) { + if (sql.contains("UPDATE aihr_practice_session")) { + sessionUpdateSql = sql; + sessionUpdateCount += 1; + return transitionResult; + } + if (sql.contains("INSERT INTO aihr_practice_assignment")) { + assignmentInsertCount += 1; + return 1; + } + return 0; + } + + @Override + public void execute(String sql) { + // Schema setup is irrelevant for transition tests. + } + } + + private static final class SupervisorAuthorizationJdbcTemplate extends JdbcTemplate { + private final Map positionLevels; + private String authorizationSql = ""; + private List authorizationArgs = List.of(); + private int authorizationQueryCount; + + private SupervisorAuthorizationJdbcTemplate(Map positionLevels) { + this.positionLevels = positionLevels; + } + + @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 List query(String sql, RowMapper rowMapper, Object... args) { + authorizationSql = sql; + authorizationArgs = List.of(args); + authorizationQueryCount += 1; + String positionLevel = positionLevels.get(String.valueOf(args[1])); + if (positionLevel == null) { + return List.of(); + } + try { + return mapRows(rowMapper, List.of(Map.of("position_level", positionLevel))); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + } + + private static final class TeamSnapshotJdbcTemplate extends JdbcTemplate { + private final boolean orgSnapshotAvailable; + private String membersSql = ""; + private List membersArgs = List.of(); + private String recordsSql = ""; + private List recordsArgs = List.of(); + private String assignmentsSql = ""; + private List assignmentsArgs = List.of(); + + private TeamSnapshotJdbcTemplate(boolean orgSnapshotAvailable) { + this.orgSnapshotAvailable = orgSnapshotAvailable; + } + + @Override + public T queryForObject(String sql, Class requiredType) { + if (sql.contains("information_schema.TABLES") && sql.contains("aihr_org_snapshot")) { + return requiredType.cast(orgSnapshotAvailable ? 1 : 0); + } + return requiredType.cast(1); + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.contains("information_schema.COLUMNS") && sql.contains("aihr_org_snapshot")) { + return requiredType.cast(orgSnapshotAvailable ? 1 : 0); + } + return requiredType.cast(1); + } + + @Override + public void execute(String sql) { + // Schema setup is irrelevant for snapshot query tests. + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + try { + if (sql.contains("SELECT project_code, position_level")) { + return mapRows(rowMapper, List.of(Map.of( + "project_code", "P1", + "position_level", "主管" + ))); + } + if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) { + return mapRows(rowMapper, List.of( + Map.of("ext_party_id", "EMP-SUP"), + Map.of("ext_party_id", "EMP-1") + )); + } + if (sql.contains("SELECT person_phone") && sql.contains("ORDER BY position_level")) { + return mapRows(rowMapper, List.of( + Map.of("person_phone", "SUP-PHONE"), + Map.of("person_phone", "EMP-PHONE-1") + )); + } + if (sql.contains("FROM aihr_org_snapshot") && sql.contains("AS name")) { + membersSql = sql; + membersArgs = List.of(args); + return mapRows(rowMapper, List.of(Map.of( + "ext_party_id", "EMP-PHONE-1", + "name", "稳定员工" + ))); + } + if (sql.contains("UNION ALL") + && sql.contains("FROM aihr_practice_session") + && sql.contains("FROM aihr_practice_assignment")) { + membersSql = sql; + membersArgs = List.of(args); + return mapRows(rowMapper, List.of(Map.of( + "ext_party_id", sql.contains("ext_party_id IN") ? "SUP-PHONE" : "REAL-1", + "name", "真实训练员工" + ))); + } + if (sql.contains("SELECT id, session_id") && sql.contains("FROM aihr_practice_session")) { + recordsSql = sql; + recordsArgs = List.of(args); + String extPartyId = sql.contains("ext_party_id IN") + ? "SUP-PHONE" + : orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1"; + return mapRows(rowMapper, List.of(Map.of( + "id", "1", + "session_id", "session-1", + "trainee_name", "稳定员工", + "scenario_name", "新高分场景", + "total_score", "93", + "status", "已复盘", + "summary", "已完成复盘", + "ext_party_id", extPartyId + ))); + } + if (sql.contains("SELECT id, ext_party_id") && sql.contains("FROM aihr_practice_assignment")) { + assignmentsSql = sql; + assignmentsArgs = List.of(args); + String extPartyId = sql.contains("ext_party_id IN") + ? "SUP-PHONE" + : orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1"; + return mapRows(rowMapper, List.of(Map.of( + "id", "2", + "ext_party_id", extPartyId, + "trainee_name", orgSnapshotAvailable ? "稳定员工" : "真实训练员工", + "scenario_id", "complaint-water", + "scenario_name", "投诉接待", + "source", "manual", + "reason", "历史专项", + "status", "已完成" + ))); + } + return List.of(); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + } + + private static final class ReviewAssignmentOrderJdbcTemplate extends JdbcTemplate { + private final List events = new ArrayList<>(); + + @Override + public T queryForObject(String sql, Class requiredType) { + return requiredType.cast(1); + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.contains("TABLE_NAME = 'aihr_practice_assignment'")) { + return requiredType.cast(0); + } + return requiredType.cast(1); + } + + @Override + public int update(String sql, Object... args) { + if (sql.contains("UPDATE aihr_practice_session")) { + events.add("session-update"); + return 1; + } + if (sql.contains("INSERT INTO aihr_practice_assignment")) { + events.add("assignment-insert"); + return 1; + } + return 0; + } + + @Override + public void execute(String sql) { + if (sql.contains("aihr_practice_assignment")) { + events.add("assignment-ddl"); + } + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + try { + if (sql.contains("SELECT ext_party_id") && sql.contains("FROM aihr_practice_session")) { + return mapRows(rowMapper, List.of(Map.of("ext_party_id", "EMP-1"))); + } + return List.of(); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + } + } diff --git a/docs/20260708/uni-app用户侧前端重建计划.md b/docs/20260708/uni-app用户侧前端重建计划.md index ff0ea8d1..f78717b2 100644 --- a/docs/20260708/uni-app用户侧前端重建计划.md +++ b/docs/20260708/uni-app用户侧前端重建计划.md @@ -28,7 +28,15 @@ mobile-uni/ user/practice/ user/profile/ supervisor/index/ + supervisor/team/ + supervisor/assign/ + supervisor/reviews/ + supervisor/review/ candidate/index/ + candidate/interview/ + candidate/materials/ + candidate/progress/ + candidate/study/ services/ api.ts auth.ts @@ -109,7 +117,11 @@ mobile-uni/ - 打勾/打叉提交后页面锁定反馈状态,失败时保留原状态。 - 总结卡显式点击才生成,失败可重试。 -## 6. 当前进展 +## 6. 当前基线与验证记录 + +截至 2026-07-10,`mobile-uni/` 已是用户、主管、候选人三端的现役页面栈,旧 `mobile/` 仅作 MVP 兜底。三端均以登录身份和真实 API 驱动:员工完成训练、问师傅和成长回读;主管按后端组织范围进行团队、复盘和专项指派;候选人按真实面试/资料状态进入独立线性页面。H5 构建、真实浏览器主链路和接口回读均已完成验证,不使用前端 mock 掩盖业务状态。 + +正式试点尚未通过:组织同步 dry-run 的 3474 名上游员工中仅 1 名具备可用于移动端身份映射的手机号,另有 8 条疑似乱码;严格门禁在窗口 `2026-07-07..2026-07-10` 只识别到 1 名正式人员且无人完成 10 次训练。下一阶段优先补齐上游正式组织数据并执行联合试点验收,不继续把功能堆回首页或旧单页。 - 第一批用户侧主链路已落地到 `mobile-uni/`: 登录、今日页、问师傅、答案反馈、总结卡、对练页、个人页。 - 本机开发入口已切到 portless: `https://wygj-mobile-uni.localhost/h5/`。 @@ -139,7 +151,7 @@ mobile-uni/ - 主管端已补团队画像: 复用真实待复盘记录和训练指派数据聚合个人风险、待复盘、待训练和最近得分,可一键带入专项指派。 - 主管端从团队画像派专项时已保留员工姓名和外部 ID: 指派记录不再把已选员工统一写成“手机用户xxxx”。 - 主管端复盘已支持顺手派下一次训练: 复用 `ReviewAdviceRequest.nextScenarioId/nextReason`,标记已复盘时可同步生成专项训练。 -- 主管端团队预警的聚合动作已改为“去画像”: 预警本身不含员工标识,点击后滚动定位团队画像,具体派发仍从画像员工行进入,避免一键误派给默认手机号。 +- 主管端团队预警的聚合动作已改为进入独立团队画像页: 预警本身不含员工标识,具体派发仍从画像员工行进入,避免一键误派给默认手机号。 - 主管端指派专项已收敛操作面板: 默认不再把主管本人手机号当指派对象,必须选择员工或手动输入;历史记录只展示最近专项,不混入每日三题流水。 - 主管端团队画像待训练口径已收敛: 只统计主管指派专项,不再把员工每日三题误算进主管待训练。 - 主管端概览低分口径已收敛: 顶部低分预警直接跟随后端 `low_score` 预警数量,不再用当前画像截断列表推导高风险人数。 @@ -207,7 +219,7 @@ mobile-uni/ - 候选人端审核中主动作已收敛: 资料待审核/已通过时主按钮只刷新进度,不再提示“重新面试”造成阶段误导;待补资料/资料驳回仍走上传。 - 主管端派专项已补场景自动匹配: 从团队画像选择员工时按投诉/报修/噪音/电梯/催缴等关键词选对应专项,不再把非催费低分默认派成催费沟通。 - 主管端最近专项回读已修正: 后端专项列表改为非 daily 优先,避免每日三题占满前 10 条导致手工指派成功后页面仍显示 0 条。 -- 主管端团队画像派专项已用真实浏览器跑通: 从画像低分员工预填对象和场景后提交,最近专项立即回读新指派记录;低分预警“去画像”锚点已落到团队画像面板。 +- 主管端团队画像派专项已用真实浏览器跑通: 从画像低分员工预填对象和场景后提交,最近专项立即回读新指派记录;低分预警进入独立团队画像页,不再依赖首页锚点。 - 候选人端页头称呼已收敛: 已登录副标题改为手机号尾号,不再和页面标题拼出“候选人候选人0001”。 - 候选人端未登录主任务卡已收起: 未登录只保留登录面板,不再同时出现两处“去登录”入口。 - 主管端低分预警已落到可操作对象: 待复盘列表优先返回低分记录,团队概览用全量预警数展示,避免“有低分预警但画像看不到低分员工”。 @@ -237,6 +249,24 @@ mobile-uni/ - H5 本地验证噪音已收敛:关闭 uni 统计自动上报并复用现有静态图作为 favicon,真实 390px 浏览器扫描无 `tongji-collector`/`WebTrack.gif`/`favicon.ico` 坏资源。 - 员工练习页场景说明已跟随真实预习卡/主管指派:场景卡优先展示 `/prep-card` 返回的要点,其次展示专项原因,不再固定写“投诉服务或设施问题”。 - 员工端岗位确认提示已对齐正式组织数据边界:组织快照无法按手机号匹配时,明确提示可能是手机号未开放或已脱敏,再让用户手选岗位。 +- 主管端已完成真实页面栈拆分: 概览、团队画像、专项指派、复盘列表、复盘详情分别落到独立路由,首页只保留指标、预警和待办摘要,不再把功能揉成长页面或用锚点模拟导航。 +- 主管端团队数据已接正式范围接口: `GET /api/aihr/mobile/practice/team` 一次返回组织成员、全状态训练记录和非 daily 专项;后端先按租户、主管项目范围和移动端模式过滤,再做列表限制。 +- 主管权限已以后端组织快照为准: 只有在职“主管/项目经理”可访问团队、预警、复盘和指派接口;普通员工真实浏览器验证只显示“无主管权限”及返回员工端/切换账号,不泄露团队指标和表单草稿。 +- 主管复盘写入已补事务和幂等: 仅 `待复盘 -> 已复盘` 的首次状态转换创建后续专项;并发重复请求通过行锁读取最终状态,不重复派发。 +- 主管端已用真实项目经理账号验证组织范围: 本地正式快照返回 19 名团队成员、1 条待复盘、2 项待训练和 1 条低分预警;团队页、复盘页和指派页均读取真实接口,不补前端 mock。 +- 正式组织快照中的不可读问号姓名在用户侧回退为手机号尾号或外部 ID 末尾标识,真实 ID 保持不变;开放组织 dry-run 已确认源接口自身仍返回 8 条疑似乱码,需由上游修复后再同步。 +- 候选人端已完成线性页面栈拆分: 原 750 行长页收敛为候选首页、面试任务、补充资料、进度通知和岗前预习 5 个路由;首页只展示真实状态和办理入口,不再原地展开所有表单和结果。 +- 候选人流程状态已抽成共享纯函数: 只根据当前候选人的正式面试记录和最新资料状态派生“待面试/待补资料/审核中/已通过”,不读取公开 home seed,不补前端 mock。 +- 候选面试页继续复用真实 `/api/recruit/interview/start|answer|finish|records`: 浏览器以当前移动账号验证 `/start` 返回 3 道岗位题,未提交答案和评分,避免产生额外正式面试记录。 +- 候选资料页继续复用真实 multipart 上传和本人资料列表: 本轮浏览器只验证正式空态与上传入口,未上传测试文件污染资料库;已有上传-审核-回读证据继续有效。 +- 候选进度页按正式记录展示三步办理状态和证据,面试未完成时明确停在第 1 步,不把空数据渲染成已办理。 +- 候选岗前预习页改为自然业务问题检索正式 SOP: 浏览器验证投诉预习返回正式答案、关键要点、注意事项,引用命中 `投诉处理 SOP v1.0`、`生活顾问的一天.mp4` 和正式课件,无前端示例答案。 +- 演示预检已跟随页面栈更新: `scripts/demo-check.sh` 改为检查主管复盘详情和候选 4 个子路由,不再在旧主管首页查复盘表单标记;本地数据库连接不可用时非严格模式明确跳过样本、严格模式继续失败。 +- 本地连接耗尽已处置: 清理未被 portless 使用的旧后端实例并重启本地 MySQL 后,连接稳定在约 `11/151`,当前 API HTTP 200;`dev.sh stop/restart` 已改为安全终止 portless 进程树,避免旧 Java/Vite 子进程继续占用连接池。 +- 试点严格预检已改为正式批次口径: 必须指定起止日期,训练、校准和 SOP 评审只统计该时间窗;人员通过唯一手机号映射到在职组织,排除重复手机号和身份碰撞,完训定义为每人至少 10 次。`2026-07-07..2026-07-10` 实测开发活动身份 50 人、10 次完训 0 人,但正式组织仍为 `org_login=1/3001`、`org_pilot=1/20`、`org_ten_sessions=0/1`,所以严格模式正确失败。开放组织 dry-run 返回 `employees=3474 phone_linked=1 masked_phone=3473 suspect_text=8 synced=0`,且 dry-run 不访问本地表结构或写库,证明手机号和 8 条问号数据来自上游且本轮未覆盖快照。 +- 管理端试点 CSV 已与严格预检统一口径: 必须选择起止日期,只导出窗口内唯一匹配正式组织的训练明细,完训按每人至少 10 次,校准必须关联正式试点会话。真实 API 返回正式试点 1 人/1 项目、10 次完训 `0/1`、校准 `0/20`、窗口内 SOP `2/2`,总闸门为 `false`;此前开发会话中的 20 条校准不再计入正式验收。 +- 发布会正式候选题已重新走查: Q01-Q12 `12/12` 返回有效答案和依据,来源告警 `0`,向量状态 `450/450`;仍有 119 条 SOP 问答待业务评审,不把开发方走查替代业务签字。 +- 下一阶段进入三端联合发布门槛: 复跑员工-主管-候选人角色切换、清理本地验证数据、核对正式组织姓名编码并完成 H5 发布前验收;不把本次页面拆分等同一期生产验收完成。 ## 7. 风险 @@ -244,3 +274,4 @@ mobile-uni/ - H5 路由和将来小程序路径不完全一致,页面命名要早期克制。 - 旧 H5 在 Alpha 前仍承担演示兜底,新工程未完成替换前不能下线旧入口。 - 如果后端某些接口仍要求管理端 token,需要后端补移动端认证口径,不能在新前端绕过认证。 +- 当前本地组织快照存在部分不可读姓名,前端已做可识别展示兜底,但正式试点前仍需从开放组织同步源修复编码并重跑快照同步。 diff --git a/docs/20260708/发布会演示候选题单.md b/docs/20260708/发布会演示候选题单.md index ee84dc21..186c8814 100644 --- a/docs/20260708/发布会演示候选题单.md +++ b/docs/20260708/发布会演示候选题单.md @@ -3,6 +3,7 @@ > 生成时间:2026-07-08 > 来源口径:基于已入库正式资料生成,不使用本地 smoke 题作为验收依据。 > 状态说明:本文件是开发方候选题单,最终题单需业务方确认后锁定。 +> 最近复核:2026-07-10 运行 `node scripts/verify-demo-questions.mjs --prefix=Q`,Q01-Q12 结果 `12/12`、来源告警 `0`、向量 `450/450`。 ## 生成依据 diff --git a/docs/AI陪练二期开发推进计划.md b/docs/AI陪练二期开发推进计划.md index 3b09accc..79351e6a 100644 --- a/docs/AI陪练二期开发推进计划.md +++ b/docs/AI陪练二期开发推进计划.md @@ -9,6 +9,7 @@ > v0.6 修订:并入 2026-07-07 立项会结论(见《银城员工端APP分阶段实施总纲》)——本计划升格为总纲**阶段一**的执行文档;时间锚点对齐 Alpha/Beta/半年会/收费季;收费场景权重上调;新增新员工每日三题;埋 `knowledge_gap` 钩子;岗位术语待 M0 对齐。 > v0.7 确认:客户方确认"管家"="生活顾问"(同一岗位,术语统一为"管家(生活顾问)",`position` 标签不迁移);APP 形态定为 H5 套壳(维持 D6)。M0 两项待确认项关闭。 > v0.8 修订:并入 2026-07-08 产品讨论结论(见《数字师傅学练问报整合方案 v1.1》,docs/20260708/)——①M1 新增 `sop_answer` 直给格式模板(结论先行 + 一二三四五编号步骤 + 引用后置),随 2026-07-10 测试版上线;②M2 新增对练回复调节按钮(turn 支持 style 重生成)与问答答案打勾/打叉反馈;③师徒演练暂缓升级为**定向师带徒不做**,由阶段二开放问题榜承载(主管传帮带工作台不受影响);④M4 成长路径补学分/统考资格口径。 +> v0.9 同步:正式试点导出和严格门禁已完成工程收口。两者均强制批次起止日、唯一在职组织身份和每人至少 10 次完训;校准/SOP 门禁以原始计数判定,不以四舍五入后的比率反推。当前仍等待上游提供至少 20 名可登录正式试点人员及业务评审,不将工程就绪写成业务验收通过。 > 结论:二期不做“24 个缺口全量铺开”,先把 AI 陪练从 MVP 演示闭环推进到**生活顾问岗位试点可用**。 ## 1. 二期目标 @@ -249,6 +250,9 @@ - 住宅类 SOP 问答人工评审可用率目标 ≥80%。 - 语音单轮响应目标 ≤5 秒;超过时有文本降级。 - 试点数据可导出:训练次数、完训率、平均分、预警数、主管复盘率。 +- 导出与严格预检必须指定同一批次起止日;只统计能唯一映射到在职组织的正式会话,明细保留正式身份和项目,并同时导出校准命中数、SOP 可用数及对应比率。 + +当前状态(2026-07-10):工程链路、真实 API 验证、回归和复审已完成;本地正式窗口 `2026-07-07..2026-07-10` 只有 1 名可登录正式人员、`0/1` 人达到十次完训、正式校准 `0/20`、已评审 SOP `2/2`,另有 119 条待评审。因此 M5 的**工程能力已就绪,正式业务试点未通过**。下一动作是上游修复/开放正式手机号和姓名编码后做 dry-run、同步,再按真实试点批次积累训练、校准和 SOP 评审证据。 ## 5. 二期 API 建议 @@ -430,3 +434,4 @@ - 画像能支撑“下一步练什么”的规则分发。 - 系统能产出初/中/高级生活顾问评级证据包,但不自动给出晋升结论。 - 试点所需的合规、成本、人工校准底线已经留好。 +- 二期技术闭环完成不等于一期业务验收完成;业务通过必须以 M5 的正式组织数据、训练记录和业务评审证据为准。 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 35fa03cd..4a5c8a1b 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -8,15 +8,16 @@ |---|---|---| | AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 | +| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数和 SOP 可用数,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 | | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed | | 资料处理 `/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)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | -| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页大小参数按线上接口使用 `limit`,映射并刷新 `aihr_org_snapshot`;员工手机号只落 `person_phone` 用于移动端登录身份映射,不在组织快照列表响应暴露;岗位优先识别 `position/job_title/post/job_name/role/title` 等字段,避免正式数据把主管/项目经理误落为一线;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL 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` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。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 代码级强制失效) | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | -| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;主管端复盘默认按登录手机号映射组织快照后做项目/主管范围过滤,组织快照缺失时保持演示降级不阻断;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV | -| 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;APP 候选人资料、面试启动和面试记录以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 | +| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | +| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | ## 后端落点 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 1e695a8e..73b7d893 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -15,7 +15,7 @@ | 顺序 | 页面 | 点击路径 | 必须看到 | |---|---|---|---| | 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” | -| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端今日页进入问师傅、练习、学习页、案例素材和个人页;学习页读取今日任务、岗位 SOP 和训练前预习卡;练习页完成开始练习、提交回应、结束评分、每日三题提交;问师傅页可检索、反馈、生成总结卡;案例素材页可上传语音并回读入库记录;候选人端点“开始面试/面试练习”和“补充资料”;主管端查看团队画像、待复盘、标记已复盘并从画像派专项 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;学习页不再落到「建设中」,内容来自现有任务、知识库和预习卡接口;底部 tab 整页切换(员工端为 今日/练/问/我);未实现入口进「建设中」整页(返回按钮),无死按钮;主管端待复盘列表增加,复盘详情可看评分、话术、导师改写,并可标记已复盘;从团队画像派专项后最近专项立即回读;候选人端可拉题/答题/评分,资料上传后显示审核状态;HR 审核后候选人端显示 `已通过/已驳回` | +| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 | | 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 | | 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | | 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 | @@ -29,10 +29,12 @@ | 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 | | 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | | 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 | -| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 导出试点 CSV | 导出包含训练次数、完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率 | +| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 | | 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 | -试点正式验收前,用 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门;人员完训率、校准样本、AI/人工分档一致率和 SOP 可用率未达标时应失败,不用静态演示 marker 代替真实试点数据。 +试点正式验收前,用 `AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,日期替换为本批试点的真实起止日。闸门只统计该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。 + +2026-07-10 本地快照(时间窗 `2026-07-07..2026-07-10`):场景 `12/12`、开发活动身份 `50` 人,但达到 10 次对练者 `0` 人;窗口内能关联正式试点会话的校准样本为 `0/20`,因此分档一致率暂不可验收;已评审 SOP `2/2` 可用,另有 `119` 条待评审。严格预检当前**失败**于正式组织人员门槛:3001 名本地在职人员只有 1 名具备唯一可用手机号,活动数据也只有 1 人能唯一匹配正式组织,且该人员达到 10 次对练的数量为 `0/1`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。 ## 录屏兜底 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 9bc031b5..1216d362 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -38,6 +38,8 @@ portless run --name wygj-api ./scripts/dev-backend.sh ``` +重复启动或切换分支时优先使用 `./scripts/dev.sh restart`,停止使用 `./scripts/dev.sh stop`。脚本会递归终止 portless 包装进程及其 Java/Vite 子进程,避免旧后端脱离后继续占用 MySQL 连接池;进程树回归可用 `bash scripts/tests/process-tree.test.sh` 验证。 + 也可以只让 portless 按根目录 `portless.json` 启动前端工程: ```bash @@ -48,6 +50,12 @@ portless Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/api/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;业务请求会用 client secret 生成 HMAC-SHA256 hex 签名。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。若开放平台凭证放在 `backend/.env`,启动脚本也会加载该文件,并把 `client_id`/`client_secret` 映射为组织同步实际读取的 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`。 +组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。只有 `phone_linked` 能覆盖至少 20 名正式试点人员且上游修复疑似乱码后,才切换管理端组织同步为写入模式。 + +正式试点预检必须指定当前批次时间窗,例如:`AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受 `YYYY-MM-DD`,只统计窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。 + +管理端正式试点 CSV 也必须显式选择同一批次日期。可用真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。 + 本地 `.env.local` 示例: ```bash diff --git a/docs/README.md b/docs/README.md index f7922e21..c801a2f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,11 +8,11 @@ |---|---| | [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 | -| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、业务模块落点、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | +| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | | [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 | | [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜) → 北森对接;身份三轴模型与防遗漏分发策略 | -| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 | +| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:产品定义(学练问报+问题榜+学分晋升)与半年会发布会方案分层;客户"第三版"逐项处置对照 | | [基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get 笔记】.md](基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get%20笔记】.md) | 2026-07-07 立项会纪要原文(总纲的输入源,只作留档) | diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index debdf407..a8fb59cb 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -217,10 +217,11 @@ export function calibratePracticeSession(sessionId: string, data: PracticeCalibr }); } -export function exportPracticePilotCsv(): Promise { +export function exportPracticePilotCsv(params: { startDate: string; endDate: string }): Promise { return request({ url: '/api/train/practice/export', method: 'get', + params, responseType: 'blob' }) as unknown as Promise; } diff --git a/frontend/src/views/train/pilot-export.test.ts b/frontend/src/views/train/pilot-export.test.ts new file mode 100644 index 00000000..0c9d3e6c --- /dev/null +++ b/frontend/src/views/train/pilot-export.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { resolvePilotDateWindow } from './pilot-export'; + +describe('resolvePilotDateWindow', () => { + it('returns null when the Element Plus date range is cleared', () => { + expect(resolvePilotDateWindow(null)).toBeNull(); + }); + + it('returns an explicit start and end date only for a complete range', () => { + expect(resolvePilotDateWindow(['2026-07-07', '2026-07-10'])).toEqual({ + startDate: '2026-07-07', + endDate: '2026-07-10' + }); + }); +}); diff --git a/frontend/src/views/train/pilot-export.ts b/frontend/src/views/train/pilot-export.ts new file mode 100644 index 00000000..e4eced67 --- /dev/null +++ b/frontend/src/views/train/pilot-export.ts @@ -0,0 +1,15 @@ +export type PilotDateWindow = { + startDate: string; + endDate: string; +}; + +export function resolvePilotDateWindow(value: unknown): PilotDateWindow | null { + if (!Array.isArray(value) || value.length !== 2) { + return null; + } + const [startDate, endDate] = value; + if (typeof startDate !== 'string' || typeof endDate !== 'string' || !startDate || !endDate) { + return null; + } + return { startDate, endDate }; +} diff --git a/frontend/src/views/train/practice.vue b/frontend/src/views/train/practice.vue index 5d1ad593..10e2635d 100644 --- a/frontend/src/views/train/practice.vue +++ b/frontend/src/views/train/practice.vue @@ -8,7 +8,17 @@
{{ apiPill.text }} {{ flowPill.text }} - 导出试点CSV + + 导出试点CSV 返回首页 重置流程
@@ -292,7 +302,7 @@ + + diff --git a/mobile-uni/src/pages/candidate/materials/index.vue b/mobile-uni/src/pages/candidate/materials/index.vue new file mode 100644 index 00000000..8e3e37f5 --- /dev/null +++ b/mobile-uni/src/pages/candidate/materials/index.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/mobile-uni/src/pages/candidate/progress/index.vue b/mobile-uni/src/pages/candidate/progress/index.vue new file mode 100644 index 00000000..201a740e --- /dev/null +++ b/mobile-uni/src/pages/candidate/progress/index.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/mobile-uni/src/pages/candidate/study/index.vue b/mobile-uni/src/pages/candidate/study/index.vue new file mode 100644 index 00000000..8453a42e --- /dev/null +++ b/mobile-uni/src/pages/candidate/study/index.vue @@ -0,0 +1,333 @@ +