diff --git a/AGENTS.md b/AGENTS.md index 9695e681..80c3f10c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ - 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向。底栏固定「今日/练/问/我」;问师傅文字和现场附件查询复用 `/api/knowledge/query`、`/api/knowledge/query-media`,内部登录端可带 `conversationId/contextVersion` 使用 30 分钟、最近 6 轮的短期会话,原文件/视频只通过 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权交付;总结卡复用 `/api/knowledge/summary-card`,失败不兜假数据。显式“记一下/帮我记/保存一下”复用同一 query 返回确认卡,确认后按 `PRIVATE/COMPANY` 写 `aihr_assistant_capture`;COMPANY 只表示 `PENDING` 待流转,不得冒充已派单或已送达。外部知识 API 保持无状态。 +- 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。 - 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 - 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。 @@ -78,6 +79,7 @@ - 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md` - AI 陪练二期推进(阶段一执行):`docs/AI陪练二期开发推进计划.md` - 数字师傅产品定案(2026-07-08 学练问报/问题榜/学分):`docs/20260708/数字师傅学练问报整合方案.md` +- 工作助手后续施工顺序:`docs/工作助手与今日工作成果迭代计划-20260721.md` - AI 陪练缺口修订:`docs/AI陪练功能优化与缺口分析(修订).md` - 后端 API 对接:`docs/API_INTEGRATION.md` - 对外会议版口径:`docs/AI人力资源系统项目规划方案与AI接口说明.md` diff --git a/README.md b/README.md index dec44635..a9b232a1 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | | [问师傅多轮会话与原始资料交付设计](docs/20260718/问师傅多轮会话与原始资料交付设计.md) | 已实现的短期会话、指代改写、意图路由、原文件/视频安全交付及分层验收记录 | +| [工作助手与今日工作成果迭代计划](docs/工作助手与今日工作成果迭代计划-20260721.md) | 项目名称选择、日常采集、来源追溯、今日成果、主管视图、成果投稿和外部流转的实施顺序;含当前本地实现、390×844 证据、权威高保真路径和剩余生产边界 | | [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md) | 现阶段结论、三端输入匹配矩阵、权限、迁移、线上资源和未完成项证据 | | [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 | | [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 | 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 71616860..43ae7273 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 @@ -29,6 +29,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse; import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse; +import org.dromara.aihr.domain.vo.AihrMobileDto.ProjectResponse; import org.dromara.aihr.learning.AihrExamService; import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest; import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse; @@ -112,20 +113,23 @@ public class AihrMobileController { public R me() { String phone = currentAppUsername(); if (phone.isBlank()) { - return R.ok(new IdentityResponse("user", "员工端", "", "一线")); + return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of())); } + List people = List.of(); var row = java.util.Optional.empty(); try { var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 500, 500); - row = snapshot.rows().stream() + var snapshotPeople = snapshot.rows(); + people = snapshotPeople; + row = snapshotPeople.stream() .filter(person -> "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel())) .findFirst() - .or(() -> snapshot.rows().stream().findFirst()); + .or(() -> snapshotPeople.stream().findFirst()); } catch (RuntimeException ex) { log.warn("mobile identity lookup failed, fallback to employee role(处理错误已隐藏)"); } if (row.isEmpty()) { - return R.ok(new IdentityResponse("user", "员工端", "", "一线")); + return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of())); } var person = row.get(); boolean supervisor = "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel()); @@ -133,10 +137,32 @@ public class AihrMobileController { supervisor ? "supervisor" : "user", supervisor ? "主管端" : "员工端", person.positionName(), - person.positionLevel() + person.positionLevel(), + projectOptions(people) )); } + static List projectOptions(List people) { + if (people == null || people.isEmpty()) { + return List.of(); + } + var projects = new java.util.LinkedHashMap(); + people.stream() + .filter(java.util.Objects::nonNull) + .filter(person -> person.projectCode() != null && !person.projectCode().isBlank()) + .sorted(java.util.Comparator.comparing( + person -> person.projectName() == null ? "" : person.projectName())) + .forEach(person -> { + String code = person.projectCode().trim(); + String name = person.projectName() == null || person.projectName().isBlank() + ? code : person.projectName().trim(); + projects.putIfAbsent(code, name); + }); + return projects.entrySet().stream() + .map(entry -> new ProjectResponse(entry.getKey(), entry.getValue())) + .toList(); + } + @GetMapping("/practice/history") public R> practiceHistory(@RequestParam(required = false) String extPartyId) { return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId))); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java index d5388386..a6307114 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java @@ -14,7 +14,14 @@ public final class AihrMobileDto { String role, String roleLabel, String positionName, - String positionLevel + String positionLevel, + List projects + ) { + } + + public record ProjectResponse( + String projectCode, + String projectName ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java index 3a437ca6..58c1e49b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java @@ -6,8 +6,11 @@ import lombok.RequiredArgsConstructor; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; +import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.common.core.domain.R; import org.dromara.system.service.ISysOssService; +import org.dromara.system.domain.vo.SysOssVo; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -29,6 +32,7 @@ public class AihrKnowledgeQueryController { private final AihrKnowledgeQueryService queryService; private final ISysOssService ossService; + private final AihrMemoryService memoryService; @SaCheckLogin @PostMapping("/query") @@ -42,15 +46,37 @@ public class AihrKnowledgeQueryController { @RequestParam(value = "queryText", required = false) String queryText, @RequestParam(value = "position", required = false) String position, @RequestParam(value = "conversationId", required = false) String conversationId, - @RequestParam(value = "contextVersion", required = false) Long contextVersion) { + @RequestParam(value = "contextVersion", required = false) Long contextVersion, + @RequestParam(value = "projectCode", required = false) String projectCode) { String question = queryText == null || queryText.isBlank() ? "请根据附件现场情况,告诉我应该按什么 SOP 处理" : queryText.trim(); QueryRequest request = new QueryRequest( question, List.of(), "sop", position, "mobile_uni_media", 5, null, - conversationId, contextVersion + conversationId, contextVersion, projectCode ); - return R.ok(queryService.queryInternalMedia(request, file)); + QueryResponse response = queryService.queryInternalMedia(request, file); + if (response.memoryCandidate() == null) { + return R.ok(response); + } + SysOssVo uploaded = ossService.upload(file); + try { + SourceSnapshot detected = response.memoryCandidate().source(); + SourceSnapshot source = new SourceSnapshot( + detected == null ? null : detected.sourceType(), + detected == null ? question : detected.transcript(), + uploaded.getOssId(), + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + detected == null ? null : detected.recognizedSummary() + ); + return R.ok(response.withMemoryCandidate( + memoryService.bindCandidateSource(response.memoryCandidate().id(), source))); + } catch (RuntimeException ex) { + ossService.deleteWithValidByIds(List.of(uploaded.getOssId()), false); + throw ex; + } } @SaCheckLogin diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java index 18f92f7f..6638f907 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -19,11 +19,19 @@ public final class AihrKnowledgeQueryDto { Integer limit, String toolCode, String conversationId, - Long contextVersion + Long contextVersion, + String projectCode ) { public QueryRequest(String queryText, List spaceCodes, String category, String position, String source, Integer limit, String toolCode) { - this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null); + this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null, null); + } + + public QueryRequest(String queryText, List spaceCodes, String category, String position, + String source, Integer limit, String toolCode, String conversationId, + Long contextVersion) { + this(queryText, spaceCodes, category, position, source, limit, toolCode, + conversationId, contextVersion, null); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java index 9705a034..410f2507 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java @@ -47,6 +47,7 @@ public class AihrKnowledgeConversationService { public ConversationContext prepare(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) { String conversationId = clean(request.conversationId()); + String projectCode = clean(request.projectCode()); if (conversationId.isBlank()) { return stateless(request.queryText(), request.toolCode()); } @@ -62,7 +63,11 @@ public class AihrKnowledgeConversationService { log.warn("knowledge conversation unavailable, using stateless query(处理错误已隐藏)"); RewritePlan plan = fallbackPlan(List.of(), request.queryText(), request.toolCode()); return new ConversationContext(conversationId, false, false, 0L, List.of(), - plan.rewrittenQuery(), plan.intent(), plan.toolCode()); + plan.rewrittenQuery(), plan.intent(), plan.toolCode(), projectCode); + } + + if (row != null && !clean(row.projectCode()).equals(projectCode)) { + throw new ServiceException("当前项目已切换,请开始新对话", HttpStatus.CONFLICT); } LocalDateTime now = LocalDateTime.now(); @@ -76,7 +81,7 @@ public class AihrKnowledgeConversationService { List turns = row == null || expired ? List.of() : readTurns(row.contextJson()); RewritePlan plan = rewrite(turns, request.queryText(), request.toolCode()); return new ConversationContext(conversationId, true, row != null, storedVersion, turns, - plan.rewrittenQuery(), plan.intent(), plan.toolCode()); + plan.rewrittenQuery(), plan.intent(), plan.toolCode(), projectCode); } public static ConversationContext stateless(String queryText, String explicitToolCode) { @@ -118,16 +123,17 @@ public class AihrKnowledgeConversationService { set version = ?, context_json = ?, expires_time = date_add(now(), interval 30 minute), update_time = now() where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? and version = ? + and coalesce(project_code, '') = ? """, nextVersion, json, principal.tenantId(), app.id(), principal.userId(), - context.conversationId(), context.storedVersion()); + context.conversationId(), context.storedVersion(), clean(context.projectCode())); } else { updated = jdbcTemplate.update(""" insert into aihr_knowledge_conversation - (tenant_id, app_id, user_id, conversation_id, version, context_json, + (tenant_id, app_id, user_id, conversation_id, project_code, version, context_json, expires_time, create_time, update_time) - values (?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now()) + values (?, ?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now()) """, principal.tenantId(), app.id(), principal.userId(), context.conversationId(), - nextVersion, json); + clean(context.projectCode()).isBlank() ? null : clean(context.projectCode()), nextVersion, json); } if (updated != 1) { throw conflict(); @@ -145,11 +151,12 @@ public class AihrKnowledgeConversationService { private ConversationRow findRow(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) { List rows = jdbcTemplate.query(""" - select version, context_json, expires_time + select project_code, version, context_json, expires_time from aihr_knowledge_conversation where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? limit 1 """, (rs, rowNum) -> new ConversationRow( + rs.getString("project_code"), rs.getLong("version"), rs.getString("context_json"), toLocalDateTime(rs.getTimestamp("expires_time")) @@ -345,8 +352,15 @@ public class AihrKnowledgeConversationService { List turns, String rewrittenQuery, String intent, - String toolCode + String toolCode, + String projectCode ) { + public ConversationContext(String conversationId, boolean stateful, boolean existingRow, + long storedVersion, List turns, String rewrittenQuery, + String intent, String toolCode) { + this(conversationId, stateful, existingRow, storedVersion, turns, rewrittenQuery, + intent, toolCode, null); + } } public record Turn( @@ -366,6 +380,6 @@ public class AihrKnowledgeConversationService { record RewritePlan(String rewrittenQuery, String intent, String toolCode) { } - private record ConversationRow(long version, String contextJson, LocalDateTime expiresTime) { + private record ConversationRow(String projectCode, long version, String contextJson, LocalDateTime expiresTime) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index 0393421f..a30861e5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -13,6 +13,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource; import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult; import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; +import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall; import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.aihr.service.AihrSopSeedService; @@ -27,6 +28,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -48,7 +50,7 @@ public class AihrKnowledgeQueryService { public QueryResponse queryInternal(QueryRequest rawRequest) { long queryStarted = System.nanoTime(); QueryRequest request = validate(rawRequest); - AihrKnowledgePrincipal principal = principalResolver.current(); + AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); ConversationContext context = conversationContext(principal, app, request); QueryRequest routed = routedRequest(request, context); @@ -93,7 +95,9 @@ public class AihrKnowledgeQueryService { } } MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null - : memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId()); + : memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId(), + new SourceSnapshot(sourceType(request.source()), truncate(request.queryText(), 4000), + null, null, null, null, null)); QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response); QueryResponse result = completed.withMemoryCandidate(candidate); if (finalizeAudit || candidate != null) { @@ -111,7 +115,7 @@ public class AihrKnowledgeQueryService { if (hasText(request.toolCode())) { throw new ServiceException("媒体提问不能调用内部数据工具", HttpStatus.BAD_REQUEST); } - AihrKnowledgePrincipal principal = principalResolver.current(); + AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); Set spaceIds = accessService.resolveInternalSpaceIds( principal, app, request.spaceCodes(), "READ"); @@ -125,14 +129,21 @@ public class AihrKnowledgeQueryService { + "\n附件现场信息:" + truncate(extractedContext, 520); QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest( combinedQuery, request.spaceCodes(), request.category(), request.position(), - request.source(), request.limit(), null, request.conversationId(), request.contextVersion() + request.source(), request.limit(), null, request.conversationId(), request.contextVersion(), + request.projectCode() ), request.queryText()); - return complete(principal, app, spaceIds, request.queryText(), context, response); + QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response); + MemoryCandidateResponse candidate = memoryService.detectForQuery( + principal, combinedQuery, context.conversationId(), completed.requestId(), + new SourceSnapshot(mediaSourceType(file), truncate(request.queryText(), 4000), null, + file.getOriginalFilename(), file.getContentType(), file.getSize(), truncate(extractedContext, 2000))); + return completed.withMemoryCandidate(candidate); } public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) { QueryRequest request = validate(rawRequest); - if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null) { + if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null + || hasText(request.projectCode())) { throw new ServiceException("外部调用应用不支持数据工具或对话状态", HttpStatus.FORBIDDEN); } return TenantHelper.dynamic(app.tenantId(), () -> { @@ -335,7 +346,8 @@ public class AihrKnowledgeQueryService { private static QueryRequest routedRequest(QueryRequest request, ConversationContext context) { return new QueryRequest( context.rewrittenQuery(), request.spaceCodes(), request.category(), request.position(), request.source(), - request.limit(), context.toolCode(), request.conversationId(), request.contextVersion() + request.limit(), context.toolCode(), request.conversationId(), request.contextVersion(), + request.projectCode() ); } @@ -402,14 +414,43 @@ public class AihrKnowledgeQueryService { if (request.contextVersion() != null && request.contextVersion() < 0) { throw new ServiceException("contextVersion 不能小于 0", HttpStatus.BAD_REQUEST); } + String projectCode = request.projectCode() == null ? "" : request.projectCode().trim(); + if (projectCode.length() > 100) { + throw new ServiceException("projectCode 长度不能超过 100", HttpStatus.BAD_REQUEST); + } return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, - request.toolCode(), request.conversationId(), request.contextVersion()); + request.toolCode(), request.conversationId(), request.contextVersion(), + projectCode.isBlank() ? null : projectCode); + } + + private static AihrKnowledgePrincipal projectPrincipal(AihrKnowledgePrincipal principal, String projectCode) { + if (!hasText(projectCode)) { + return principal; + } + String selected = projectCode.trim(); + if (!principal.projectCodes().contains(selected)) { + throw new ServiceException("无权使用当前项目,请重新选择", HttpStatus.FORBIDDEN); + } + return new AihrKnowledgePrincipal( + principal.tenantId(), principal.userId(), principal.userType(), principal.extPartyId(), + principal.roles(), Set.of(selected), principal.clientKey()); } private static long elapsedMillis(long started) { return Math.max(0, (System.nanoTime() - started) / 1_000_000); } + private static String sourceType(String source) { + String value = source == null ? "" : source.toLowerCase(Locale.ROOT); + return value.contains("voice") ? "VOICE" : "TEXT"; + } + + private static String mediaSourceType(MultipartFile file) { + String type = file == null || file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + String name = file == null || file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT); + return type.startsWith("video/") || name.matches(".*\\.(mp4|mov|avi|mkv|webm|m4v)$") ? "VIDEO" : "IMAGE"; + } + private static String placeholders(int size) { return String.join(",", java.util.Collections.nCopies(size, "?")); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java index dd00add9..6b9e2fa5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java @@ -1,12 +1,16 @@ package org.dromara.aihr.memory; import cn.dev33.satoken.annotation.SaCheckLogin; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest; import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse; import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.aihr.memory.AihrMemoryDto.AssistantCaptureResponse; +import org.dromara.aihr.memory.AihrMemoryDto.UpdateCaptureStatusRequest; +import org.dromara.aihr.memory.AihrMemoryDto.CaptureStatusHistoryResponse; import org.dromara.common.core.domain.R; +import org.dromara.system.service.ISysOssService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -16,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.io.IOException; @RestController @RequiredArgsConstructor @@ -23,6 +28,7 @@ import java.util.List; public class AihrMemoryController { private final AihrMemoryService memoryService; + private final ISysOssService ossService; @SaCheckLogin @GetMapping("/memory-candidates") @@ -52,4 +58,30 @@ public class AihrMemoryController { ) { return R.ok(memoryService.assistantCaptures(saveScope, limit)); } + + @SaCheckLogin + @GetMapping("/assistant-captures/{id}/source") + public void source(@PathVariable Long id, HttpServletResponse response) throws IOException { + Long ossId = memoryService.authorizedCaptureSourceOssId(id); + if (ossId == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "原始来源不存在或无权访问"); + return; + } + ossService.download(ossId, response); + } + + @SaCheckLogin + @PostMapping("/assistant-captures/{id}/status") + public R updateStatus( + @PathVariable Long id, + @RequestBody UpdateCaptureStatusRequest request + ) { + return R.ok(memoryService.updateCaptureStatus(id, request)); + } + + @SaCheckLogin + @GetMapping("/assistant-captures/{id}/status-history") + public R> statusHistory(@PathVariable Long id) { + return R.ok(memoryService.captureStatusHistory(id)); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java index 3f5be9ce..0e1a362f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java @@ -21,7 +21,26 @@ public final class AihrMemoryDto { String detail, String status, String occurredAt, - String followUpAt + String followUpAt, + String workDate, + String businessStatus + ) { + public MemoryDraft(String projectCode, String buildingName, String unitName, String roomNo, + String category, String title, String summary, String detail, String status, + String occurredAt, String followUpAt) { + this(projectCode, buildingName, unitName, roomNo, category, title, summary, detail, status, + occurredAt, followUpAt, null, null); + } + } + + public record SourceSnapshot( + String sourceType, + String transcript, + Long ossId, + String fileName, + String mimeType, + Long fileSize, + String recognizedSummary ) { } @@ -33,11 +52,18 @@ public final class AihrMemoryDto { String targetDomain, MemoryDraft draft, List missingFields, - String expiresAt + String expiresAt, + SourceSnapshot source ) { public MemoryCandidateResponse { missingFields = missingFields == null ? List.of() : List.copyOf(missingFields); } + + public MemoryCandidateResponse(Long id, Integer version, String status, String memoryType, + String targetDomain, MemoryDraft draft, List missingFields, + String expiresAt) { + this(id, version, status, memoryType, targetDomain, draft, missingFields, expiresAt, null); + } } public record ConfirmMemoryRequest( @@ -73,7 +99,27 @@ public final class AihrMemoryDto { String detail, String occurredAt, String followUpAt, + String workDate, + String businessStatus, String deliveryStatus, + String createdAt, + SourceSnapshot source + ) { + } + + public record UpdateCaptureStatusRequest( + String businessStatus, + String note, + String idempotencyKey + ) { + } + + public record CaptureStatusHistoryResponse( + Long id, + String fromStatus, + String toStatus, + String note, + Long operatorUserId, String createdAt ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java index 6adc0508..bb122e8c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java @@ -15,6 +15,9 @@ import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryResponse; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse; +import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; +import org.dromara.aihr.memory.AihrMemoryDto.UpdateCaptureStatusRequest; +import org.dromara.aihr.memory.AihrMemoryDto.CaptureStatusHistoryResponse; import org.dromara.aihr.service.AihrSensitiveText; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.exception.ServiceException; @@ -39,6 +42,7 @@ import java.sql.Types; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; @@ -62,6 +66,8 @@ public class AihrMemoryService { private static final Set CAPTURE_SCOPES = Set.of("PRIVATE", "COMPANY"); private static final Set CANDIDATE_STATUSES = Set.of("DRAFT", "NEEDS_INPUT"); private static final Set MEMORY_STATUSES = Set.of("OPEN", "IN_PROGRESS", "RESOLVED", "VOID"); + private static final Set BUSINESS_STATUSES = Set.of("RECORDED", "PENDING", "IN_PROGRESS", "COMPLETED", "VOID"); + private static final ZoneId WORK_ZONE = ZoneId.of("Asia/Shanghai"); private static final Pattern IDEMPOTENCY_KEY = Pattern.compile("[A-Za-z0-9._:-]{8,100}"); private static final Pattern BUILDING = Pattern.compile("([A-Za-z0-9一二三四五六七八九十-]{1,8})(栋|幢|号楼)"); private static final Pattern UNIT = Pattern.compile("([0-9一二三四五六七八九十]{1,4})单元"); @@ -96,6 +102,13 @@ public class AihrMemoryService { /** Query-path integration degrades to ordinary knowledge search until the migration is installed. */ public MemoryCandidateResponse detectForQuery(AihrKnowledgePrincipal principal, String queryText, String conversationId, String requestId) { + return detectForQuery(principal, queryText, conversationId, requestId, + new SourceSnapshot("TEXT", truncate(clean(queryText), 4000), null, null, null, null, null)); + } + + public MemoryCandidateResponse detectForQuery(AihrKnowledgePrincipal principal, String queryText, + String conversationId, String requestId, + SourceSnapshot source) { if (!hasText(requestId)) { return null; } @@ -104,13 +117,13 @@ public class AihrMemoryService { if (continued != null) { return continued; } - CandidatePlan plan = detectPlan(principal, queryText, LocalDateTime.now().withNano(0)); + CandidatePlan plan = detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0)); if (plan == null) { return null; } - return saveCandidate(principal, plan, conversationId, requestId); + return saveCandidate(principal, plan, conversationId, requestId, normalizeSource(source)); } catch (RuntimeException ex) { - if (detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null) { + if (detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0)) != null) { throw new ServiceException("记忆功能暂不可用,请稍后重试", 503); } log.warn("service memory candidate unavailable; query answer remains usable"); @@ -120,7 +133,7 @@ public class AihrMemoryService { /** Pure intent check used only to decide whether a no-knowledge-space query may continue to confirmation. */ public boolean isProjectMemoryCandidateIntent(AihrKnowledgePrincipal principal, String queryText) { - return detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null; + return detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0)) != null; } public List candidates(String rawStatus) { @@ -131,7 +144,7 @@ public class AihrMemoryService { } expireCandidates(principal); return jdbcTemplate.query(""" - select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json, target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at from aihr_memory_candidate where tenant_id = ? and owner_user_id = ? and status = ? and expires_at > now() @@ -261,24 +274,122 @@ public class AihrMemoryService { try { return jdbcTemplate.query(""" select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no, - category, summary, detail, occurred_at, follow_up_at, delivery_status, create_time + category, summary, detail, occurred_at, follow_up_at, work_date, business_status, + delivery_status, create_time, source_snapshot_json from aihr_assistant_capture where tenant_id = ? and owner_user_id = ?%s order by create_time desc, id desc limit ? - """.formatted(scopeClause), (rs, rowNum) -> new AssistantCaptureResponse( - rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"), - rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"), - rs.getString("room_no"), rs.getString("category"), rs.getString("summary"), - rs.getString("detail"), format(rs.getTimestamp("occurred_at")), - format(rs.getTimestamp("follow_up_at")), - rs.getString("delivery_status"), format(rs.getTimestamp("create_time")) - ), args.toArray()); + """.formatted(scopeClause), (rs, rowNum) -> mapAssistantCapture(rs), args.toArray()); } catch (DataAccessException ex) { throw new ServiceException("统一采集数据库迁移未执行", 503); } } + public MemoryCandidateResponse bindCandidateSource(Long candidateId, SourceSnapshot rawSource) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireCandidateId(candidateId); + SourceSnapshot source = normalizeSource(rawSource); + int updated = jdbcTemplate.update(""" + update aihr_memory_candidate + set source_snapshot_json = ?, update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? + and status in ('DRAFT', 'NEEDS_INPUT') + """, json(source), principal.tenantId(), principal.userId(), candidateId); + if (updated != 1) { + throw conflict("确认卡已变化,无法绑定现场来源"); + } + CandidateRow row = requireCandidateForUpdate(principal, candidateId); + return candidateResponse(row); + } + + public Long authorizedCaptureSourceOssId(Long captureId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (captureId == null || captureId <= 0) return null; + List rows = jdbcTemplate.query(""" + select source_snapshot_json + from aihr_assistant_capture + where tenant_id = ? and id = ? + and (owner_user_id = ? or project_code in (%s)) + limit 1 + """.formatted(projectPlaceholders(principal)), (rs, rowNum) -> rs.getString(1), + captureAccessArgs(principal, captureId)); + if (rows.isEmpty()) return null; + SourceSnapshot source = readSource(rows.get(0)); + return source == null ? null : source.ossId(); + } + + @Transactional(rollbackFor = Exception.class) + public AssistantCaptureResponse updateCaptureStatus(Long captureId, UpdateCaptureStatusRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (captureId == null || captureId <= 0) throw new ServiceException("工作记录不存在或无权访问", 404); + String next = normalizeBusinessStatus(request == null ? null : request.businessStatus(), null); + String note = field(request == null ? null : request.note(), 500, "note"); + String key = clean(request == null ? null : request.idempotencyKey()); + if (!IDEMPOTENCY_KEY.matcher(key).matches()) { + throw badRequest("idempotencyKey 长度必须为 8-100,且只能包含字母、数字、点、下划线、冒号或连字符"); + } + List rows = jdbcTemplate.query(""" + select id, project_code, business_status + from aihr_assistant_capture + where tenant_id = ? and id = ? + and (owner_user_id = ? or project_code in (%s)) + limit 1 for update + """.formatted(projectPlaceholders(principal)), (rs, rowNum) -> new CaptureStatusRow( + rs.getLong("id"), rs.getString("project_code"), rs.getString("business_status")), + captureAccessArgs(principal, captureId)); + if (rows.isEmpty()) throw new ServiceException("工作记录不存在或无权访问", 404); + CaptureStatusRow row = rows.get(0); + List replays = jdbcTemplate.query(""" + select capture_id, to_status + from aihr_assistant_capture_status_log + where tenant_id = ? and operator_user_id = ? and idempotency_key = ? + limit 1 + """, (rs, rowNum) -> new CaptureStatusReplay( + rs.getLong("capture_id"), rs.getString("to_status")), + principal.tenantId(), principal.userId(), key); + if (!replays.isEmpty()) { + CaptureStatusReplay replay = replays.get(0); + if (!captureId.equals(replay.captureId()) || !next.equals(replay.toStatus())) { + throw conflict("该 idempotencyKey 已用于其他状态请求"); + } + return assistantCapture(principal, captureId); + } + if (!clean(row.businessStatus()).equals(next)) { + jdbcTemplate.update(""" + update aihr_assistant_capture + set business_status = ?, version = version + 1, update_time = now() + where tenant_id = ? and id = ? and business_status = ? + """, next, principal.tenantId(), captureId, row.businessStatus()); + } + try { + jdbcTemplate.update(""" + insert into aihr_assistant_capture_status_log + (tenant_id, capture_id, project_code, from_status, to_status, note, + operator_user_id, idempotency_key, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, now()) + """, principal.tenantId(), captureId, row.projectCode(), row.businessStatus(), next, + hasText(note) ? note : null, principal.userId(), key); + } catch (DuplicateKeyException duplicate) { + throw conflict("状态请求正在并发处理,请按原请求重试"); + } + return assistantCapture(principal, captureId); + } + + public List captureStatusHistory(Long captureId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + assistantCapture(principal, captureId); + return jdbcTemplate.query(""" + select id, from_status, to_status, note, operator_user_id, create_time + from aihr_assistant_capture_status_log + where tenant_id = ? and capture_id = ? + order by create_time desc, id desc + """, (rs, rowNum) -> new CaptureStatusHistoryResponse( + rs.getLong("id"), rs.getString("from_status"), rs.getString("to_status"), + rs.getString("note"), rs.getLong("operator_user_id"), format(rs.getTimestamp("create_time"))), + principal.tenantId(), captureId); + } + @Transactional(rollbackFor = Exception.class) public MemoryCandidateResponse dismiss(Long candidateId) { AihrKnowledgePrincipal principal = principalResolver.current(); @@ -531,13 +642,14 @@ public class AihrMemoryService { if (!hasText(projectCode)) { missing.add("projectCode"); } - if (!hasText(building)) { + boolean needsResidentLocation = Set.of("SERVICE_LEAD", "RESIDENT_PROFILE").contains(memoryType); + if (needsResidentLocation && !hasText(building)) { missing.add("buildingName"); } - if (!hasText(unit)) { + if (needsResidentLocation && !hasText(unit)) { missing.add("unitName"); } - if (!hasText(room)) { + if (needsResidentLocation && !hasText(room)) { missing.add("roomNo"); } String summary = query; @@ -551,6 +663,8 @@ public class AihrMemoryService { missing.add("summary"); } LocalDateTime occurredAt = (now == null ? LocalDateTime.now() : now).withNano(0); + LocalDate workDate = inferWorkDate(query, occurredAt.toLocalDate()); + String businessStatus = initialBusinessStatus(memoryType); String category = suggestedCategory(memoryType); MemoryDraft draft = new MemoryDraft( projectCode, building, unit, room, @@ -558,7 +672,7 @@ public class AihrMemoryService { defaultTitle("", memoryType, new MemoryDraft(projectCode, building, unit, room, category, "", summary, "", "", format(occurredAt), null)), summary, "", "FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN", - format(occurredAt), null); + format(occurredAt), null, workDate.toString(), businessStatus); return new CandidatePlan(projectCode, memoryType, "DRAFT", draft, List.copyOf(missing), explicit ? 0.92 : 0.78); } @@ -606,13 +720,14 @@ public class AihrMemoryService { if (!hasText(summary)) missing.add("summary"); MemoryDraft mergedSeed = new MemoryDraft( resolvedProject, building, unit, room, existing.category(), existing.title(), summary, - existing.detail(), existing.status(), existing.occurredAt(), existing.followUpAt()); + existing.detail(), existing.status(), existing.occurredAt(), existing.followUpAt(), + existing.workDate(), existing.businessStatus()); String title = hasText(existing.title()) && !existing.title().contains("待补充位置") ? existing.title() : defaultTitle("", memoryType, mergedSeed); MemoryDraft merged = new MemoryDraft( resolvedProject, building, unit, room, defaultCategory(existing.category(), memoryType), title, summary, existing.detail(), initialStatus(existing.status(), memoryType), - existing.occurredAt(), existing.followUpAt()); + existing.occurredAt(), existing.followUpAt(), existing.workDate(), existing.businessStatus()); return new CandidatePlan(resolvedProject, fresh == null ? memoryType : fresh.memoryType(), "DRAFT", merged, List.copyOf(missing), 0.92); } @@ -624,7 +739,7 @@ public class AihrMemoryService { return null; } List rows = jdbcTemplate.query(""" - select id, project_code, memory_type, draft_json, version, expires_at + select id, project_code, memory_type, draft_json, source_snapshot_json, version, expires_at from aihr_memory_candidate where tenant_id = ? and owner_user_id = ? and source_conversation_id = ? and status in ('DRAFT', 'NEEDS_INPUT') and expires_at > now() @@ -632,7 +747,8 @@ public class AihrMemoryService { limit 1 """, (rs, rowNum) -> new ContinuationRow( rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"), - rs.getString("draft_json"), rs.getInt("version"), toLocalDateTime(rs.getTimestamp("expires_at")) + rs.getString("draft_json"), rs.getString("source_snapshot_json"), rs.getInt("version"), + toLocalDateTime(rs.getTimestamp("expires_at")) ), principal.tenantId(), principal.userId(), conversation); if (rows.isEmpty()) { return null; @@ -647,10 +763,11 @@ public class AihrMemoryService { merged.memoryType(), merged.projectCode(), merged.draft())); int updated = jdbcTemplate.update(""" update aihr_memory_candidate - set project_code = ?, status = ?, draft_json = ?, missing_fields_json = ?, + set project_code = ?, work_date = ?, status = ?, draft_json = ?, missing_fields_json = ?, candidate_hash = ?, version = version + 1, update_time = now() where tenant_id = ? and owner_user_id = ? and id = ? and status in ('DRAFT', 'NEEDS_INPUT') and version = ? - """, hasText(merged.projectCode()) ? merged.projectCode() : null, merged.status(), + """, hasText(merged.projectCode()) ? merged.projectCode() : null, + parseWorkDate(merged.draft().workDate(), merged.draft().occurredAt()), merged.status(), json(merged.draft()), json(merged.missingFields()), candidateHash, principal.tenantId(), principal.userId(), row.id(), row.version()); if (updated != 1) { @@ -658,24 +775,24 @@ public class AihrMemoryService { } return new MemoryCandidateResponse( row.id(), row.version() + 1, merged.status(), merged.memoryType(), ASSISTANT_CAPTURE, - merged.draft(), merged.missingFields(), format(row.expiresAt())); + merged.draft(), merged.missingFields(), format(row.expiresAt()), readSource(row.sourceSnapshotJson())); } private MemoryCandidateResponse saveCandidate(AihrKnowledgePrincipal principal, CandidatePlan plan, - String conversationId, String requestId) { + String conversationId, String requestId, SourceSnapshot source) { String sourceConversationId = truncate(clean(conversationId), 64); String sourceRequestId = truncate(clean(requestId), 64); String candidateHash = hash(new CandidateFingerprint(plan.memoryType(), plan.projectCode(), plan.draft())); - LocalDateTime expiresAt = LocalDateTime.now().withNano(0).plusHours(24); + LocalDateTime expiresAt = LocalDateTime.now(WORK_ZONE).withNano(0).plusHours(24); KeyHolder key = new GeneratedKeyHolder(); try { jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_memory_candidate (tenant_id, owner_user_id, source_conversation_id, source_request_id, candidate_hash, - project_code, memory_type, target_domain, status, draft_json, missing_fields_json, - confidence, version, expires_at, create_time, update_time) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now()) + project_code, work_date, memory_type, target_domain, status, draft_json, missing_fields_json, + source_snapshot_json, confidence, version, expires_at, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now()) """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, principal.tenantId()); statement.setLong(2, principal.userId()); @@ -687,13 +804,15 @@ public class AihrMemoryService { } else { statement.setNull(6, Types.VARCHAR); } - statement.setString(7, plan.memoryType()); - statement.setString(8, ASSISTANT_CAPTURE); - statement.setString(9, plan.status()); - statement.setString(10, json(plan.draft())); - statement.setString(11, json(plan.missingFields())); - statement.setDouble(12, plan.confidence()); - statement.setTimestamp(13, Timestamp.valueOf(expiresAt)); + statement.setObject(7, parseWorkDate(plan.draft().workDate(), plan.draft().occurredAt())); + statement.setString(8, plan.memoryType()); + statement.setString(9, ASSISTANT_CAPTURE); + statement.setString(10, plan.status()); + statement.setString(11, json(plan.draft())); + statement.setString(12, json(plan.missingFields())); + statement.setString(13, json(source)); + statement.setDouble(14, plan.confidence()); + statement.setTimestamp(15, Timestamp.valueOf(expiresAt)); return statement; }, key); } catch (DuplicateKeyException duplicate) { @@ -704,13 +823,13 @@ public class AihrMemoryService { throw new ServiceException("创建记忆候选失败", 500); } return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), ASSISTANT_CAPTURE, - plan.draft(), plan.missingFields(), format(expiresAt)); + plan.draft(), plan.missingFields(), format(expiresAt), source); } private MemoryCandidateResponse findCandidateBySource(AihrKnowledgePrincipal principal, String sourceRequestId, String candidateHash) { List rows = jdbcTemplate.query(""" - select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json, target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at from aihr_memory_candidate where tenant_id = ? and owner_user_id = ? and source_request_id = ? and candidate_hash = ? @@ -725,7 +844,7 @@ public class AihrMemoryService { private CandidateRow requireCandidateForUpdate(AihrKnowledgePrincipal principal, Long id) { List rows = jdbcTemplate.query(""" - select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json, target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at from aihr_memory_candidate where tenant_id = ? and owner_user_id = ? and id = ? @@ -749,6 +868,8 @@ public class AihrMemoryService { throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN); } LocalDateTime occurredAt = parseDateTime(draft.occurredAt(), "occurredAt", false); + LocalDate workDate = parseWorkDate(draft.workDate(), draft.occurredAt()).toLocalDate(); + String businessStatus = normalizeBusinessStatus(draft.businessStatus(), candidate.memoryType()); LocalDateTime followUpAt = Boolean.TRUE.equals(request.enableReminder()) ? parseDateTime(draft.followUpAt(), "followUpAt", false) : null; @@ -757,7 +878,7 @@ public class AihrMemoryService { long captureId; try { captureId = insertAssistantCapture(principal, candidate, draft, projectCode, saveScope, - deliveryStatus, occurredAt, followUpAt, idempotencyKey, requestHash); + deliveryStatus, businessStatus, workDate, occurredAt, followUpAt, idempotencyKey, requestHash); int updated = jdbcTemplate.update(""" update aihr_memory_candidate set status = 'CONFIRMED', target_domain = ?, target_id = ?, target_version = 1, @@ -778,38 +899,42 @@ public class AihrMemoryService { private long insertAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft, String projectCode, String saveScope, String deliveryStatus, - LocalDateTime occurredAt, LocalDateTime followUpAt, + String businessStatus, LocalDate workDate, LocalDateTime occurredAt, + LocalDateTime followUpAt, String idempotencyKey, String requestHash) { KeyHolder key = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_assistant_capture - (tenant_id, owner_user_id, project_code, save_scope, suggested_type, + (tenant_id, owner_user_id, project_code, work_date, save_scope, suggested_type, building_name, unit_name, room_no, category, summary, detail, occurred_at, - follow_up_at, source_conversation_id, source_request_id, delivery_status, + follow_up_at, source_conversation_id, source_request_id, source_snapshot_json, + business_status, delivery_status, confirm_idempotency_key, confirm_request_hash, version, create_time, update_time) - select tenant_id, owner_user_id, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?, - source_conversation_id, source_request_id, ?, ?, ?, 1, now(), now() + select tenant_id, owner_user_id, ?, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?, + source_conversation_id, source_request_id, source_snapshot_json, ?, ?, ?, ?, 1, now(), now() from aihr_memory_candidate where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT' """, Statement.RETURN_GENERATED_KEYS); if (hasText(projectCode)) statement.setString(1, projectCode); else statement.setNull(1, Types.VARCHAR); - statement.setString(2, saveScope); - nullable(statement, 3, draft.buildingName()); - nullable(statement, 4, draft.unitName()); - nullable(statement, 5, draft.roomNo()); - statement.setString(6, defaultCategory(draft.category(), candidate.memoryType())); - statement.setString(7, draft.summary()); - nullable(statement, 8, draft.detail()); - statement.setTimestamp(9, Timestamp.valueOf(occurredAt)); - if (followUpAt == null) statement.setNull(10, Types.TIMESTAMP); - else statement.setTimestamp(10, Timestamp.valueOf(followUpAt)); - statement.setString(11, deliveryStatus); - statement.setString(12, idempotencyKey); - statement.setString(13, requestHash); - statement.setString(14, principal.tenantId()); - statement.setLong(15, principal.userId()); - statement.setLong(16, candidate.id()); + statement.setObject(2, workDate); + statement.setString(3, saveScope); + nullable(statement, 4, draft.buildingName()); + nullable(statement, 5, draft.unitName()); + nullable(statement, 6, draft.roomNo()); + statement.setString(7, defaultCategory(draft.category(), candidate.memoryType())); + statement.setString(8, draft.summary()); + nullable(statement, 9, draft.detail()); + statement.setTimestamp(10, Timestamp.valueOf(occurredAt)); + if (followUpAt == null) statement.setNull(11, Types.TIMESTAMP); + else statement.setTimestamp(11, Timestamp.valueOf(followUpAt)); + statement.setString(12, businessStatus); + statement.setString(13, deliveryStatus); + statement.setString(14, idempotencyKey); + statement.setString(15, requestHash); + statement.setString(16, principal.tenantId()); + statement.setLong(17, principal.userId()); + statement.setLong(18, candidate.id()); return statement; }, key); Number id = key.getKey(); @@ -939,14 +1064,17 @@ public class AihrMemoryService { safeText(draft.detail(), 4000, "detail"), upper(draft.status()), normalizeDateTime(draft.occurredAt(), "occurredAt"), - normalizeOptionalDateTime(draft.followUpAt(), "followUpAt") + normalizeOptionalDateTime(draft.followUpAt(), "followUpAt"), + normalizeWorkDate(draft.workDate(), draft.occurredAt()), + normalizeBusinessStatus(draft.businessStatus(), memoryType) ); } private static MemoryDraft copyWithProjectCode(MemoryDraft draft, String projectCode) { return new MemoryDraft( projectCode, draft.buildingName(), draft.unitName(), draft.roomNo(), draft.category(), draft.title(), - draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt()); + draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt(), + draft.workDate(), draft.businessStatus()); } private static void requireLocation(MemoryDraft draft) { @@ -990,12 +1118,26 @@ public class AihrMemoryService { if (hasText(title)) { return title; } + if (!Set.of("SERVICE_LEAD", "RESIDENT_PROFILE").contains(memoryType)) { + return suggestedCategory(memoryType); + } String location = clean(draft.buildingName()) + clean(draft.unitName()) + clean(draft.roomNo()); return suggestedCategory(memoryType) + ":" + (location.isEmpty() ? "待补充位置" : location); } private static String suggestedType(String query) { + if (containsAny(query, "晨会", "应到", "实到", "请假", "到岗", "出勤")) { + return "ATTENDANCE"; + } + if (containsAny(query, "巡检", "巡视", "卫生问题", "设施问题", "停车问题", "现场发现", + "地面", "泥迹", "污渍", "垃圾", "积水", "异味", "破损", "损坏", "松动", "堵塞", + "漏水", "故障", "停运", "乱停", "占道", "堆放")) { + return "INSPECTION"; + } + if (containsAny(query, "服务住户", "安抚住户", "协助业主", "接待业主", "业主服务")) { + return "SERVICE_RECORD"; + } if (containsAny(query, "已解决", "处理完成", "已经恢复", "成功处理", "优秀案例", "经验案例")) { return "CASE"; } @@ -1022,10 +1164,17 @@ public class AihrMemoryService { case "CASE" -> "优秀案例"; case "PERSONAL_NOTE" -> "个人想法"; case "FOLLOW_UP" -> "待跟进事项"; + case "ATTENDANCE" -> "到岗/晨会记录"; + case "INSPECTION" -> "巡检问题"; + case "SERVICE_RECORD" -> "业主服务记录"; default -> "工作记录"; }; } + private static String initialBusinessStatus(String memoryType) { + return Set.of("INSPECTION", "FOLLOW_UP").contains(memoryType) ? "PENDING" : "RECORDED"; + } + private static boolean sameCapture(String existingType, MemoryDraft existing, CandidatePlan fresh) { if (!clean(existingType).equals(fresh.memoryType())) return false; String oldBuilding = clean(existing.buildingName()); @@ -1069,7 +1218,7 @@ public class AihrMemoryService { : ("服务需求".equals(category) ? "住户提出服务需求" : "住户提出" + category + "需求"); return new MemoryDraft( input.projectCode(), input.buildingName(), input.unitName(), input.roomNo(), category, title, summary, "", - input.status(), input.occurredAt(), input.followUpAt()); + input.status(), input.occurredAt(), input.followUpAt(), input.workDate(), input.businessStatus()); } private static String resolveProjectCode(Set rawProjectCodes, String query) { @@ -1174,7 +1323,7 @@ public class AihrMemoryService { return new CandidateRow( rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"), rs.getString("target_domain"), rs.getString("status"), rs.getString("draft_json"), - rs.getString("missing_fields_json"), (Long) rs.getObject("target_id"), + rs.getString("missing_fields_json"), rs.getString("source_snapshot_json"), (Long) rs.getObject("target_id"), (Integer) rs.getObject("target_version"), rs.getInt("version"), rs.getString("confirm_idempotency_key"), rs.getString("confirm_request_hash"), toLocalDateTime(rs.getTimestamp("expires_at")) @@ -1184,7 +1333,63 @@ public class AihrMemoryService { private MemoryCandidateResponse candidateResponse(CandidateRow row) { return new MemoryCandidateResponse(row.id(), row.version(), row.status(), row.memoryType(), row.targetDomain(), readDraft(row.draftJson()), readStrings(row.missingFieldsJson()), - format(row.expiresAt())); + format(row.expiresAt()), readSource(row.sourceSnapshotJson())); + } + + private AssistantCaptureResponse assistantCapture(AihrKnowledgePrincipal principal, Long captureId) { + List rows = jdbcTemplate.query(""" + select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no, + category, summary, detail, occurred_at, follow_up_at, work_date, business_status, + delivery_status, create_time, source_snapshot_json + from aihr_assistant_capture + where tenant_id = ? and id = ? + and (owner_user_id = ? or project_code in (%s)) + limit 1 + """.formatted(projectPlaceholders(principal)), (rs, rowNum) -> mapAssistantCapture(rs), + captureAccessArgs(principal, captureId)); + if (rows.isEmpty()) throw new ServiceException("工作记录不存在或无权访问", 404); + return rows.get(0); + } + + private AssistantCaptureResponse mapAssistantCapture(ResultSet rs) throws SQLException { + return new AssistantCaptureResponse( + rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"), + rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"), + rs.getString("room_no"), rs.getString("category"), rs.getString("summary"), + rs.getString("detail"), format(rs.getTimestamp("occurred_at")), + format(rs.getTimestamp("follow_up_at")), rs.getString("work_date"), + rs.getString("business_status"), rs.getString("delivery_status"), + format(rs.getTimestamp("create_time")), readSource(rs.getString("source_snapshot_json")) + ); + } + + private String projectPlaceholders(AihrKnowledgePrincipal principal) { + return placeholders(Math.max(1, supervisorProjects(principal).size())); + } + + private Object[] captureAccessArgs(AihrKnowledgePrincipal principal, Long captureId) { + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.add(captureId); + args.add(principal.userId()); + Set projects = supervisorProjects(principal); + if (projects.isEmpty()) args.add(""); + else args.addAll(projects); + return args.toArray(); + } + + private Set supervisorProjects(AihrKnowledgePrincipal principal) { + boolean supervisor = principal.roles().stream().map(AihrMemoryService::upper) + .anyMatch(role -> Set.of("SUPERVISOR", "PROJECT_MANAGER", "ADMIN", "SYS_ADMIN").contains(role)); + if (!supervisor) return Set.of(); + if (!"app_user".equals(principal.userType())) return principal.projectCodes(); + if (!hasText(principal.extPartyId())) return Set.of(); + return new java.util.LinkedHashSet<>(jdbcTemplate.queryForList(""" + select distinct project_code + from aihr_org_snapshot + where tenant_id = ? and ext_party_id = ? and employment_status = 'active' + and position_level in ('主管', '项目经理') + """, String.class, principal.tenantId(), principal.extPartyId())); } private ServiceMemoryResponse mapServiceMemory(ResultSet rs, List versions) @@ -1246,6 +1451,34 @@ public class AihrMemoryService { } } + private SourceSnapshot readSource(String json) { + if (!hasText(json)) return null; + try { + return objectMapper.readValue(json, SourceSnapshot.class); + } catch (Exception ex) { + throw new ServiceException("工作来源快照无法读取", 500); + } + } + + private static SourceSnapshot normalizeSource(SourceSnapshot source) { + SourceSnapshot value = source == null + ? new SourceSnapshot("TEXT", "", null, null, null, null, null) + : source; + String type = upper(value.sourceType()); + if (!Set.of("TEXT", "VOICE", "IMAGE", "VIDEO").contains(type)) type = "TEXT"; + Long ossId = value.ossId() != null && value.ossId() > 0 ? value.ossId() : null; + Long size = value.fileSize() != null && value.fileSize() >= 0 ? value.fileSize() : null; + return new SourceSnapshot( + type, + truncate(clean(value.transcript()), 4000), + ossId, + truncate(clean(value.fileName()), 255), + truncate(clean(value.mimeType()), 100), + size, + truncate(clean(value.recognizedSummary()), 2000) + ); + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); @@ -1308,6 +1541,37 @@ public class AihrMemoryService { } } + private static String normalizeWorkDate(String rawWorkDate, String occurredAt) { + return parseWorkDate(rawWorkDate, occurredAt).toLocalDate().toString(); + } + + private static java.sql.Date parseWorkDate(String rawWorkDate, String occurredAt) { + LocalDate date = parseDate(rawWorkDate, "workDate"); + if (date == null) { + LocalDateTime occurred = parseDateTime(occurredAt, "occurredAt", false); + date = occurred.toLocalDate(); + } + return java.sql.Date.valueOf(date); + } + + private static LocalDate inferWorkDate(String query, LocalDate fallback) { + if (containsAny(query, "前天")) return fallback.minusDays(2); + if (containsAny(query, "昨天", "昨日")) return fallback.minusDays(1); + return fallback; + } + + private static String normalizeBusinessStatus(String raw, String memoryType) { + String value = upper(raw); + if (value.isEmpty()) { + if (memoryType == null) throw badRequest("businessStatus 必填"); + value = initialBusinessStatus(memoryType); + } + if (!BUSINESS_STATUSES.contains(value)) { + throw badRequest("businessStatus 仅支持 RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID"); + } + return value; + } + private static String format(Timestamp value) { return value == null ? null : format(value.toLocalDateTime()); } @@ -1437,6 +1701,7 @@ public class AihrMemoryService { String status, String draftJson, String missingFieldsJson, + String sourceSnapshotJson, Long targetId, Integer targetVersion, int version, @@ -1458,8 +1723,15 @@ public class AihrMemoryService { String projectCode, String memoryType, String draftJson, + String sourceSnapshotJson, int version, LocalDateTime expiresAt ) { } + + private record CaptureStatusRow(Long id, String projectCode, String businessStatus) { + } + + private record CaptureStatusReplay(Long captureId, String toStatus) { + } } 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 cc515046..2d658aa6 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 @@ -101,27 +101,31 @@ public class AihrOrgSyncService { Map companies = companyMap(companyItems); Map departments = departmentMap(departmentItems); Map projects = projectMap(projectItems); - Map assignments = assignmentMap(assignmentItems); + Map> assignmentGroups = assignmentGroups(assignmentItems); List rows = new ArrayList<>(); int skipped = 0; int maskedPhone = 0; + int phoneLinked = 0; for (JsonNode employee : employeeItems) { - OrgRow row = orgRow(employee, companies, departments, projects, assignments); - if (row == null) { + List employeeRows = orgRows(employee, companies, departments, projects, assignmentGroups); + if (employeeRows.isEmpty()) { skipped++; } else { - if (row.personPhone().isBlank() && hasUnusablePhoneCandidate(employee)) { + if (employeeRows.get(0).personPhone().isBlank() && hasUnusablePhoneCandidate(employee)) { maskedPhone++; } - rows.add(row); + if (!employeeRows.get(0).personPhone().isBlank()) { + phoneLinked++; + } + rows.addAll(employeeRows); } } - int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count(); int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count(); - int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList()); - if (!rows.isEmpty() && phoneLinked < rows.size()) { - warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员"); + int duplicateMemberships = countDuplicateMemberships(rows); + int mappedEmployees = employeeItems.size() - skipped; + if (mappedEmployees > 0 && phoneLinked < mappedEmployees) { + warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + mappedEmployees + ",移动端正式登录仅可映射已带手机号人员"); } if (suspectText > 0) { warnings.add("外部员工快照存在 " + suspectText + " 条疑似乱码字段,请检查开放接口编码或导入链路"); @@ -129,17 +133,17 @@ public class AihrOrgSyncService { if (maskedPhone > 0) { warnings.add("外部员工快照存在 " + maskedPhone + " 条脱敏/不可用手机号,不能用于移动端正式登录映射"); } - if (duplicatePartyIds > 0) { - warnings.add("外部员工快照存在 " + duplicatePartyIds + " 条重复 ext_party_id,写入会被唯一键折叠,请先修复上游快照"); + if (duplicateMemberships > 0) { + warnings.add("外部员工快照存在 " + duplicateMemberships + " 条重复项目成员关系,请先修复上游快照"); } if (!dryRun && replaceExisting && rows.isEmpty()) { throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照"); } - if (!dryRun && duplicatePartyIds > 0) { - throw new IllegalArgumentException("外部员工快照存在重复 ext_party_id,已阻止写入组织人员快照;请先修复上游身份数据"); + if (!dryRun && duplicateMemberships > 0) { + throw new IllegalArgumentException("外部员工快照存在重复项目成员关系,已阻止写入组织人员快照;请先修复上游身份数据"); } if (!dryRun && replaceExisting && !allowPartialReplace - && hasUnsafeReplaceData(employeeItems.size(), rows.size(), skipped, phoneLinked, maskedPhone, suspectText)) { + && hasUnsafeReplaceData(employeeItems.size(), mappedEmployees, skipped, phoneLinked, maskedPhone, suspectText)) { throw new IllegalArgumentException("外部员工快照存在不完整或疑似异常数据,已阻止覆盖本地组织人员快照;请先 dry-run,确认后显式传 allowPartialReplace=true"); } if (!dryRun && !rows.isEmpty()) { @@ -182,6 +186,17 @@ public class AihrOrgSyncService { return duplicates; } + private static int countDuplicateMemberships(List rows) { + Set seen = new HashSet<>(); + int duplicates = 0; + for (OrgRow row : rows) { + if (!seen.add(row.projectCode() + "\u0000" + row.extPartyId())) { + duplicates++; + } + } + return duplicates; + } + public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status, Integer pageNum, Integer pageSize, Integer limit) { requireSnapshotTable(); @@ -364,7 +379,6 @@ public class AihrOrgSyncService { position_name, position_level, employment_status, hire_date, snapshot_date, create_time) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) on duplicate key update - project_code = values(project_code), project_name = values(project_name), dept_name = values(dept_name), person_phone = values(person_phone), @@ -464,6 +478,34 @@ public class AihrOrgSyncService { ); } + private List orgRows(JsonNode employee, Map companies, + Map departments, Map projects, + Map> assignmentGroups) { + String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo")); + String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId")); + String extPartyId = firstNonBlank(employeeNumber, employeeId); + if (extPartyId.isBlank()) { + return List.of(); + } + List assignments = assignmentList(assignmentGroups, employeeId, employeeNumber, extPartyId); + if (assignments.isEmpty()) { + OrgRow row = orgRow(employee, companies, departments, projects, Map.of()); + return row == null ? List.of() : List.of(row); + } + List rows = new ArrayList<>(); + for (AssignmentInfo assignment : assignments) { + Map selected = new TreeMap<>(); + putAssignment(selected, employeeId, assignment); + putAssignment(selected, employeeNumber, assignment); + putAssignment(selected, extPartyId, assignment); + OrgRow row = orgRow(employee, companies, departments, projects, selected); + if (row != null) { + rows.add(row); + } + } + return rows; + } + private Map companyMap(List items) { Map map = new TreeMap<>(); for (JsonNode item : items) { @@ -522,28 +564,68 @@ public class AihrOrgSyncService { if ("departed".equals(status(text(item, "status")))) { continue; } - String employeeId = firstNonBlank(text(item, "employee_id", "employeeId")); - String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber")); - AssignmentInfo info = new AssignmentInfo( - employeeId, - employeeNumber, - firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""), - firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""), - firstNonBlank(text(item, "project_id", "projectId"), ""), - firstNonBlank(text(item, "project_code", "projectCode"), ""), - firstNonBlank(text(item, "project_name", "projectName"), ""), - firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""), - firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""), - firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""), - firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""), - bool(item, "is_primary", "isPrimary") || "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType")) - ); - putAssignment(map, employeeId, info); - putAssignment(map, employeeNumber, info); + AssignmentInfo info = assignmentInfo(item); + putAssignment(map, info.employeeId(), info); + putAssignment(map, info.employeeNumber(), info); } return map; } + private Map> assignmentGroups(List items) { + Map> groups = new TreeMap<>(); + for (JsonNode item : items) { + if ("departed".equals(status(text(item, "status")))) { + continue; + } + AssignmentInfo info = assignmentInfo(item); + putAssignmentGroup(groups, info.employeeId(), info); + putAssignmentGroup(groups, info.employeeNumber(), info); + } + return groups; + } + + private static AssignmentInfo assignmentInfo(JsonNode item) { + String employeeId = firstNonBlank(text(item, "employee_id", "employeeId")); + String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber")); + return new AssignmentInfo( + employeeId, + employeeNumber, + firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""), + firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""), + firstNonBlank(text(item, "project_id", "projectId"), ""), + firstNonBlank(text(item, "project_code", "projectCode"), ""), + firstNonBlank(text(item, "project_name", "projectName"), ""), + firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""), + firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""), + firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""), + firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""), + bool(item, "is_primary", "isPrimary") + || "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType")) + ); + } + + private static List assignmentList(Map> groups, String... keys) { + for (String key : keys) { + List assignments = groups.get(clean(key)); + if (assignments != null) { + return assignments; + } + } + return List.of(); + } + + private static void putAssignmentGroup(Map> groups, String key, + AssignmentInfo info) { + String cleanKey = clean(key); + if (cleanKey.isBlank()) { + return; + } + List assignments = groups.computeIfAbsent(cleanKey, ignored -> new ArrayList<>()); + if (!assignments.contains(info)) { + assignments.add(info); + } + } + private static AssignmentInfo assignment(Map assignments, String... keys) { for (String key : keys) { AssignmentInfo info = assignments.get(clean(key)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultController.java new file mode 100644 index 00000000..7c73d436 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultController.java @@ -0,0 +1,37 @@ +package org.dromara.aihr.workresult; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.workresult.AihrWorkResultDto.ProjectWorkResultResponse; +import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@SaCheckLogin +@RequestMapping("/api/aihr/work-results") +public class AihrWorkResultController { + + private final AihrWorkResultService workResultService; + + @PostMapping("/mine/generate") + public R generateMine( + @RequestParam("projectCode") String projectCode, + @RequestParam(value = "workDate", required = false) String workDate + ) { + return R.ok(workResultService.generateMine(projectCode, workDate)); + } + + @GetMapping("/project") + public R project( + @RequestParam("projectCode") String projectCode, + @RequestParam(value = "workDate", required = false) String workDate + ) { + return R.ok(workResultService.project(projectCode, workDate)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultDto.java new file mode 100644 index 00000000..363b2111 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultDto.java @@ -0,0 +1,49 @@ +package org.dromara.aihr.workresult; + +import java.util.List; +import java.util.Map; + +public final class AihrWorkResultDto { + + private AihrWorkResultDto() { + } + + public record WorkResultItem( + Long captureId, + String suggestedType, + String category, + String summary, + String businessStatus, + String deliveryStatus, + String occurredAt, + String sourceType, + String sourceUrl + ) { + } + + public record WorkResultResponse( + Long ownerUserId, + String ownerName, + String projectCode, + String projectName, + String workDate, + Integer version, + String generatedAt, + Map> sections, + Integer total, + Integer pending + ) { + } + + public record ProjectWorkResultResponse( + String projectCode, + String projectName, + String workDate, + Integer contributorCount, + Integer resultCount, + Integer pendingCount, + Integer highPriorityCount, + List members + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultService.java new file mode 100644 index 00000000..81bb1c7a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/workresult/AihrWorkResultService.java @@ -0,0 +1,260 @@ +package org.dromara.aihr.workresult; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.workresult.AihrWorkResultDto.ProjectWorkResultResponse; +import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultItem; +import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +public class AihrWorkResultService { + + private static final ZoneId WORK_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Set SUPERVISOR_ROLES = Set.of("SUPERVISOR", "PROJECT_MANAGER", "ADMIN", "SYS_ADMIN"); + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final AihrKnowledgePrincipalResolver principalResolver; + + @Transactional(rollbackFor = Exception.class) + public WorkResultResponse generateMine(String rawProjectCode, String rawWorkDate) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String projectCode = requireProject(principal, rawProjectCode); + LocalDate workDate = workDate(rawWorkDate); + WorkResultResponse draft = build(principal.tenantId(), principal.userId(), projectCode, workDate, 0, ""); + List sourceIds = allItems(draft).stream().map(WorkResultItem::captureId).toList(); + String contentHash = contentHash(draft); + List snapshots = jdbcTemplate.query(""" + select version, content_hash, generated_at + from aihr_daily_work_result + where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ? + """, (rs, rowNum) -> new Snapshot( + rs.getInt("version"), rs.getString("content_hash"), rs.getTimestamp("generated_at")), + principal.tenantId(), principal.userId(), projectCode, workDate); + if (!snapshots.isEmpty() && contentHash.equals(snapshots.get(0).contentHash())) { + Snapshot snapshot = snapshots.get(0); + return build(principal.tenantId(), principal.userId(), projectCode, workDate, + snapshot.version(), format(snapshot.generatedAt())); + } + int version = snapshots.isEmpty() ? 1 : snapshots.get(0).version() + 1; + LocalDateTime generatedAtValue = LocalDateTime.now(WORK_ZONE).withNano(0); + String generatedAt = generatedAtValue.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + WorkResultResponse result = build(principal.tenantId(), principal.userId(), projectCode, + workDate, version, generatedAt); + jdbcTemplate.update(""" + insert into aihr_daily_work_result + (tenant_id, owner_user_id, project_code, work_date, report_json, + source_capture_ids_json, content_hash, version, generated_at, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + on duplicate key update report_json = values(report_json), + source_capture_ids_json = values(source_capture_ids_json), + content_hash = values(content_hash), version = values(version), + generated_at = values(generated_at), update_time = now() + """, principal.tenantId(), principal.userId(), projectCode, workDate, + json(result), json(sourceIds), contentHash, version, Timestamp.valueOf(generatedAtValue)); + return result; + } + + public ProjectWorkResultResponse project(String rawProjectCode, String rawWorkDate) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String projectCode = requireProject(principal, rawProjectCode); + requireSupervisor(principal, projectCode); + LocalDate date = workDate(rawWorkDate); + List owners = jdbcTemplate.queryForList(""" + select distinct owner_user_id + from aihr_assistant_capture + where tenant_id = ? and project_code = ? and work_date = ? + order by owner_user_id + """, Long.class, principal.tenantId(), projectCode, date); + List members = owners.stream() + .map(owner -> build(principal.tenantId(), owner, projectCode, date, snapshotVersion( + principal.tenantId(), owner, projectCode, date))) + .toList(); + int pending = members.stream().mapToInt(WorkResultResponse::pending).sum(); + int high = members.stream().flatMap(item -> allItems(item).stream()) + .mapToInt(item -> "INSPECTION".equals(item.suggestedType()) + && !Set.of("COMPLETED", "VOID").contains(item.businessStatus()) ? 1 : 0).sum(); + return new ProjectWorkResultResponse( + projectCode, projectName(principal.tenantId(), projectCode), date.toString(), owners.size(), + members.stream().mapToInt(item -> item.total() > 0 ? 1 : 0).sum(), pending, high, members); + } + + private WorkResultResponse build(String tenantId, Long ownerUserId, String projectCode, + LocalDate workDate, int version) { + return build(tenantId, ownerUserId, projectCode, workDate, version, + LocalDateTime.now(WORK_ZONE).withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + + private WorkResultResponse build(String tenantId, Long ownerUserId, String projectCode, + LocalDate workDate, int version, String generatedAt) { + Map> sections = emptySections(); + List rows = jdbcTemplate.query(""" + select id, suggested_type, category, summary, business_status, delivery_status, + occurred_at, source_snapshot_json + from aihr_assistant_capture + where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ? + order by occurred_at, id + """, (rs, rowNum) -> mapItem(rs), tenantId, ownerUserId, projectCode, workDate); + for (WorkResultItem item : rows) { + sections.get(section(item)).add(item); + } + int pending = (int) rows.stream() + .filter(item -> Set.of("PENDING", "IN_PROGRESS").contains(item.businessStatus())).count(); + return new WorkResultResponse( + ownerUserId, ownerName(tenantId, ownerUserId), projectCode, projectName(tenantId, projectCode), + workDate.toString(), version, generatedAt, + sections, rows.size(), pending); + } + + private WorkResultItem mapItem(ResultSet rs) throws SQLException { + String sourceType = "TEXT"; + try { + Map source = objectMapper.readValue(rs.getString("source_snapshot_json"), Map.class); + Object detected = source.get("sourceType"); + sourceType = detected == null ? "TEXT" : String.valueOf(detected); + } catch (Exception ignored) { + // Old records have no source snapshot and are treated as text records. + } + long id = rs.getLong("id"); + return new WorkResultItem( + id, rs.getString("suggested_type"), rs.getString("category"), rs.getString("summary"), + rs.getString("business_status"), rs.getString("delivery_status"), + format(rs.getTimestamp("occurred_at")), sourceType, + "/api/aihr/personal-assistant/assistant-captures/" + id + "/source"); + } + + private static Map> emptySections() { + Map> sections = new LinkedHashMap<>(); + for (String key : List.of("completed", "inspection", "services", "leads", "attendance", "pending")) { + sections.put(key, new ArrayList<>()); + } + return sections; + } + + static String section(WorkResultItem item) { + if (Set.of("PENDING", "IN_PROGRESS").contains(item.businessStatus())) return "pending"; + return switch (item.suggestedType()) { + case "ATTENDANCE" -> "attendance"; + case "INSPECTION" -> "inspection"; + case "SERVICE_RECORD" -> "services"; + case "SERVICE_LEAD", "RESIDENT_PROFILE" -> "leads"; + default -> "completed"; + }; + } + + private static List allItems(WorkResultResponse response) { + return response.sections().values().stream().flatMap(List::stream).toList(); + } + + private String projectName(String tenantId, String projectCode) { + List rows = jdbcTemplate.queryForList(""" + select project_name from aihr_org_snapshot + where tenant_id = ? and project_code = ? and employment_status = 'active' + order by id limit 1 + """, String.class, tenantId, projectCode); + return rows.isEmpty() ? "当前项目" : rows.get(0); + } + + private String ownerName(String tenantId, Long ownerUserId) { + List rows = jdbcTemplate.queryForList(""" + select coalesce(nullif(nick_name, ''), user_name) + from sys_user where tenant_id = ? and user_id = ? limit 1 + """, String.class, tenantId, ownerUserId); + return rows.isEmpty() ? "员工" : rows.get(0); + } + + private int snapshotVersion(String tenantId, Long owner, String projectCode, LocalDate date) { + List rows = jdbcTemplate.queryForList(""" + select version from aihr_daily_work_result + where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ? + """, Integer.class, tenantId, owner, projectCode, date); + return rows.isEmpty() ? 0 : rows.get(0); + } + + private static String requireProject(AihrKnowledgePrincipal principal, String rawProjectCode) { + String projectCode = rawProjectCode == null ? "" : rawProjectCode.trim(); + if (projectCode.isEmpty()) throw new ServiceException("请先选择当前项目", HttpStatus.BAD_REQUEST); + if (!principal.projectCodes().contains(projectCode)) { + throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN); + } + return projectCode; + } + + private void requireSupervisor(AihrKnowledgePrincipal principal, String projectCode) { + boolean allowed = principal.roles().stream().map(value -> value.toUpperCase(Locale.ROOT)) + .anyMatch(SUPERVISOR_ROLES::contains); + if (!allowed) throw new ServiceException("无权查看团队工作成果", HttpStatus.FORBIDDEN); + if (!"app_user".equals(principal.userType())) return; + Integer rows = jdbcTemplate.queryForObject(""" + select count(*) from aihr_org_snapshot + where tenant_id = ? and ext_party_id = ? and project_code = ? + and employment_status = 'active' and position_level in ('主管', '项目经理') + """, Integer.class, principal.tenantId(), principal.extPartyId(), projectCode); + if (rows == null || rows < 1) { + throw new ServiceException("当前项目没有主管权限", HttpStatus.FORBIDDEN); + } + } + + private static LocalDate workDate(String raw) { + if (raw == null || raw.isBlank()) return LocalDate.now(WORK_ZONE); + try { + return LocalDate.parse(raw.trim()); + } catch (Exception ex) { + throw new ServiceException("workDate 必须为 yyyy-MM-dd", HttpStatus.BAD_REQUEST); + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new ServiceException("工作成果序列化失败", 500); + } + } + + String contentHash(WorkResultResponse response) { + Map content = new LinkedHashMap<>(); + content.put("sections", response.sections()); + content.put("total", response.total()); + content.put("pending", response.pending()); + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(json(content).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (Exception ex) { + throw new ServiceException("工作成果指纹生成失败", 500); + } + } + + private static String format(java.sql.Timestamp value) { + return value == null ? null : value.toLocalDateTime().withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + private record Snapshot(int version, String contentHash, Timestamp generatedAt) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/controller/AihrMobileControllerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/controller/AihrMobileControllerTest.java new file mode 100644 index 00000000..4451e676 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/controller/AihrMobileControllerTest.java @@ -0,0 +1,34 @@ +package org.dromara.aihr.controller; + +import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("dev") +class AihrMobileControllerTest { + + @Test + void projectOptionsUseNamesAndDeduplicateCodes() { + var rows = List.of( + row(1L, "P2", "星河湾二期"), + row(2L, "P1", "星河湾一期"), + row(3L, "P1", "星河湾一期"), + row(4L, "P3", "") + ); + + var projects = AihrMobileController.projectOptions(rows); + + assertEquals(List.of("P3", "P1", "P2"), projects.stream().map(item -> item.projectCode()).toList()); + assertEquals("P3", projects.get(0).projectName()); + assertEquals("星河湾一期", projects.get(1).projectName()); + } + + private static OrgPersonRow row(Long id, String projectCode, String projectName) { + return new OrgPersonRow(id, projectCode, projectName, "客服部", "employee-1", "王敏", + "生活顾问", "一线", "active", "2026-07-21"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index c17a25b0..bb6dd545 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -48,6 +48,25 @@ import static org.mockito.ArgumentMatchers.eq; @Tag("dev") class AihrKnowledgeQueryServiceTest { + @Test + void selectedProjectMustBelongToCurrentEmployee() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var sop = mock(AihrSopSeedService.class); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), "app"); + when(resolver.current()).thenReturn(principal); + var service = service(resolver, appService, mock(AihrKnowledgeAccessService.class), sop); + + ServiceException error = assertThrows(ServiceException.class, () -> service.queryInternal( + new QueryRequest("记一下巡检问题", List.of(), "sop", null, "mobile", 5, null, + "conversation_123", 0L, "P2"))); + + assertEquals(403, error.getCode()); + verify(appService, never()).requireSessionApp(anyString(), anyString()); + verify(sop, never()).searchAuthorized(any(), any(), any()); + } + @Test void emptyEffectiveScopeStopsBeforeRag() { var resolver = mock(AihrKnowledgePrincipalResolver.class); @@ -264,7 +283,7 @@ class AihrKnowledgeQueryServiceTest { when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty()); when(memory.isProjectMemoryCandidateIntent(principal, "记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true); - when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate); + when(memory.detectForQuery(eq(principal), anyString(), any(), anyString(), any())).thenReturn(candidate); var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit, jdbc, mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory); @@ -279,7 +298,7 @@ class AihrKnowledgeQueryServiceTest { assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result) .contains("\"memoryCandidate\"")); verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"), - any(), eq(result.requestId())); + any(), eq(result.requestId()), any()); verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of()), eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("service-memory-v1")); } @@ -307,7 +326,7 @@ class AihrKnowledgeQueryServiceTest { when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty()); when(memory.isProjectMemoryCandidateIntent(principal, "记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true); - when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate); + when(memory.detectForQuery(eq(principal), anyString(), any(), anyString(), any())).thenReturn(candidate); var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit, mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java index 265b95da..7a7a9e15 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java @@ -12,7 +12,9 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import java.sql.ResultSet; import java.util.List; import java.util.Set; @@ -97,6 +99,28 @@ class AihrKnowledgeConversationServiceTest { assertEquals(409, error.getCode()); } + @Test + void existingConversationCannotBeReusedAcrossProjects() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getString("project_code")).thenReturn("P1"); + when(rs.getLong("version")).thenReturn(1L); + when(rs.getString("context_json")).thenReturn("[]"); + when(rs.getTimestamp("expires_time")).thenReturn(java.sql.Timestamp.valueOf("2099-01-01 00:00:00")); + return List.of(mapper.mapRow(rs, 0)); + }); + var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class)); + + ServiceException error = assertThrows(ServiceException.class, () -> service.prepare(principal(), app(), + new QueryRequest("继续", List.of(), "sop", null, "test", 5, null, + "conversation_123", 1L, "P2"))); + + assertEquals(409, error.getCode()); + assertTrue(error.getMessage().contains("项目已切换")); + } + private static AihrKnowledgePrincipal principal() { return new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", Set.of("employee"), Set.of("P1"), "app"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java index 3bd60beb..88ab8f97 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java @@ -138,7 +138,7 @@ class AihrMemoryServiceTest { assertNotNull(plan); assertEquals("", plan.draft().roomNo()); - assertTrue(plan.missingFields().contains("roomNo")); + assertFalse(plan.missingFields().contains("roomNo")); } @Test @@ -147,6 +147,18 @@ class AihrMemoryServiceTest { assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "电梯困人应该怎么处理?", NOW)); } + @Test + void propertyInspectionLanguageCreatesPendingInspectionWithoutRoomRequirement() { + var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")), + "C5栋大厅地面有泥迹,请安排处理,帮我记一下", NOW); + + assertNotNull(plan); + assertEquals("INSPECTION", plan.memoryType()); + assertEquals("巡检问题", plan.draft().title()); + assertEquals("PENDING", plan.draft().businessStatus()); + assertTrue(plan.missingFields().isEmpty()); + } + @Test void sameConversationLocationFollowupUpgradesOriginalNeedsInputCandidate() throws Exception { ObjectMapper mapper = new ObjectMapper(); 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 50512ac5..4ffd5cee 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 @@ -121,6 +121,36 @@ public class AihrOrgSyncServiceTest { assertEquals("主管", ReflectionTestUtils.getField(row, "positionLevel")); } + @Test + public void oneEmployeeCanProduceMembershipRowsForMultipleProjects() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AihrOrgSyncService service = new AihrOrgSyncService( + new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "storeDisplayFields", true); + var employee = mapper.readTree(""" + {"id":"EMP-1","employee_number":"YC001","name":"测试员工","phone":"13900001111"} + """); + List assignments = List.of( + mapper.readTree(""" + {"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-1", + "project_code":"FW001","project_name":"翡翠湾","project_position_name":"生活顾问","status":"active"} + """), + mapper.readTree(""" + {"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-2", + "project_code":"YSF001","project_name":"云山府","project_position_name":"生活顾问","status":"active"} + """) + ); + + Object groups = ReflectionTestUtils.invokeMethod(service, "assignmentGroups", assignments); + List rows = ReflectionTestUtils.invokeMethod(service, "orgRows", employee, + Map.of(), Map.of(), Map.of(), groups); + + assertEquals(2, rows.size()); + assertEquals(List.of("FW001", "YSF001"), rows.stream() + .map(row -> String.valueOf(ReflectionTestUtils.getField(row, "projectCode"))).toList()); + assertTrue(rows.stream().allMatch(row -> "YC001".equals(ReflectionTestUtils.getField(row, "extPartyId")))); + } + @Test public void numericRawPositionLevelFallsBackToPositionName() throws Exception { ObjectMapper mapper = new ObjectMapper(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/workresult/AihrWorkResultServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/workresult/AihrWorkResultServiceTest.java new file mode 100644 index 00000000..c57150cd --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/workresult/AihrWorkResultServiceTest.java @@ -0,0 +1,80 @@ +package org.dromara.aihr.workresult; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultItem; +import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrWorkResultServiceTest { + + @Test + void sectionUsesBusinessStatusBeforeSuggestedType() { + assertEquals("pending", AihrWorkResultService.section(item(1L, "ATTENDANCE", "IN_PROGRESS"))); + assertEquals("attendance", AihrWorkResultService.section(item(2L, "ATTENDANCE", "COMPLETED"))); + assertEquals("inspection", AihrWorkResultService.section(item(3L, "INSPECTION", "RECORDED"))); + assertEquals("services", AihrWorkResultService.section(item(4L, "SERVICE_RECORD", "RECORDED"))); + assertEquals("leads", AihrWorkResultService.section(item(5L, "SERVICE_LEAD", "RECORDED"))); + } + + @Test + void contentHashIgnoresSnapshotVersionAndGenerationTimeButTracksStatus() { + AihrWorkResultService service = new AihrWorkResultService(null, new ObjectMapper(), null); + WorkResultResponse first = response(1, "2026-07-21T08:00:00", "RECORDED"); + WorkResultResponse reopened = response(9, "2026-07-21T10:00:00", "RECORDED"); + WorkResultResponse changed = response(9, "2026-07-21T10:00:00", "COMPLETED"); + + assertEquals(service.contentHash(first), service.contentHash(reopened)); + assertNotEquals(service.contentHash(first), service.contentHash(changed)); + } + + @Test + void supervisorRoleFromAnotherProjectDoesNotGrantTeamResultAccess() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(new AihrKnowledgePrincipal( + "000000", 7L, "app_user", "EMP-1", Set.of("employee", "supervisor"), Set.of("P1", "P2"), "app")); + when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(0); + AihrWorkResultService service = new AihrWorkResultService(jdbc, new ObjectMapper(), resolver); + + ServiceException error = assertThrows(ServiceException.class, () -> service.project("P2", "2026-07-21")); + + assertEquals(403, error.getCode()); + assertEquals("当前项目没有主管权限", error.getMessage()); + } + + private static WorkResultResponse response(int version, String generatedAt, String status) { + Map> sections = new LinkedHashMap<>(); + sections.put("completed", List.of(item(1L, "SERVICE_RECORD", status))); + sections.put("inspection", List.of()); + sections.put("services", List.of()); + sections.put("leads", List.of()); + sections.put("attendance", List.of()); + sections.put("pending", List.of()); + return new WorkResultResponse(7L, "王敏", "P1", "星河湾一期", "2026-07-21", + version, generatedAt, sections, 1, 0); + } + + private static WorkResultItem item(long id, String type, String status) { + return new WorkResultItem(id, type, "工作记录", "合成测试记录", status, "LOCAL", + "2026-07-21T08:00:00", "TEXT", "/source/" + id); + } +} diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 62cef8c4..c70a0c8b 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -237,6 +237,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` ( `app_id` bigint NOT NULL COMMENT '内部调用应用ID', `user_id` bigint NOT NULL COMMENT '内部用户ID', `conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID', + `project_code` varchar(100) DEFAULT NULL COMMENT '当前项目编码;多项目会话严格隔离', `version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本', `context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文', `expires_time` datetime NOT NULL COMMENT '不活跃过期时间', diff --git a/backend/script/sql/aihr_org_snapshot_mysql8.sql b/backend/script/sql/aihr_org_snapshot_mysql8.sql index 4548716a..fa0bf249 100644 --- a/backend/script/sql/aihr_org_snapshot_mysql8.sql +++ b/backend/script/sql/aihr_org_snapshot_mysql8.sql @@ -20,7 +20,8 @@ CREATE TABLE `aihr_org_snapshot` ( `snapshot_date` date DEFAULT NULL COMMENT '快照日期', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `ext_party_id`), + UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`), + KEY `idx_aihr_org_party` (`tenant_id`, `ext_party_id`), KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`), KEY `idx_aihr_org_project` (`tenant_id`, `project_code`, `position_level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)'; diff --git a/backend/script/sql/aihr_service_memory_mysql8.sql b/backend/script/sql/aihr_service_memory_mysql8.sql index 43e5f592..47b9ff09 100644 --- a/backend/script/sql/aihr_service_memory_mysql8.sql +++ b/backend/script/sql/aihr_service_memory_mysql8.sql @@ -8,11 +8,13 @@ CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` ( `source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID', `candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256', `project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码', + `work_date` date DEFAULT NULL COMMENT 'Asia/Shanghai 自然工作日期', `memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP', `target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域', `status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED', `draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出', `missing_fields_json` json DEFAULT NULL COMMENT '待补充字段', + `source_snapshot_json` json DEFAULT NULL COMMENT '最小必要来源快照与受保护OSS引用', `confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议', `target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID', `target_version` int DEFAULT NULL COMMENT '确认结果版本', @@ -83,6 +85,7 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` ( `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', `owner_user_id` bigint NOT NULL COMMENT '记录员工', `project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码', + `work_date` date DEFAULT NULL COMMENT 'Asia/Shanghai 自然工作日期', `save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY', `suggested_type` varchar(30) NOT NULL COMMENT 'SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE', `building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋', @@ -95,6 +98,8 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` ( `follow_up_at` datetime DEFAULT NULL COMMENT '用户确认的跟进时间', `source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID', `source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID', + `source_snapshot_json` json DEFAULT NULL COMMENT '最小必要来源快照与受保护OSS引用', + `business_status` varchar(30) NOT NULL DEFAULT 'RECORDED' COMMENT 'RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID', `delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED', `delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数', `next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间', @@ -108,9 +113,44 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` ( PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`), KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`), + KEY `idx_aihr_assistant_capture_daily` (`tenant_id`, `project_code`, `work_date`, `owner_user_id`), KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录'; +CREATE TABLE IF NOT EXISTS `aihr_assistant_capture_status_log` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '状态变更ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `capture_id` bigint NOT NULL COMMENT '统一采集记录ID', + `project_code` varchar(100) DEFAULT NULL COMMENT '项目编码', + `from_status` varchar(30) NOT NULL COMMENT '变更前业务状态', + `to_status` varchar(30) NOT NULL COMMENT '变更后业务状态', + `note` varchar(500) DEFAULT NULL COMMENT '处理备注', + `operator_user_id` bigint NOT NULL COMMENT '操作人', + `idempotency_key` varchar(100) NOT NULL COMMENT '状态更新幂等键', + `create_time` datetime NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_capture_status_idempotency` (`tenant_id`, `operator_user_id`, `idempotency_key`), + KEY `idx_aihr_capture_status_capture` (`tenant_id`, `capture_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手业务状态变更历史'; + +CREATE TABLE IF NOT EXISTS `aihr_daily_work_result` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '今日工作成果ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `owner_user_id` bigint NOT NULL COMMENT '员工用户ID', + `project_code` varchar(100) NOT NULL COMMENT '项目编码', + `work_date` date NOT NULL COMMENT 'Asia/Shanghai 自然工作日期', + `report_json` json NOT NULL COMMENT '确定性聚合后的成果快照', + `source_capture_ids_json` json NOT NULL COMMENT '来源采集记录ID', + `content_hash` char(64) NOT NULL DEFAULT '' COMMENT '忽略生成时间和版本的成果内容指纹', + `version` int NOT NULL DEFAULT 1 COMMENT '重新生成版本', + `generated_at` datetime NOT NULL COMMENT '生成时间', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_daily_work_result` (`tenant_id`, `owner_user_id`, `project_code`, `work_date`), + KEY `idx_aihr_daily_work_result_project` (`tenant_id`, `project_code`, `work_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工按项目按自然日工作成果快照'; + -- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。 SET @aihr_memory_collation := ( SELECT collation_name diff --git a/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql b/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql index 76ba78ad..2fd09b48 100644 --- a/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql +++ b/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` ( `app_id` bigint NOT NULL COMMENT '内部调用应用ID', `user_id` bigint NOT NULL COMMENT '内部用户ID', `conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID', + `project_code` varchar(100) DEFAULT NULL COMMENT '当前项目编码;多项目会话严格隔离', `version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本', `context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文', `expires_time` datetime NOT NULL COMMENT '不活跃过期时间', diff --git a/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql b/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql new file mode 100644 index 00000000..b5757d08 --- /dev/null +++ b/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql @@ -0,0 +1,38 @@ +-- 同一员工可属于多个项目;身份仍以 ext_party_id 为主,成员关系以项目隔离。 +-- 可重复执行,先替换旧的 tenant+ext_party_id 唯一键,再补身份查询索引。 + +SET @aihr_org_table_exists := ( + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' +); +SET @aihr_org_unique_columns := ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' + AND index_name = 'uk_aihr_org_party' +); +SET @ddl := IF( + @aihr_org_table_exists = 1 AND COALESCE(@aihr_org_unique_columns, '') <> 'tenant_id,project_code,ext_party_id', + IF( + @aihr_org_unique_columns IS NULL, + 'ALTER TABLE `aihr_org_snapshot` ADD UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`)', + 'ALTER TABLE `aihr_org_snapshot` DROP INDEX `uk_aihr_org_party`, ADD UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`)' + ), + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + @aihr_org_table_exists = 1 AND NOT EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' + AND index_name = 'idx_aihr_org_party' + ), + 'ALTER TABLE `aihr_org_snapshot` ADD KEY `idx_aihr_org_party` (`tenant_id`, `ext_party_id`)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := NULL; +SET @aihr_org_table_exists := NULL; +SET @aihr_org_unique_columns := NULL; diff --git a/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql b/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql new file mode 100644 index 00000000..8f36ba6a --- /dev/null +++ b/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql @@ -0,0 +1,118 @@ +-- 工作助手项目隔离、自然日成果、来源追溯与本地状态闭环。 +-- 可重复执行;外部流转仍沿用 PENDING,不在本迁移中伪造 DELIVERED。 + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `app_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `conversation_id` varchar(64) NOT NULL, + `project_code` varchar(100) DEFAULT NULL, + `version` bigint NOT NULL DEFAULT 0, + `context_json` json DEFAULT NULL, + `expires_time` datetime NOT NULL, + `create_time` datetime NOT NULL, + `update_time` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_knowledge_conversation` (`tenant_id`, `app_id`, `user_id`, `conversation_id`), + KEY `idx_aihr_knowledge_conversation_expire` (`tenant_id`, `expires_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识问答短期会话'; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_conversation' AND column_name = 'project_code'), + 'SELECT 1', + 'ALTER TABLE `aihr_knowledge_conversation` ADD COLUMN `project_code` varchar(100) DEFAULT NULL COMMENT ''当前项目编码;多项目会话严格隔离'' AFTER `conversation_id`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate' AND column_name = 'work_date'), + 'SELECT 1', + 'ALTER TABLE `aihr_memory_candidate` ADD COLUMN `work_date` date DEFAULT NULL COMMENT ''Asia/Shanghai 自然工作日期'' AFTER `project_code`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate' AND column_name = 'source_snapshot_json'), + 'SELECT 1', + 'ALTER TABLE `aihr_memory_candidate` ADD COLUMN `source_snapshot_json` json DEFAULT NULL COMMENT ''最小必要来源快照与受保护OSS引用'' AFTER `missing_fields_json`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'work_date'), + 'SELECT 1', + 'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `work_date` date DEFAULT NULL COMMENT ''Asia/Shanghai 自然工作日期'' AFTER `project_code`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'source_snapshot_json'), + 'SELECT 1', + 'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `source_snapshot_json` json DEFAULT NULL COMMENT ''最小必要来源快照与受保护OSS引用'' AFTER `source_request_id`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'business_status'), + 'SELECT 1', + 'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `business_status` varchar(30) NOT NULL DEFAULT ''RECORDED'' COMMENT ''RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID'' AFTER `source_snapshot_json`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE `aihr_memory_candidate` + SET `work_date` = DATE(`create_time`) + WHERE `work_date` IS NULL; +UPDATE `aihr_assistant_capture` + SET `work_date` = DATE(COALESCE(`occurred_at`, `create_time`)) + WHERE `work_date` IS NULL; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND index_name = 'idx_aihr_assistant_capture_daily'), + 'SELECT 1', + 'ALTER TABLE `aihr_assistant_capture` ADD KEY `idx_aihr_assistant_capture_daily` (`tenant_id`, `project_code`, `work_date`, `owner_user_id`)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `aihr_assistant_capture_status_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `capture_id` bigint NOT NULL, + `project_code` varchar(100) DEFAULT NULL, + `from_status` varchar(30) NOT NULL, + `to_status` varchar(30) NOT NULL, + `note` varchar(500) DEFAULT NULL, + `operator_user_id` bigint NOT NULL, + `idempotency_key` varchar(100) NOT NULL, + `create_time` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_capture_status_idempotency` (`tenant_id`, `operator_user_id`, `idempotency_key`), + KEY `idx_aihr_capture_status_capture` (`tenant_id`, `capture_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手业务状态变更历史'; + +CREATE TABLE IF NOT EXISTS `aihr_daily_work_result` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `owner_user_id` bigint NOT NULL, + `project_code` varchar(100) NOT NULL, + `work_date` date NOT NULL, + `report_json` json NOT NULL, + `source_capture_ids_json` json NOT NULL, + `content_hash` char(64) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT 1, + `generated_at` datetime NOT NULL, + `create_time` datetime NOT NULL, + `update_time` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_daily_work_result` (`tenant_id`, `owner_user_id`, `project_code`, `work_date`), + KEY `idx_aihr_daily_work_result_project` (`tenant_id`, `project_code`, `work_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工按项目按自然日工作成果快照'; + +SET @ddl := IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_daily_work_result' AND column_name = 'content_hash'), + 'SELECT 1', + 'ALTER TABLE `aihr_daily_work_result` ADD COLUMN `content_hash` char(64) NOT NULL DEFAULT '''' COMMENT ''成果内容指纹;用于幂等生成'' AFTER `source_capture_ids_json`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := NULL; diff --git a/docs/20260708/数字师傅学练问报整合方案.md b/docs/20260708/数字师傅学练问报整合方案.md index 83022213..696cb6c6 100644 --- a/docs/20260708/数字师傅学练问报整合方案.md +++ b/docs/20260708/数字师傅学练问报整合方案.md @@ -1,6 +1,6 @@ # 银城数字师傅产品方案(整合修订) -> 版本:v1.5 | 日期:2026-07-19 +> 版本:v1.7 | 日期:2026-07-21 > 输入源:①客户方《物业数字师傅系统三期建设方案》(2026-07-08 早,下称"第三版")②《企业内部AI培训学习APP产品需求与发布会内容讨论》会议纪要(2026-07-08)③客户方会后微信补充(学模块课程化+考试;巡检点位转入职学习任务)④2026-07-11 客户微信语音补充(员工个人 AI 助理/个人知识空间)。 > v1.1 重构:按"产品是什么 → 发布会做什么 → 决策记录 → 执行"四部分分层,产品定义与发布会演示不再混写。 > v1.2 更新(2026-07-08,客户回复三项待确认):①多端跳转议题**暂缓**,现阶段只保证能打开小程序页面;②工作积分排行榜**保留功能、可选择不用**(开关配置);③员工评价管理团队**按组织架构与权限控制**,需有权限才能评价。详见 §3.3。 @@ -8,6 +8,7 @@ > v1.4 更新(2026-07-19):个人助理增加确认式长期记忆,并把一级入口收敛为“工作助手 / 查全网”;内部个人资料、项目记录和企业知识默认自动路由,来源在答案中透明展示。 > v1.5 更新(2026-07-19):“工作上报”由传统表单主路径改为微信式语音/照片/视频会话,AI 整理并按需追问,用户确认后才提交既有审核链。 > v1.6 更新(2026-07-19):“记一下”升级为确认式统一采集。系统先建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录,用户选择仅自己保存或提交公司处理;位置字段缺失不阻断保存,公司处理在正式接口接入前只标记待流转。 +> v1.7 收口(2026-07-21):日常工作采集统一归“工作助手”,按自然日汇总为“今日工作成果”;现有 CASE/VIDEO/SOP/KNOWLEDGE 审核链产品语义收敛为“成果投稿”,当前页面和 API 名称仍保留“工作上报”。项目工作记录后续必须从项目名称下拉列表确认当前项目,不要求用户输入项目编码。施工顺序见[专项迭代计划](../工作助手与今日工作成果迭代计划-20260721.md)。 > 与《银城员工端APP分阶段实施总纲》《AI陪练二期开发推进计划》的关系:本方案是增量修订,不推翻既有阶段划分;§4.2 回写清单已于 2026-07-08 执行——总纲升 v0.3、二期计划升 v0.8。BRD 已升至 v1.4,同步个人助理与工作上报交互定案;BRD 4.4 师徒演练等旧口径仍待后续版本继续修订。 --- @@ -79,21 +80,21 @@ - **打勾/打叉**:每条 AI 答案可标好/差;打叉的知识条目在该问题上后续**不再推送**,并进后台待复核清单——解决"错误知识反复出现"的历史问题; - 悬赏问答不在问模块内,独立为问题榜(1.3.5)。 -### 1.3.4 报——案例上报与经验沉淀 +### 1.3.4 报——成果投稿与经验沉淀 -> 高保真实现基准:[工作上报微信式语音会话 v1](../prototypes/work-report-voice-conversation-v1.png)。该图是目标交互,不是当前生产截图。 +> 高保真实现基准:[工作上报微信式语音会话 v1](../prototypes/work-report-voice-conversation-v1.png)。当前会话式页面已实现,该图只作为视觉回归基准,不代表工作助手日常采集或今日成果页面。 ![工作上报微信式语音会话高保真设计](../prototypes/work-report-voice-conversation-v1.png) -- **2026-07-19 交互定案**:员工端不再以“先选类型 → 填标题 → 填说明 → 传附件”的传统表单作为主路径,而是复用「问」的微信式会话语言。首页“工作上报”仍作为清晰任务入口,进入后直接提示“今天处理了什么?按住说,也可以发照片或视频”。 -- 主流程固定为:`语音/文字/照片/视频 → ASR 与 AI 理解 → 必要时追问 1–2 个缺失信息 → 生成结构化确认卡 → 用户确认上报 → 主管/运营审核 → 会话内展示状态`。 +- **当前交互**:员工端已经不再以“先选类型 → 填标题 → 填说明 → 传附件”的传统表单作为主路径,而是复用「问」的微信式会话语言。当前首页和页面仍显示“工作上报”,后续迭代 4 再统一改成“成果投稿”。 +- 主流程固定为:`语音/文字/附件 → ASR 与文字整理 → 必要时追问 1–2 个缺失信息 → 生成结构化确认卡 → 用户确认上报 → 主管/运营审核 → 会话内展示状态`。当前附件可以保存,但整理服务不会读取图片/视频内容,不能宣传为已完成视觉理解。 - 前端默认复用现有 `ChatComposer`:语音优先,键盘次之;图片、视频和音频继续走附件入口。不得为了“报”再建设第二套录音、附件或聊天组件。 - “优秀案例 / 操作视频 / 完整 SOP / 有用知识”仍是后台结构化类型,但由 AI 优先判断并在确认卡中展示,员工可以修改,不要求普通员工在表达前理解分类。 - 确认卡至少展示类型、标题、事情经过、处理方法、结果和附件;卡片动作收敛为“修改 / 确认上报”,继续补充直接使用底部语音/文字输入,避免重复入口。只有明确点击“确认上报”才能创建正式记录,AI 不得自动提交。 - “我的上报”作为历史入口,审核中、已通过、已驳回及审核意见以会话消息或状态卡呈现。审核通过只代表上报通过;最终进入案例库或知识库仍由运营在对应模块人工完成,不自动入库。 - 上报目标仍为**每个生活顾问每月至少 1 个有价值案例**;后台采纳后计学分并署名,与问题榜优质回答共同构成“行业经验知识库”原料。 -- “工作上报”与“记一件事”必须区分:前者会提交给企业审核并可能共享入库,后者只进入已授权的个人或项目记忆域;两者都必须在最终动作前向用户说明保存范围。 -- “报”字面易与报事报修歧义,对外入口统一使用“工作上报”,不使用单独的“报”字作为页面标题。 +- “成果投稿”与“工作助手记一件事”必须区分:前者会提交给企业审核并可能共享入库,后者进入本人日常工作记录或待公司处理队列;两者都必须在最终动作前说明保存范围。 +- “报”字面易与报事报修、日常汇报混淆。当前代码兼容“工作上报”名称,后续产品文案统一迁移到“成果投稿”,不再新增第三种同义入口。 高保真评审后的实施修订: diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 89b88b18..730ef6f6 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -14,12 +14,14 @@ | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` | -| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/personal-assistant/assistant-captures?limit=50`;旧项目记录继续使用 `/api/aihr/service-memories` | 文字或 ASR 转写复用同一候选检测。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,项目和房屋位置缺失只作可选提示;系统建议 `SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转,正式外部接收端未配置前不得显示已送达。提醒默认关闭且单独确认;音色不在本次范围 | +| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 和 `POST /api/knowledge/query-media` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`;旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达 | +| 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 | +| 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 | | 资料处理 `/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` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | -| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前剩余 25 人手机号不可用属于上游数据质量问题;功能发布不再被组织同步能力阻塞 | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee/employee_project_assignment` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + ext_party_id`;只拒绝同一项目内的重复成员关系。`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝同项目重复关系。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。该统计发生在多项目成员迁移前;发布迁移后必须重新 dry-run 并核对展开后的项目成员行数。当前剩余 25 人手机号不可用属于上游数据质量问题 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | -| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | +| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端。响应返回去重后的 `projects[]` 供页面按项目名称选择,不要求用户输入或记忆编码;同一人员在多个项目的快照行按 `tenant_id + project_code + ext_party_id` 保留。接口失败回退员工端 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `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`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 | | 移动端候选人闭环 | 页面拆为 `/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 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | | 每日题反馈与常见难题 | `POST /api/aihr/mobile/practice/assignments/{id}/feedback`、`GET /api/aihr/mobile/practice/difficulties`、`POST /api/aihr/mobile/practice/difficulties/assign` | 反馈区分题目有用性与答案正确性;主管/管理端按岗位、时间、次数、人数和均分聚合,并可基于难题创建专项;身份和项目范围由服务端解析 | @@ -224,6 +226,8 @@ Content-Type: application/json ## 工作助手确认式统一采集 +本节记录当前已经部署的接口。当前版本仍允许候选项目为空,也没有当前项目下拉、项目化短会话、原始媒体绑定或今日工作成果接口;这些属于[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md),在代码和接口落地前不得提前写入本指南的可调用接口表。 + 该链路复用上面的短会话请求,不新增另一套问答 session。登录员工说“帮我记一下三栋 3203 需要保洁服务”时,`POST /api/knowledge/query` 在正常回答字段之外可返回;缺少单元不会阻断用户保存: ```json @@ -249,7 +253,7 @@ Content-Type: application/json } ``` -确认请求只提交用户在卡片上确认或修改的草稿;`projectCode` 即使传入也不能作为授权依据: +确认请求只提交用户在卡片上确认或修改的草稿。`PERSONAL_NOTE` 可以不绑定项目;晨会、巡检、业主服务、画像、线索等项目工作必须先由用户按项目名称选择,草稿携带内部 `projectCode`,但服务端仍以当前登录主体的授权项目集合复核,不能把客户端字段当授权依据: ```http POST /api/aihr/personal-assistant/memory-candidates/301/confirm @@ -262,15 +266,18 @@ Content-Type: application/json "saveScope": "COMPANY", "enableReminder": false, "draft": { + "projectCode": "PRJ-XHW1", "buildingName": "3栋", "unitName": "", "roomNo": "3203", "category": "服务线索", "summary": "需要保洁服务", "occurredAt": "2026-07-19T14:00:00+08:00", + "workDate": "2026-07-19", + "businessStatus": "PENDING", "followUpAt": null } } ``` -服务端在事务中锁定候选,写入 `aihr_assistant_capture` 后才把候选改为 `CONFIRMED`。`PRIVATE` 对应 `deliveryStatus=NOT_REQUIRED`;`COMPANY` 对应 `PENDING`。`DRAFT` 默认 24 小时过期,列表查询强制过滤当前租户与 owner。`GET /api/aihr/personal-assistant/assistant-captures?saveScope=COMPANY&limit=50` 只展示本人的企业流转状态;PENDING 不是已生成工单或已上报成功。后续 `/api/knowledge/query` 可受控召回确认记录,引用域分别为 `PERSONAL / COMPANY / PROJECT_SERVICE`;个人和公司采集按 `tenant_id + owner_user_id` 过滤,旧项目记录继续按项目权限过滤。 +服务端在事务中锁定候选,写入 `aihr_assistant_capture` 后才把候选改为 `CONFIRMED`。`PRIVATE` 对应 `deliveryStatus=NOT_REQUIRED`;`COMPANY` 对应 `PENDING`。`DRAFT` 默认 24 小时过期,列表查询强制过滤当前租户与 owner。`GET /api/aihr/personal-assistant/assistant-captures?saveScope=COMPANY&limit=50` 只展示本人的企业流转状态;PENDING 不是已生成工单或已上报成功。状态更新带独立 `idempotencyKey`,相同记录和目标状态可安全重试,跨记录或跨目标状态复用返回 409。`/api/knowledge/query` 可受控召回确认记录,引用域分别为 `PERSONAL / COMPANY / PROJECT_SERVICE`;个人和公司采集按 `tenant_id + owner_user_id` 过滤,旧项目记录继续按项目权限过滤。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 27027300..d807a79a 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,6 +1,6 @@ # BRD 功能审查与未完成项记录 -> 当前基线:2026-07-18,生产功能包提交 `064816a784dad535f2320c4321f73f2e6e364d69`。 +> 当前基线:2026-07-21。阶段一生产功能包以 `064816a784dad535f2320c4321f73f2e6e364d69` 为已记录基线;2026-07-20 确认式采集与会话式工作上报增量另按生产 JAR、H5 资源和数据库结构复核。 > > 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。 @@ -21,9 +21,11 @@ “已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。 -### 未发布工作区增量(2026-07-20) +### 2026-07-20 已部署增量与验收边界 -工作助手“记一下”统一采集已完成本地编码、数据库迁移、目标测试、390×844 视觉交互和真实 HTTP 召回验证;`PRIVATE` 仅本人可见,`COMPANY` 只写 `PENDING`。该增量尚未提交或部署,不属于上表的生产包;外部线索/工单投递、正式回执、个人文件/网页知识空间仍未实现。 +工作助手“记一下”统一采集已提交并部署:生产 `ruoyi-aihr` 包包含 `AihrMemoryService`,H5 包含确认卡资源,生产库存在 `aihr_memory_candidate/aihr_assistant_capture` 及关键确认、范围和流转字段。会话式工作上报也已出现在生产 H5 与后端包中,生产 `aihr_work_report` 已有幂等字段。以上属于生产基础存在性复核;本轮未重新执行正式账号端到端、多项目、真机媒体或真实审核样本验收。 + +`PRIVATE` 仅本人可见,`COMPANY` 只写 `PENDING`。项目名称选择、项目化会话、确认记录来源绑定、员工今日成果和主管项目成果已完成本地实现与 390×844 验证,尚未部署和生产验证;外部线索/工单投递、正式回执及个人文件/网页知识空间仍未实现。后续顺序以[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)为准。 ## 2. 三端输入与 BRD 匹配 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index e2cc3273..710aa8e2 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -14,7 +14,7 @@ 上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。 -2026-07-20 待发布增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;本批仍不包含个人文件/网页知识空间、自动派单或向量化个人资料。同批“工作上报语音会话化”只为既有 `aihr_work_report` 补 `request_key/request_hash` 和唯一索引。加入统一采集表后,待发布结构门禁为 `41/41`。 +2026-07-20 增量已把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;本批仍不包含项目名称选择、今日工作成果、原始来源绑定、个人文件/网页知识空间、自动派单或向量化个人资料。同批“工作上报语音会话化”继续使用既有 `aihr_work_report`,并补 `request_key/request_hash` 和唯一索引。2026-07-21 只读复核确认生产 JAR、H5 资源、两张确认式采集表和工作上报幂等字段存在;完整迁移计数仍应在下一次发布前由 `release-preflight.sh` 重跑,不沿用历史 `41/41` 数字。 ## 发布前置条件 @@ -52,6 +52,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index c5ac9742..d5bb9a7b 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -1,6 +1,6 @@ # 一期 MVP 演示与验收清单 -本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;用户侧三端已在 `mobile-uni/` 按真实页面栈重建,portless 入口为 `https://wygj-mobile-uni.localhost/h5/`,已接手机号登录、问师傅、员工训练、员工学习、案例素材、候选人面试/资料和主管复盘/派专项;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。 +本文件是当前能力的 MVP 现场演示入口:先跑预检,再按演示流逐条点击。它不包含尚未实施的项目名称选择、项目化会话、原始来源绑定、今日工作成果或主管项目日报;这些以[后续迭代计划](工作助手与今日工作成果迭代计划-20260721.md)为准。当前证明一期 MVP 路径可讲、可点、可截图;用户侧三端已在 `mobile-uni/` 按真实页面栈重建,portless 入口为 `https://wygj-mobile-uni.localhost/h5/`,已接手机号登录、问师傅、员工训练、员工学习、案例素材、候选人面试/资料和主管复盘/派专项;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。 ## 演示前预检 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 49c8b2c2..e405d60e 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -219,6 +219,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R - 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;页面只保留“批量导入”和“刷新”,不应出现“选择目录”“服务端导入”或目录导入任务面板。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;支持多文件和 ZIP,暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。 - uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `dev/local` profile 且 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产 profile 即使误传环境变量也强制关闭。 - 工作助手确认式统一采集:`/h5/#/pages/user/sop/index` 一级入口只保留“工作助手 / 查全网”,既有文字、ASR、媒体、数据工具和 30 分钟/最近 6 轮短会话链保持不变;显式“记一下/帮我记/保存一下”返回 `DRAFT` 确认卡,位置缺失只作可选提示。确认后写 `aihr_assistant_capture`:`PRIVATE/NOT_REQUIRED` 仅本人可见,`COMPANY/PENDING` 只表示待流转;旧项目记录继续使用 `aihr_service_memory/version`。`/h5/#/pages/user/assistant/memories` 展示待确认候选和本人已确认记录;不依赖新的 Qdrant collection,提醒默认关闭。 +- 上一条只验证当前已部署的确认式采集;项目名称下拉、项目化会话、原始来源绑定、今日工作成果和主管按日汇总仍属[后续迭代](工作助手与今日工作成果迭代计划-20260721.md),不得在本地回归结果中写成已完成。 - 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。 - uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。 - uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。 diff --git a/docs/FIGMA_IMPLEMENTATION_MATRIX.md b/docs/FIGMA_IMPLEMENTATION_MATRIX.md index ba1bba6b..7a37fb9d 100644 --- a/docs/FIGMA_IMPLEMENTATION_MATRIX.md +++ b/docs/FIGMA_IMPLEMENTATION_MATRIX.md @@ -20,7 +20,7 @@ - 本矩阵回答“Figma 设计的页面在代码/线上完成到什么程度”,覆盖 MVP、二期、阶段二/三和已取消设计。 - 二期计划回答“生活顾问 AI 陪练阶段一的工程闭环和 M5 试点条件是否满足”,不要求实现索引原口径下的全部 70 张 Figma 正式画板。 -- 因此,矩阵中的“未实现”不等于二期延期。开放问题榜和积分基础能力已因 2026-07-17 客户明确要求提前实现并发布;个人 AI 助理、完整徽章荣誉和完整偏好体系仍属阶段二/三。 +- 因此,矩阵中的“未实现”不等于二期延期。开放问题榜和积分基础能力已因 2026-07-17 客户明确要求提前实现并发布;工作助手确认式采集也已在 2026-07-20 增量中部署。矩阵中仍未实现的“个人 AI 助理”主要指个人文件/网页、独立个人知识空间、分享和 PPT 页面;完整徽章荣誉和完整偏好体系仍属阶段二/三。 - 矩阵中的“已实现”也不等于 M5 业务验收通过;评分校准、SOP 评审、正式组织身份和真实试点数据仍以严格预检为准。 与二期计划的主要对应关系: @@ -112,13 +112,13 @@ | 61 | AI 教练训练计划 | `97:5` | `mobile-uni/src/pages/supervisor/assign/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已用主管派发、低分再练、每日三题和成长证据承载;独立 AI 教练编排属于后续范围 | | 62 | 自动场景任务/自动复盘 | `97:778` | `mobile-uni/src/pages/user/practice/index.vue`、`supervisor/review/index.vue` | 按二期计划已实现基础闭环;按 Figma 部分对齐 | 已有训练、评分、标注、复盘和再练;自动编排/自动复盘报告不属于当前二期必做 | | 63 | 积分/徽章/荣誉中心 | `97:823` | `mobile-uni/src/pages/user/points/index.vue` | 部分实现并部署;Figma 已修 | 积分/学习学分余额和明细已实现;完整徽章、荣誉中心仍属后续范围 | -| 64 | 企业发布申请 | `42:2` | — | 未实现 | 未修正,阶段二 | -| 65 | 个人 AI 助理发布记录 | `44:2` | — | 未实现 | 未修正,阶段二 | -| 66 | 个人 AI 助理报告大纲 | `44:229` | — | 未实现 | 未修正,阶段二 | -| 67 | 个人 AI 助理 PPT 生成 | `45:2` | — | 未实现 | 未修正,阶段二 | -| 68 | 企业知识发布审核列表 | `46:2` | — | 未实现 | 未修正,阶段二 | -| 69 | 企业知识发布审核详情 | `47:2` | — | 未实现 | 未修正,阶段二 | -| 70 | 个人 AI 助理运营/紧急审计 | `48:2` | — | 未实现 | 未修正,阶段二 | +| 64 | 个人资料申请发布到企业 | `42:2` | — | 未实现 | 确认式采集的 `COMPANY/PENDING` 已部署,但正式发布申请、接收端和回执仍属阶段二 | +| 65 | 个人资料发布记录 | `44:2` | — | 未实现 | 不等同于当前“我的记忆”列表;后续个人知识空间范围 | +| 66 | 个人资料汇报大纲 | `44:229` | — | 未实现 | 当前尚无跨资料生成汇报大纲能力 | +| 67 | 个人资料 PPT 生成 | `45:2` | — | 未实现 | 阶段二增强项,不阻塞确认式采集和工作助手迭代 | +| 68 | 企业知识发布审核列表 | `46:2` | — | 未实现 | 当前 `COMPANY/PENDING` 不等于已进入该审核列表 | +| 69 | 企业知识发布审核详情 | `47:2` | — | 未实现 | 正式接收接口、审核主体和回执尚未确定 | +| 70 | 个人知识空间运营/紧急审计 | `48:2` | — | 未实现 | 仅指个人文件/网页知识空间的运营审计,不否定已部署的确认式采集审计字段 | ## 已取消而不应补做 @@ -130,4 +130,4 @@ 2. **P0:M5 正式试点证据**。本地严格门禁可跑通,但正式窗口内的员工、训练次数、校准、SOP 评审和完训率仍需真实数据。 3. **P1:按 BRD 补齐一期业务缺口**。优先是岗位-SOP匹配与上岗资格、错题本可见闭环、案例学习/视频详情的稳定数据模型。 4. **P2:案例详情数据模型**。`caseId`、原始音频媒体留痕、学习点和主管点评已具备;继续等待真实视频样片与相关训练场景契约,不用假视频占位。 -5. **P3:剩余阶段二/三页面**。开放问题榜和积分基础能力已按客户要求提前发布;个人 AI 助理、完整徽章荣誉、完整偏好和独立 AI 教练编排仍按后续阶段单独立项。 +5. **P3:剩余阶段二/三页面**。开放问题榜、积分基础能力和工作助手确认式采集已提前发布;个人文件/网页知识空间、发布审核、汇报/PPT、完整徽章荣誉、完整偏好和独立 AI 教练编排仍按后续阶段单独立项。 diff --git a/docs/FIGMA需求覆盖与版本偏差审计-20260717.md b/docs/FIGMA需求覆盖与版本偏差审计-20260717.md index 1509781f..0227f3b2 100644 --- a/docs/FIGMA需求覆盖与版本偏差审计-20260717.md +++ b/docs/FIGMA需求覆盖与版本偏差审计-20260717.md @@ -1,6 +1,8 @@ # Figma 需求覆盖与版本偏差审计 > 生产状态更新(2026-07-18):本文记录的 Figma 修复、代码恢复和新增学习能力已随 `32fa376d` 发布。管理端/H5/后端产物、21 个迁移、目标 schema 与关键生产页面已经验证;正式 OTP 写链路、真实手机权限、外部模型和严格试点仍未验收。文中带日期的“本轮未发布”仅描述当时批次,不再代表当前现网。 +> +> 后续边界更新(2026-07-21):工作助手确认式采集已实现并部署;本文“个人 AI 助理未实现”仅继续适用于个人文件/网页、独立个人知识空间、分享和 PPT 等完整页面。项目名称选择、今日工作成果和主管项目汇总仍按[后续迭代计划](工作助手与今日工作成果迭代计划-20260721.md)管理。 > 日期:2026-07-17 > Figma 文件:[银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd) @@ -58,7 +60,7 @@ Figma 当前存在五类风险: | 问 AI(全网) | 旧版无;会议基线是企业内部知识 | 原设计索引未见独立外部 AI 入口、来源和免责声明 | 已与问师傅分入口/接口/来源并发布,提供方须测试后启用 | **新增范围已批准并实现**:Figma 仍需补来源、禁用、错误和免责声明状态;正式提供方待验收 | | 图片/视频提问 | 历史文档列为新增/后置情境能力 | `79:11` 有回答侧视频内容,但没有提问附件上传流程 | 已补本次查询的图片/视频附件分析并发布 | **设计仍需追齐**:补权限、PII 外发、大小/失败状态,真机待验收 | | 开放问题榜/有奖问答 | 原总纲列阶段二,2026-07-17 客户明确要求一并实现 | `95:6` 等画板已完整设计 | 提问、回答、审核、最佳答案、积分/学分已实现并发布 | **范围例外已落地**:正式运营规则和认证写链路待验收;不含现金/提现 | -| 个人 AI 助理/个人资料库 | 2026-07-11 新增,明确阶段二 | Figma 已有完整画板组 | 当前未实现 | **阶段混放风险**:从当前阶段一研发指令中排除,另建阶段二验收表 | +| 个人 AI 助理/个人资料库 | 2026-07-11 新增,明确阶段二;2026-07-19 增加确认式采集 | Figma 已有完整画板组 | “记一下”确认式采集已实现并部署;个人文件/网页资料库、发布审核、汇报/PPT 未实现 | **阶段混放风险**:不得用整组“已实现”或“未实现”概括;当前能力按专项 TechSpec 验收,后续能力另建阶段二验收表 | ## 4. 根因判断 diff --git a/docs/README.md b/docs/README.md index 0056cf9b..1f542f02 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,17 @@ 根目录 [README.md](../README.md) 是项目总入口。本文件只维护 `docs/` 区的文档索引。 +## 事实源优先级 + +同一事项出现不同表述时,按以下顺序裁决,不以会议原文、旧计划或设计图推断当前实现: + +1. [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) 定义当前业务范围,[物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) 定义工程契约。 +2. [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) 与 [API_INTEGRATION.md](API_INTEGRATION.md) 说明当前实现、部署证据和接口限制。 +3. 专项 TechSpec 细化单个领域;实施计划只描述尚未完成的施工顺序,不构成“已经实现”的证据。 +4. `prototypes/` 只作视觉回归基准;会议纪要、历史 Prompt、`legacy/` 与 `archive/` 只作需求来源和追溯材料。 + +工作助手相关术语、当前本地实现证据与文档关系集中在[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md) §0、§1.5;不得把“本地今日成果已验证”外推为“已经生产发布、完整个人知识空间或外部流转已经实现”。 + ## 当前项目文档 | 文档 | 用途 | @@ -22,10 +33,13 @@ | [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 | | [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜、员工个人 AI 助理) → 北森对接;身份三轴模型与防遗漏分发策略 | | [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 工作助手确认式统一采集的当前契约,以及后续个人知识空间的数据分域、检索、权限、安全和测试规格 | +| [工作助手与今日工作成果迭代计划-20260721.md](工作助手与今日工作成果迭代计划-20260721.md) | 工作助手项目选择、统一采集、来源追溯、今日成果、主管视图、成果投稿和外部流转的分阶段计划;含本地实施状态、390×844 证据和剩余生产边界 | | [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 后续个人资料/网页/PPT 能力的 P0/P1 任务计划;“记一下”当前实现以专项 TechSpec 为准 | | [prototypes/personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) | 个人助理并入「问 · 数字师傅」后的语音优先会话、自动内部路由与确认记忆卡实现基准 | -| [prototypes/work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png) | 工作上报从传统表单升级为微信式语音/照片/视频采集、AI 整理追问和确认审核的高保真实现基准 | -| [WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md](WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md) | 可直接交给独立开发 Agent 的工作上报实现 Prompt,包含文件边界、API、交互修订、测试和视觉验收口径 | +| [prototypes/work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png) | 当前“工作上报”(产品目标名“成果投稿”)会话式页面的视觉回归基准;不代表工作助手日常采集或今日成果页 | +| [prototypes/work-assistant-project-context-v3.png](prototypes/work-assistant-project-context-v3.png) | 工作助手多项目、项目化确认卡与今日成果的权威高保真输入;运行截图另见迭代计划 §1.5 | +| [visual-evidence/20260721/](visual-evidence/20260721/) | 工作助手项目选择、确认卡、员工今日成果和主管项目成果的 390×844 本地运行截图证据 | +| [presentations/数字师傅工作助手迭代汇报-20260721.pptx](presentations/数字师傅工作助手迭代汇报-20260721.pptx) | 工作助手、多项目、今日成果、权限与发布边界的 5 页内部汇报稿 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 | | [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | @@ -54,6 +68,7 @@ | 路径 | 用途 | |---|---| +| [WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md](WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md) | 已完成的工作上报会话化历史实施说明,仅保留当前边界和后续权威入口,不再作为可执行 Prompt | | [legacy/物业AI陪练系统一期建设实施方案.md](legacy/物业AI陪练系统一期建设实施方案.md) | 旧陪练系统一期方案,作为 HR 系统范围收敛的参考 | | [legacy/物业AI陪练系统建设方案研判报告.md](legacy/物业AI陪练系统建设方案研判报告.md) | 旧陪练系统方案研判 | diff --git a/docs/WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md b/docs/WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md index bd4fd28c..aca4ff18 100644 --- a/docs/WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md +++ b/docs/WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md @@ -1,130 +1,27 @@ -# 工作上报语音会话化开发 Agent Prompt +# 工作上报会话化历史实施说明 -将下面整段 Prompt 交给负责“工作上报”的开发 Agent。该任务与个人助理开发并行时,工作上报 Agent 只负责本文列出的文件,不修改 `mobile-uni/src/pages/user/sop/index.vue` 或个人助理后端包。 +> 状态:已归档,不再作为可执行 Agent Prompt。 +> +> 原任务已完成:`mobile-uni/src/pages/user/report/index.vue` 已复用 `ChatComposer`,后端已有 `/api/aihr/work-report/organize`、附件上传、幂等提交、“我的上报”和主管/运营审核链。生产 H5、JAR 与目标表结构已确认包含该能力;正式账号和业务审核样本仍需独立验收。 ---- +## 当前术语 -你正在 `/Users/yuanjiantsui/dev/11-project/wygj` 开发银城员工端 APP 的“工作上报”。请直接完成实现、测试和视觉验证,不要只输出方案。 +- 页面和 API 当前仍名“工作上报”。 +- 该链路只承载优秀案例、操作视频、完整 SOP 和有用知识,产品目标名称为“成果投稿”。 +- 日常晨会、巡检、业主服务、住户画像、服务线索和待跟进事项进入工作助手,不进入 `aihr_work_report`。 -## 必读事实源 +## 当前限制 -1. `AGENTS.md` -2. `docs/20260708/数字师傅学练问报整合方案.md` §1.3.4 -3. `docs/物业AI人力资源系统业务需求文档BRD.md` §4.6 -4. `docs/物业AI人力资源系统开发规格TechSpec.md` §5.3.1–5.3.2 -5. 高保真基准:`docs/prototypes/work-report-voice-conversation-v1.png` +- AI 整理只读取员工文字/ASR 转写、当前草稿和附件名称。 +- 附件名称是不可信元数据;当前整理服务没有分析图片或视频内容。 +- 审核通过不自动进入案例库或企业知识库。 +- 页面正式改名、项目归属、工作助手来源追溯和今日成果尚未实施。 -设计图只约束结构和视觉方向。若图中存在“文字已经显示但仍写转文字”“承诺自动隐藏房号”“类型看似已审核”“继续补充入口重复”等问题,以 TechSpec §5.3.2 为准。 +## 后续施工入口 -## 目标 +- 业务边界:[物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) +- 当前开发契约:[物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) +- 当前接口:[API_INTEGRATION.md](API_INTEGRATION.md) +- 下一阶段:[工作助手与今日工作成果迭代计划-20260721.md](工作助手与今日工作成果迭代计划-20260721.md) -把当前 `mobile-uni/src/pages/user/report/index.vue` 的传统表单主路径改成适合物业一线员工的微信式会话: - -`语音/文字/照片/视频 → AI 整理 → 必要时追问 → 可编辑确认卡 → 用户确认上报 → 原审核链 → 会话内状态` - -保留现有 `aihr_work_report`、附件上传、我的上报和主管审核,不创建第二套正式记录或草稿表。 - -## 文件所有权 - -主要允许修改: - -- `mobile-uni/src/pages/user/report/index.vue` -- `mobile-uni/src/services/work-report.ts` -- `mobile-uni/tests/work-report.test.mjs`(新建或扩展现有契约测试) -- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/report/*` -- 对应的 `backend/ruoyi-modules/ruoyi-aihr/src/test/**/report/*` - -优先直接复用,不要复制或重写: - -- `mobile-uni/src/components/chat/ChatComposer.vue` -- `mobile-uni/src/services/speech.ts` -- 现有 OSS、登录态、模型配置和 JSON 结构化输出能力 - -不要修改: - -- `mobile-uni/src/pages/user/sop/index.vue` -- `org.dromara.aihr.personal` 或个人助理相关 SQL/文档 -- 企业知识检索与短会话实现 - -多人共享工作区,不要回滚或覆盖其他 Agent 的改动。 - -## 后端要求 - -1. 保留现有接口: - - `POST /api/aihr/work-report/attachment` - - `POST /api/aihr/work-report/reports` - - `GET /api/aihr/work-report/reports/mine` - - `GET /api/aihr/work-report/reports` - - `POST /api/aihr/work-report/reports/{id}/review` -2. 新增登录态无状态整理接口: - -```text -POST /api/aihr/work-report/organize -req: { transcript, currentDraft?, attachmentOssId?, attachmentName? } -resp: { suggestedType, title, content, missingQuestions[], privacyWarnings[], confidence } -``` - -3. `organize` 只返回草稿,绝不能写 `aihr_work_report`、创建审核记录或自动入知识库。 -4. 复用现有 chat 模型运行时,要求结构化 JSON;模型未配置、超时或 JSON 解析失败时,不编造事实,返回基于原始转写的可编辑草稿和明确降级提示。 -5. `suggestedType` 只能是 `CASE / VIDEO / SOP / KNOWLEDGE`。正式提交继续由现有 `create` 做白名单、标题/正文长度和 `VIDEO` 附件校验。 -6. `privacyWarnings` 只提示检测到的姓名、手机号、房号等风险,不得宣称已经脱敏。没有真实服务端脱敏就不要返回“提交后会自动隐藏”。 -7. 所有接口继续从登录态解析租户、用户和权限,不接受客户端提交人身份;附件不返回公开 OSS URL。 -8. 审核通过仍只更新 `APPROVED`,不自动写案例库/知识库、不自动计分。 - -## 移动端要求 - -1. 页面标题“工作上报”,右上角“我的上报”。首屏不出现四类选择网格、标题输入框或大段说明表单。 -2. 复用 `ChatComposer`,默认“按住说话”,键盘为次级切换,支持图片/视频/音频附件。 -3. 用户语音经 ASR 后显示“语音转文字”气泡;整理接口根据当前草稿和补充内容生成下一版草稿。 -4. `missingQuestions` 非空时只展示最必要的 1–2 个追问,不生成可提交状态。 -5. 短追问直接显示文字并提供播放按钮;长回答才使用“语音条 + 转文字/收起文字”。文字已经可见时不得继续显示“转文字”。 -6. 确认卡展示:建议类型、标题、场景/经过、处理、结果、附件、隐私提示。建议类型必须可修改,正式提交使用员工最后确认值。 -7. 卡片只保留“修改 / 确认上报”。继续补充统一使用底部语音/文字输入,可显示弱提示“还可以继续说话补充”。 -8. 隐私提示必须真实:未实现服务端脱敏时写“检测到具体房号,请修改后再提交”,确认卡展示的就是即将提交的最终文本。 -9. 点击“确认上报”后才调用 `POST /reports`;成功在会话中显示 `PENDING / 待审核` 状态卡,并能从“我的上报”读取同一记录。 -10. 模型失败、ASR 失败、上传失败、重复点击和未登录都要有明确可恢复状态,不使用假数据。 - -## 最小测试 - -后端至少覆盖: - -- 整理接口不写数据库。 -- 非法类型被拒绝或归一化为允许值。 -- 模型失败不编造内容。 -- 未登录 401、跨租户不可见、普通员工不能审核。 -- 正式提交幂等防重复点击(若现有接口尚无幂等,增加最小请求幂等键并测试)。 - -移动端至少覆盖: - -- 页面默认语音模式且不再呈现传统表单主路径。 -- 文字追问可见时不存在“转文字”按钮。 -- “建议类型”可修改,提交使用修改后的类型。 -- 未实现脱敏时不存在“提交后自动隐藏”承诺。 -- 只有一个继续补充入口,确认前不调用正式提交接口。 - -运行: - -```bash -npm --prefix mobile-uni run test:unit -npm --prefix mobile-uni run typecheck -npm --prefix mobile-uni run build:h5 -mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am test -``` - -## 视觉与交互验证 - -启动真实 H5,在 390×844 视口完成并截图: - -1. 初始语音输入。 -2. 带照片/视频的用户消息。 -3. AI 追问。 -4. 完整确认卡。 -5. 修改建议类型。 -6. 确认后待审核状态。 -7. “我的上报”可见同一记录。 - -检查固定输入栏不遮挡确认按钮,无横向滚动、文字截断、重复入口和虚假脱敏文案。截图不是功能验证的替代,必须实际点击受影响交互。 - -## 完成口径 - -分别报告:implemented、locally verified、visually verified、deployed、production-verified。没有真实生产账号、真实附件和线上审核链验证时,不得写 production-verified。提交时只暂存你负责的文件,使用 Conventional Commit,并给出提交哈希、测试结果、截图路径和未完成项。 +不得再按本文件重建工作上报页面、接口、表或第二套会话组件。 diff --git a/docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx b/docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx new file mode 100644 index 00000000..e3476177 Binary files /dev/null and b/docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx differ diff --git a/docs/prototypes/work-assistant-project-context-v3.png b/docs/prototypes/work-assistant-project-context-v3.png new file mode 100644 index 00000000..9335c762 Binary files /dev/null and b/docs/prototypes/work-assistant-project-context-v3.png differ diff --git a/docs/visual-evidence/20260721/employee-daily-work-result-fixed-390x844.png b/docs/visual-evidence/20260721/employee-daily-work-result-fixed-390x844.png new file mode 100644 index 00000000..87a551a8 Binary files /dev/null and b/docs/visual-evidence/20260721/employee-daily-work-result-fixed-390x844.png differ diff --git a/docs/visual-evidence/20260721/supervisor-project-work-results-fixed-390x844.png b/docs/visual-evidence/20260721/supervisor-project-work-results-fixed-390x844.png new file mode 100644 index 00000000..e914f082 Binary files /dev/null and b/docs/visual-evidence/20260721/supervisor-project-work-results-fixed-390x844.png differ diff --git a/docs/visual-evidence/20260721/work-assistant-inspection-card-autoscroll-390x844.png b/docs/visual-evidence/20260721/work-assistant-inspection-card-autoscroll-390x844.png new file mode 100644 index 00000000..242dc5f9 Binary files /dev/null and b/docs/visual-evidence/20260721/work-assistant-inspection-card-autoscroll-390x844.png differ diff --git a/docs/visual-evidence/20260721/work-assistant-project-picker-fixed-390x844.png b/docs/visual-evidence/20260721/work-assistant-project-picker-fixed-390x844.png new file mode 100644 index 00000000..90b7234e Binary files /dev/null and b/docs/visual-evidence/20260721/work-assistant-project-picker-fixed-390x844.png differ diff --git a/docs/visual-evidence/20260721/work-assistant-selected-project-390x844.png b/docs/visual-evidence/20260721/work-assistant-selected-project-390x844.png new file mode 100644 index 00000000..e399c072 Binary files /dev/null and b/docs/visual-evidence/20260721/work-assistant-selected-project-390x844.png differ diff --git a/docs/个人AI助理阶段二专项TechSpec.md b/docs/个人AI助理阶段二专项TechSpec.md index 78b250db..5c20afa8 100644 --- a/docs/个人AI助理阶段二专项TechSpec.md +++ b/docs/个人AI助理阶段二专项TechSpec.md @@ -1,6 +1,6 @@ # 员工个人 AI 助理 / 个人知识空间专项 TechSpec -> 版本:v1.2 | 日期:2026-07-19 +> 版本:v1.4 | 日期:2026-07-21 > > 归属:《银城员工端 APP 分阶段实施总纲》阶段二 > @@ -11,8 +11,12 @@ > v1.1 需求补充:客户明确提出,员工在对话中描述住户需求、待跟进事项或个人经验时,个人助理应识别值得长期保留的信息,返回可编辑确认卡;仅在用户确认后写入对应记忆域,后续问答可按权限召回。模型不得直接修改数据库,提醒必须单独确认。 > > v1.2 实施收口:显式“记一下 / 帮我记 / 保存一下”统一生成 `ASSISTANT_CAPTURE` 确认卡;楼栋、单元、房号和项目均为可补充信息,不再阻断保存。用户必须选择“仅自己保存 / 提交公司处理”;公司处理首批只可靠写入待流转队列,未配置正式接收接口前不得显示“已送达”。音色不在本次范围。 +> +> v1.3 文档收口:v1.2 描述的是 2026-07-20 已部署行为;多项目工作采集按[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)执行。个人笔记允许项目为空,项目工作记录必须先按名称选择当前项目;项目编码只作内部授权字段,不由用户输入。 +> +> v1.4 本地实施回填:项目名称选择、项目化短会话、项目化确认卡、来源快照、业务状态历史、员工今日成果和主管项目成果已完成本地实现、真实 HTTP 与 390×844 验证,尚未提交、部署或生产验证。外部线索/工单/考勤投递仍保持 PENDING。 -当前代码状态(2026-07-20):候选检测、六类系统建议、同会话合并、可编辑确认、PRIVATE/COMPANY 持久化、可选跟进时间、“我的资料与记忆”列表,以及确认记录按 owner/项目权限参与后续问答召回均已实现。召回引用明确区分 `PERSONAL / COMPANY / PROJECT_SERVICE`。外部线索/工单投递 Worker、正式回执、统一采集转工作上报确认卡,以及个人文件/网页知识空间仍是后续工作,开发者不得从 `PENDING` 状态推断这些能力已完成。 +当前代码状态(2026-07-21 复核):2026-07-20 生产版本已包含候选检测、可编辑确认、PRIVATE/COMPANY 持久化、可选跟进时间、“我的资料与记忆”列表和受控召回。当前工作树进一步完成多项目、`ATTENDANCE/INSPECTION` 分类、来源绑定、状态闭环和今日成果,但这部分仍只完成本地验证。个人文件/网页知识空间、PPT 自动生成、外部线索/工单投递 Worker 和正式回执仍是后续工作;开发者不得从 `PENDING` 推断已派单或已送达。 ## 0. 交互设计基准 @@ -20,6 +24,8 @@ 设计资产:[personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) +多项目与今日成果联合高保真资产:[work-assistant-project-context-v3.png](prototypes/work-assistant-project-context-v3.png),仓库绝对路径为 `/Users/yuanjiantsui/dev/11-project/wygj/docs/prototypes/work-assistant-project-context-v3.png`;逐状态运行截图路径见[迭代计划 §1.5](工作助手与今日工作成果迭代计划-20260721.md#15-本地运行视觉证据390844)。 + 该图是阶段二「问」页和确认记忆流程的实现参考,不是当前生产截图。后续 Agent 实施 `mobile-uni/src/pages/user/sop/index.vue` 与记忆确认卡时应遵守以下不变量: 1. 页面标题保持“问 · 数字师傅”,一级只区分“工作助手 / 查全网”,不增加“个人助理”一级 Tab。 @@ -202,12 +208,14 @@ org.dromara.aihr.knowledge.parse ### 3.4 项目业务记忆权限 -1. `project_code` 必须由当前登录员工的有效项目任职解析,沿用 `aihr_org_snapshot.project_code` 和现有知识授权口径;客户端不得指定越权项目。 -2. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_code`,创建人不因“本人记录”获得跨项目访问权。 -3. 住户姓名、电话、身份证件和健康信息不作为自由文本检索字段;首批优先保存房屋定位、需求摘要、跟进状态和必要说明。 -4. 发送外部模型前先由服务端提取并令牌化可识别信息;模型输出不得覆盖服务端保存的主体定位。 -5. 员工修改已确认记录必须携带 `expectedVersion`,成功后新增版本快照,不原地抹除历史。 -6. 项目业务记忆不能通过个人资料删除接口删除;作废使用 `VOID` 状态并保留操作人、原因和版本记录。 +1. 个人笔记允许 `project_code` 为空;晨会、巡检、业主服务、画像、线索等项目工作记录必须先选择当前项目。 +2. 客户端展示员工有权限的项目名称下拉列表,不展示或要求手输项目编码;选择结果可携带内部项目标识,服务端必须按当前登录员工的有效项目任职重新解析并校验,不能信任客户端字段。 +3. 项目授权沿用 `aihr_org_snapshot.project_code` 和现有知识授权口径;同一员工的多项目成员关系按 `tenant_id + project_code + ext_party_id` 保留,客户端不得指定越权项目。 +4. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_code`,创建人不因“本人记录”获得跨项目访问权。 +5. 住户姓名、电话、身份证件和健康信息不作为自由文本检索字段;首批优先保存房屋定位、需求摘要、跟进状态和必要说明。 +6. 发送外部模型前先由服务端提取并令牌化可识别信息;模型输出不得覆盖服务端保存的主体定位。 +7. 员工修改已确认记录必须携带 `expectedVersion`,成功后新增版本快照,不原地抹除历史。 +8. 项目业务记忆不能通过个人资料删除接口删除;作废使用 `VOID` 状态并保留操作人、原因和版本记录。 ## 4. 数据模型 @@ -329,11 +337,13 @@ Message 保存: ### 4.8 `aihr_assistant_capture` -用户确认后的统一采集事实源。核心字段为 `tenant_id/owner_user_id/project_code/save_scope/suggested_type/building_name/unit_name/room_no/category/summary/detail/occurred_at/follow_up_at/source_conversation_id/source_request_id/delivery_status/delivery_attempts/next_delivery_at/external_record_id/last_error/confirm_idempotency_key/confirm_request_hash/version/create_time/update_time`。 +用户确认后的统一采集事实源。核心字段为 `tenant_id/owner_user_id/project_code/save_scope/suggested_type/building_name/unit_name/room_no/category/summary/detail/occurred_at/follow_up_at/work_date/business_status/source_snapshot_json/source_conversation_id/source_request_id/delivery_status/delivery_attempts/next_delivery_at/external_record_id/last_error/confirm_idempotency_key/confirm_request_hash/version/create_time/update_time`。 - `save_scope=PRIVATE` 时 `delivery_status=NOT_REQUIRED`,查询必须同时带 `tenant_id + owner_user_id`。 - `save_scope=COMPANY` 时首批写 `delivery_status=PENDING`;`DELIVERED` 只能由拿到外部成功回执的投递 Worker 更新。 - `confirm_idempotency_key` 在租户和 owner 范围唯一,确认事务先写采集记录再关闭候选,任一步失败整体回滚。 +- `business_status` 固定为 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`;状态历史保留原状态、目标状态、操作人、说明和时间,状态幂等键不得跨记录或目标状态复用。 +- `source_snapshot_json` 只保存最小必要来源快照和受保护 OSS 引用,源文件通过重新鉴权接口交付,不保存公开 URL。 - 表内不保存原始录音、模型原始输出或伪造的住户主体 ID;音色不属于本表和本次实现。 ### 4.9 `aihr_service_memory` @@ -513,6 +523,11 @@ Message 保存: | POST | `/memory-candidates/{id}/confirm` | 提交编辑后的草稿、`expectedVersion`、`idempotencyKey`;分域写入 | | POST | `/memory-candidates/{id}/dismiss` | 明确不保存;同源候选本会话不再提示 | | GET | `/assistant-captures?saveScope=PRIVATE|COMPANY&limit=50` | 查看本人的已确认记录及公司流转状态 | +| POST | `/assistant-captures/{id}/status` | 带 `businessStatus/note/idempotencyKey` 推进本地处理状态 | +| GET | `/assistant-captures/{id}/status-history` | 查看状态审计历史 | +| GET | `/assistant-captures/{id}/source` | 重新鉴权读取确认记录绑定的原始媒体 | +| POST | `/api/aihr/work-results/mine/generate` | 按员工、项目和自然日幂等生成今日成果 | +| GET | `/api/aihr/work-results/project` | 主管按目标项目权限查看团队当日成果 | | GET | `/api/aihr/service-memories` | 按当前项目、状态、日期、楼栋/单元/房号查询 | | GET | `/api/aihr/service-memories/{id}` | 项目权限内查看详情、来源和版本 | | PATCH | `/api/aihr/service-memories/{id}` | 携带 `expectedVersion` 修改必要字段或状态并生成版本 | @@ -532,13 +547,15 @@ Message 保存: "category": "服务线索", "summary": "住户希望了解社区可提供的养老服务", "occurredAt": "2026-07-19T14:00:00+08:00", + "workDate": "2026-07-19", + "businessStatus": "PENDING", "followUpAt": null }, "enableReminder": false } ``` -服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;客户端可省略该字段。确认成功返回 `targetDomain=PERSONAL|COMPANY/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。 +服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;个人笔记可省略该字段,项目工作记录不得省略。确认成功返回 `targetDomain=PERSONAL|COMPANY/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。 ## 7. 网页采集安全 diff --git a/docs/个人AI助理阶段二开发推进计划.md b/docs/个人AI助理阶段二开发推进计划.md index 46b46bba..1fc69857 100644 --- a/docs/个人AI助理阶段二开发推进计划.md +++ b/docs/个人AI助理阶段二开发推进计划.md @@ -8,9 +8,9 @@ **Tech Stack:** Spring Boot / Java 21、JdbcTemplate、Sa-Token、MySQL 8 Fulltext、Qdrant REST、MinIO/sys_oss、Apache Tika、uni-app Vue 3、TypeScript、Node test runner。 -**交互设计基准:** [工作助手语音优先与确认记忆设计图](prototypes/personal-assistant-work-helper-voice-first-v2.png)。该图是 Task 10 的实现参考,不是当前生产截图;安全、权限、状态机和 API 冲突时以 [专项 TechSpec](个人AI助理阶段二专项TechSpec.md) 为准。 +**交互设计基准:** [工作助手语音优先与确认记忆设计图](prototypes/personal-assistant-work-helper-voice-first-v2.png)。该图用于现有确认式采集的视觉回归,不是当前生产截图;安全、权限、状态机和 API 冲突时以 [专项 TechSpec](个人AI助理阶段二专项TechSpec.md) 为准。 -**当前边界:** Task 10 的“记一下”已复用 `/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成本地闭环;本计划其余个人文件、网页、独立 `/ask`、分享和 PPT 任务仍未实施,不得按计划文本推断已完成。 +**当前边界:** Task 10 的“记一下”已复用 `/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成实现并部署;本计划其余个人文件、网页、独立 `/ask`、分享和 PPT 任务仍未实施,不得按计划文本推断已完成。项目名称选择、项目化会话、来源追溯和今日工作成果不在本文件重复规划,统一以[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)为施工入口。 --- @@ -979,135 +979,12 @@ Expected: 单测 PASS;匿名请求业务响应为 401,接口没有 @SaIgnore --- -### Task 10: 将现有「问」页升级为工作助手并接入个人助理 +### Task 10: 工作助手确认式采集(已完成,不重复施工) -**Design reference:** [personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) - -实现时复用当前 `ChatComposer` 的默认语音模式和师傅回答的“语音条 → 转文字”形态,不重建第二套会话组件。设计图中的确认卡用于约束信息层级和关键动作;首屏不暴露三种内部资料域,来源在回答或保存结果中按实际命中展示。 - -**Files:** -- Create: mobile-uni/src/services/personal-assistant.ts -- Create: mobile-uni/src/pages/user/assistant/library.vue -- Create: mobile-uni/src/pages/user/assistant/capture.vue -- Create: mobile-uni/src/pages/user/assistant/item.vue -- Create: mobile-uni/src/pages/user/assistant/sessions.vue -- Create: mobile-uni/tests/personal-assistant.test.mjs -- Modify: mobile-uni/src/pages.json -- Modify: mobile-uni/src/pages/user/sop/index.vue -- Modify: mobile-uni/src/pages/user/web-ai/index.vue -- Modify: mobile-uni/src/types/api.ts - -- [ ] **Step 1: 写失败的页面契约测试** - - import test from 'node:test'; - import assert from 'node:assert/strict'; - import { readFile } from 'node:fs/promises'; - - const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); - - test('工作助手默认自动选择内部资料范围', async () => { - const page = await source('../src/pages/user/sop/index.vue'); - assert.match(page, /工作助手/); - assert.match(page, /资料范围/); - assert.match(page, /自动/); - assert.match(page, /收藏资料/); - assert.match(page, /citation\.domain/); - }); - - test('一级入口只区分工作助手与查全网', async () => { - const internalPage = await source('../src/pages/user/sop/index.vue'); - const webPage = await source('../src/pages/user/web-ai/index.vue'); - assert.match(internalPage, /工作助手/); - assert.match(webPage, /查全网/); - assert.doesNotMatch(internalPage, /企业问师傅\s*\/\s*个人助理\s*\/\s*全网/); - }); - - test('个人资料页面包含状态、重试和删除', async () => { - const page = await source('../src/pages/user/assistant/item.vue'); - assert.match(page, /PARSING|解析中/); - assert.match(page, /retryPersonalItem/); - assert.match(page, /deletePersonalItem/); - }); - -- [ ] **Step 2: 运行测试确认失败** - -Run: - - npm --prefix mobile-uni run test:unit - -Expected: FAIL,现有「问」页尚未提供工作助手 AUTO 范围和简化后的一级入口。 - -- [ ] **Step 3: 实现 API 客户端** - -personal-assistant.ts 固定使用现有 apiRequest: - - export type InternalKnowledgeScope = 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE'; - - export const askPersonalAssistant = (payload: { - sessionId?: number; - queryText: string; - scopeMode: 'AUTO' | 'MANUAL'; - scope: InternalKnowledgeScope[]; - dateFrom?: string; - dateTo?: string; - itemIds?: number[]; - outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE'; - }) => apiRequest({ - url: '/api/aihr/personal-assistant/ask', - method: 'POST', - data: payload, - timeout: 45000 - }); - - export const deletePersonalItem = (id: number) => - apiRequest<{ cleanupJobId: number }>({ - url: '/api/aihr/personal-assistant/items/' + id, - method: 'DELETE', - timeout: 15000 - }); - -- [ ] **Step 4: 实现页面交互** - -关键状态必须唯一: - - const scopeMode = ref<'AUTO' | 'MANUAL'>('AUTO'); - const manualScopes = ref([]); - const queryText = ref(''); - const asking = ref(false); - const answer = ref(); - - const submitQuestion = async () => { - const question = queryText.value.trim(); - if (!question || asking.value) return; - asking.value = true; - try { - answer.value = await askPersonalAssistant({ - queryText: question, - scopeMode: scopeMode.value, - scope: scopeMode.value === 'MANUAL' ? manualScopes.value : [], - outputFormat: 'ACTION_PLAN' - }); - } finally { - asking.value = false; - } - }; - -一级只显示“工作助手 / 查全网”。工作助手默认 `AUTO`,只在 PERSONAL / PROJECT_SERVICE / ENTERPRISE 三个内部授权域路由;“资料范围”放在输入区次级设置中。引用卡按 `citation.domain` 显示“我的资料 / 项目记录 / 企业 SOP”,让用户看得见实际来源。公网查询继续使用独立页面和接口,内部无命中时只能提示用户主动进入“查全网”,不得自动转发原问题或住户/项目内容。 - -- [ ] **Step 5: 运行移动端检查** - -Run: - - npm --prefix mobile-uni run test:unit - npm --prefix mobile-uni run typecheck - npm --prefix mobile-uni run build:h5 - -Expected: 全部 PASS;无新增横向滚动;页面路由存在。 - -- [ ] **Step 6: 提交** - - git add mobile-uni/src/pages.json mobile-uni/src/pages/user/assistant mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/pages/user/web-ai/index.vue mobile-uni/src/services/personal-assistant.ts mobile-uni/src/types/api.ts mobile-uni/tests/personal-assistant.test.mjs - git commit -m "feat(mobile): add personal assistant knowledge flow" +- 已复用 `ChatComposer`、`/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成确认式采集并部署。 +- 当前生产能力、接口和限制以[专项 TechSpec](个人AI助理阶段二专项TechSpec.md)、[API 对接指南](API_INTEGRATION.md)和[BRD 功能审查](BRD_IMPLEMENTATION_AUDIT.md)为准。 +- 本文件原有的页面脚手架、失败测试和重复实现步骤已删除,避免后续 Agent 创建第二套工作助手或把未实现的个人资料 API 当成现有接口。 +- 后续项目选择、来源追溯、今日成果和主管项目视图只执行[工作助手迭代计划](工作助手与今日工作成果迭代计划-20260721.md)。 --- diff --git a/docs/工作助手与今日工作成果迭代计划-20260721.md b/docs/工作助手与今日工作成果迭代计划-20260721.md new file mode 100644 index 00000000..2248b190 --- /dev/null +++ b/docs/工作助手与今日工作成果迭代计划-20260721.md @@ -0,0 +1,592 @@ +# 工作助手与今日工作成果迭代计划 + +> 版本:v1.4 +> 日期:2026-07-21 +> 状态:迭代 0-4 已完成本地实现、目标测试、真实 HTTP 与 390×844 视觉交互验证;尚未提交、部署和生产验证 +> 适用范围:员工端工作助手、今日工作成果、主管项目视图、成果投稿及后续外部流转 +> v1.4:在 v1.3 基础上把 UI 变化、权威高保真图、仓库内运行截图和正式汇报 PPT 的准确路径统一落盘;设计目标、本地运行截图与生产状态继续严格区分。 + +## 0. 文档权威关系与冲突裁决 + +本文只回答“下一步按什么顺序建设”,不取代当前接口、生产状态或既有审核链的事实文档: + +| 文档 | 职责 | 使用规则 | +|---|---|---| +| [业务需求 BRD](物业AI人力资源系统业务需求文档BRD.md) | 决定做什么及业务边界 | 业务需求事实源 | +| [开发规格 TechSpec](物业AI人力资源系统开发规格TechSpec.md) | 说明现有工程结构与已定技术契约 | 开发层当前事实源 | +| [个人助理专项 TechSpec](个人AI助理阶段二专项TechSpec.md) | 说明确认式采集、权限、记忆和个人知识空间边界 | 工作助手专项契约 | +| [API 对接指南](API_INTEGRATION.md) | 只列当前已经存在的接口 | 不把本文规划中的接口写成可调用接口 | +| [BRD 功能审查](BRD_IMPLEMENTATION_AUDIT.md) | 区分实现、部署、生产基础验证和正式验收 | 当前状态事实源 | +| 本文 | 项目选择、统一采集、今日成果、主管视图和外部流转的实施顺序与阶段证据 | 只能按本文状态列声明本地实现;部署和生产状态仍以审查文档及真实环境为准 | +| [数字师傅整合方案](20260708/数字师傅学练问报整合方案.md) | 保留产品演进背景和“学练问报”决策 | 与当前事实冲突时以上述事实文档为准 | + +已裁决的矛盾: + +1. **项目是否必填**:当前已部署的确认式采集允许项目为空;后续迭代中,个人笔记仍可不选项目,但晨会、巡检、业主服务、画像、线索等项目工作记录必须先从项目名称下拉列表选择当前项目。用户不输入项目编码。 +2. **“工作上报”是什么**:`/pages/user/report/index` 已在员工界面改名为“成果投稿”,底层 `/api/aihr/work-report/**` 继续兼容既有优秀案例、操作视频、完整 SOP 和有用知识审核链;日常事实不进入该表。 +3. **日常记录放在哪里**:日常事实统一进入工作助手和 `aihr_assistant_capture`;不得为了日报把既有 `aihr_work_report` 改造成万能工作记录表。 +4. **附件是否已经被 AI 看懂**:工作助手的现场图片/视频已有本次分析能力;确认式采集已能把受保护 OSS 来源绑定到记录并重新鉴权回看。成果投稿链仍不能仅凭附件名称声称已经理解图片或视频内容。 +5. **“个人助理已实现”是什么意思**:确认式采集已经提交并部署,但个人文件、网页收藏、独立个人向量库、分享和 PPT 生成仍未实现;两者不能合并宣称完成。 +6. **今日成果与外部流转**:员工今日成果和主管项目成果已完成本地最小闭环;真实线索/工单投递仍未接通,`COMPANY/PENDING` 只表示待流转。 +7. **部署与验收**:2026-07-20 生产 JAR、H5 资源及目标表结构已确认包含确认式采集和会话式工作上报;这属于部署与生产基础验证,不等于多项目、真机、正式账号和业务窗口验收通过。 + +## 1. 目标与边界 + +本轮不是新建另一套聊天、上传或工作上报系统,而是在现有「问 · 数字师傅」和确认式记忆链路上补齐项目上下文、日常工作采集、按日汇总与主管查看能力。 + +一线员工只需完成三个动作: + +1. 按项目名称选择当前项目。 +2. 通过语音、文字、照片或视频说明现场情况。 +3. 检查 AI 整理结果并确认。 + +员工不需要记忆或输入项目编码,也不需要在表达前判断“线索、画像、案例、知识、工单”等专业分类。项目编码只作为前后端内部传输字段,服务端必须再次校验项目权限。 + +### 1.1 功能名称收敛 + +| 功能 | 用户理解 | 主要用途 | +|---|---|---| +| 工作助手 | 今天发生什么,直接告诉数字师傅 | 日常工作采集、记忆、待跟进 | +| 今日工作成果 | 今天做了什么,还有什么没完成 | 自动汇总、个人查看、主管查看 | +| 成果投稿 | 这条经验值得让公司复用 | 优秀案例、SOP、视频、知识投稿 | + +当前代码和页面仍使用「工作上报」名称,其四类审核内容在产品语义上对应“成果投稿”。既有审核链继续保留,日常工作事实则统一进入工作助手。 + +### 1.2 业务链路 + +```mermaid +flowchart LR + A[选择当前项目名称] --> B[语音、文字、照片或视频] + B --> C[AI 理解并生成确认卡] + C --> D[本人工作记录] + C --> E[待跟进事项] + C --> F[公司处理 PENDING] + C --> G[成果投稿建议] + D --> H[今日工作成果] + E --> H + F --> H + G --> H + H --> I[主管项目视图] +``` + +### 1.3 UI 变化与高保真基准 + +迭代 1-3 在现有「问 · 数字师傅」页面内渐进完成,不新建第二套工作助手界面: + +![工作助手项目上下文与今日成果高保真基准](prototypes/work-assistant-project-context-v3.png) + +权威资产路径:`docs/prototypes/work-assistant-project-context-v3.png`。本地生成源为 `output/imagegen/work-assistant-project-context-v3.png`,该目录被 Git 忽略,后续 Agent 必须引用前者。 + +### 1.4 UI 设计交付清单 + +| 设计交付物 | 覆盖页面与状态 | 对应迭代 | 开发引用路径 | 当前性质 | +|---|---|---|---|---| +| 工作助手项目上下文与今日成果 v3 | 工作助手主会话、当前项目、项目化确认卡、今日成果摘要、待公司处理 | 迭代 1-3,兼顾迭代 5 状态语义 | `docs/prototypes/work-assistant-project-context-v3.png` | 目标高保真图,尚非生产截图 | + +仓库内绝对路径:`/Users/yuanjiantsui/dev/11-project/wygj/docs/prototypes/work-assistant-project-context-v3.png`。 + +UI 与代码页面的对应关系: + +| UI 变化 | 员工/主管路由 | 主要实现文件 | 说明 | +|---|---|---|---| +| 当前项目名称选择器与大尺寸底部面板 | `/pages/user/sop/index` | `mobile-uni/src/pages/user/sop/index.vue` | 用户只选择项目名称,不输入或记忆项目编码;切换项目同时切换会话上下文 | +| 项目化确认卡、来源与业务状态 | `/pages/user/sop/index` | `mobile-uni/src/components/chat/MemoryCandidateCard.vue` | 确认卡展示项目、工作日期、来源和状态,并区分个人记录与待公司处理 | +| 今日工作成果摘要与员工详情 | `/pages/user/sop/index`、`/pages/user/work-results/index` | `mobile-uni/src/pages/user/sop/index.vue`、`mobile-uni/src/pages/user/work-results/index.vue` | 会话内给出当日摘要入口,详情按员工 + 项目 + 日期聚合 | +| 主管项目成果 | `/pages/supervisor/work-results/index` | `mobile-uni/src/pages/supervisor/work-results/index.vue` | 按项目查看记录人数、成果人数、待跟进和高优问题,不展示无权限项目 | +| 原“工作上报”分流为“成果投稿” | `/pages/user/report/index` | `mobile-uni/src/pages/user/report/index.vue` | 仅承载优秀案例、操作视频、完整 SOP 和知识投稿,不承载日常事实记录 | + +### 1.5 本地运行视觉证据(390×844) + +下列文件是 2026-07-21 本地开发环境的实际页面截图,用于证明实现状态和记录视觉修正;它们不是高保真设计源,也不是生产截图: + +| 实际页面或状态 | 仓库内截图路径 | 验证结论 | +|---|---|---| +| 多项目名称选择面板 | `docs/visual-evidence/20260721/work-assistant-project-picker-fixed-390x844.png` | 两个项目完整可见;面板使用 `--window-bottom` 避开底栏 | +| 已选择当前项目的工作助手 | `docs/visual-evidence/20260721/work-assistant-selected-project-390x844.png` | 顶部显示项目名称,切换提示明确,不显示项目编码 | +| 巡检确认卡自动定位到操作区 | `docs/visual-evidence/20260721/work-assistant-inspection-card-autoscroll-390x844.png` | 项目、日期、来源、业务状态与四个确认动作可操作 | +| 员工今日工作成果 | `docs/visual-evidence/20260721/employee-daily-work-result-fixed-390x844.png` | 按项目和日期聚合;已记录事项不再错误显示处理动作 | +| 主管项目成果 | `docs/visual-evidence/20260721/supervisor-project-work-results-fixed-390x844.png` | 显示记录人数、成果人数、待跟进、高优问题和员工来源类型;非文字记录可打开受保护原始来源 | + +绝对目录:`/Users/yuanjiantsui/dev/11-project/wygj/docs/visual-evidence/20260721/`。这些文件是已纳入仓库的本地验收证据;正式设计目标仍只以 `docs/prototypes/` 下的权威高保真图为准。 + +后续开发和验收使用规则: + +1. 产品、前端、后端和测试 Agent 都以 `docs/prototypes/work-assistant-project-context-v3.png` 为共同设计输入。 +2. 图中项目名称、日期、来源、业务状态和今日成果入口必须与本计划的数据与权限规则同时实现,不能只还原视觉外壳。 +3. `output/imagegen/work-assistant-project-context-v3.png` 仅保留生成过程来源,不纳入 Git,也不能写入开发任务或验收报告作为权威资产。 +4. 新增项目选择面板、今日成果详情页或主管项目视图高保真图时,继续放入 `docs/prototypes/`,并回填本表和对应迭代章节。 + +相对当前页面的变化: + +1. 页面标题继续使用「问 · 数字师傅」,顶部增加按项目名称操作的「当前项目」选择器,不展示项目编码。 +2. 保留「工作助手 / 查全网」双入口,在会话顶部增加「今日工作成果」摘要入口。 +3. 语音、照片和文字仍以微信式消息进入同一会话,不要求员工先选业务分类。 +4. 确认卡增加项目、日期、地点、事项、原始语音/现场照片来源和本地业务状态;确认后才计入今日工作成果。 +5. 「确认记录」与「提交公司处理」分开;后者只显示「进入待流转」,不得显示已派单或已送达。 +6. 底部继续以「按住说话」为主入口,键盘、快捷指令和「今日 / 练 / 问 / 我」导航保持原有交互习惯。 + +| 图中元素 | 对应迭代 | 当前状态 | +|---|---|---| +| 当前项目名称选择器、切换提示 | 迭代 1 | 已本地实现并完成 390×844 视觉交互验证 | +| 项目化确认卡、来源入口、业务状态 | 迭代 2 | 已本地实现;文字链路已完成真实 HTTP 与页面验证,媒体来源仍需生产真机回归 | +| 今日工作成果摘要入口 | 迭代 3 | 已本地实现,员工详情页和主管项目视图已完成视觉验证 | +| 提交公司处理、进入待流转 | 当前确认式采集 + 迭代 5 | `PENDING` 已有;真实外部投递和回执未实现 | + +该图只定义信息层级、交互语义和视觉方向,不是生产截图。项目选择面板、员工今日成果详情页和主管项目视图已按 390×844 运行页面完成视觉验收,证据见 §1.5;生产发布后仍需重新完成真机验收。 + +## 2. 当前能力审计 + +### 2.1 已存在并可复用 + +- 「问」页面已经支持语音优先、文字次之,以及照片和视频辅助输入。 +- 已有统一 `ChatComposer`,不得再建设第二套聊天或上传组件。 +- “记一下/帮我记/保存一下”已经具备识别、补充、确认、幂等保存链路。 +- 已有 `PRIVATE/COMPANY` 保存范围和 `NOT_REQUIRED/PENDING` 流转状态。 +- 后端已经能够得到员工有权限的项目编码集合,并在确认时校验项目权限。 +- `aihr_assistant_capture` 已包含项目、房号、分类、摘要、跟进时间、来源请求和流转状态等基础字段。 + +### 2.2 已有基础但需要补齐 + +- `/api/aihr/mobile/me` 已返回完整授权项目列表;生产组织同步仍需用真实多项目人员验证。 +- 数据层和前端已支持按项目名称选择、切换与账号隔离缓存。 +- 图片和视频可用于本次分析,确认记录已支持绑定受保护原始附件;生产真机媒体链路尚待回归。 +- 查询审计只保存问题哈希和来源类型,不能回看原始文字、语音、照片或视频。 +- 短会话已绑定当前项目,项目不一致拒绝复用。 +- 已能按“员工 + 项目 + 自然日”幂等生成工作成果。 + +### 2.3 尚未实现 + +- 真实外部线索、工单、考勤投递与回执。 +- 生产真机语音、照片、视频来源回看验收。 +- 未经确认的自动外发(本期明确不做)。 +- 原始文字、语音、照片、视频追溯。 +- 公司处理类事项的真实外部回执。 + +### 2.4 外部边界 + +以下能力在正式接口确定前只能保留 `PENDING`: + +- 养老服务线索正式送达。 +- 客户画像正式写入。 +- 正式工单系统派单。 +- 正式考勤系统同步。 +- 线索奖励和结算。 + +没有真实接口回执前,界面只能显示“已记录”或“待公司处理”,不得显示“已派单”“已同步”或“已送达”。 + +## 3. 总体实施策略 + +1. 复用现有工作助手页面、`ChatComposer`、确认卡和 `aihr_assistant_capture`。 +2. 先完成项目隔离和本地工作闭环,再建设外部接口。 +3. 工作记录按自然日聚合;问师傅仍保持 30 分钟、最近六轮短会话,不全局改为 24 小时。 +4. 分类由 AI 建议,用户只做确认或修改。 +5. 业务处理状态与外部流转状态分开,避免把“已解决”误写成“已送达外部平台”。 +6. 日报先用确定性规则聚合,LLM 只润色;模型失败时仍能生成结构化成果。 + +## 4. 迭代计划 + +## 4.1 迭代 0:统一产品与接口语义 + +**预计:0.5-1 人日** +**优先级:P0** + +### 工作内容 + +- 文档和页面统一使用“工作助手、今日工作成果、成果投稿”。 +- 明确“确认记录”和“提交公司处理”是两个动作。 +- 明确 `COMPANY/PENDING` 为待流转,不是成功回执。 +- 工作日按自然日处理,MVP 时区固定为 `Asia/Shanghai`,不采用滚动 24 小时。 +- 更新专项 TechSpec、总 TechSpec、API 文档和后续 Agent 实施提示词。 + +### 验收标准 + +- 文档、页面名称、接口状态没有互相矛盾的表述。 +- 原有工作上报审核链不被删除,也不被误改成日常记录表。 + +## 4.2 迭代 1:当前项目选择与严格隔离 + +**预计:1.5-2 人日** +**优先级:P0** + +### 用户交互 + +本迭代 UI 设计输入:`docs/prototypes/work-assistant-project-context-v3.png`,详见 [§1.3](#13-ui-变化与高保真基准) 和 [§1.4](#14-ui-设计交付清单)。顶部项目选择器和大尺寸底部面板已实现;运行截图见 §1.5。 + +页面顶部展示: + +> 当前项目:星河湾一期 ▼ + +点击后打开适合一线员工操作的大尺寸底部选择面板: + +- 只展示项目名称,不展示项目编码。 +- 项目较多时支持按名称搜索。 +- 标记当前项目和最近使用项目。 +- 切换项目前,如存在未发送内容或待确认卡,必须提示用户确认。 + +进入逻辑: + +- 只有一个项目:自动选择并展示名称。 +- 多个项目:首次进入或第一次工作记录前必须选择。 +- 没有项目:禁用工作采集,并提示联系管理员。 +- 组织权限变化导致原项目失效:清除旧选择并要求重新选择。 + +### 技术处理 + +- 扩展 `/api/aihr/mobile/me`,返回去重后的 `projects[]`。 +- `aihr_org_snapshot` 同一员工的多项目成员关系使用 `UNIQUE(tenant_id, project_code, ext_party_id)`;组织同步把有效 `employee_project_assignment` 展开为多条项目成员行,不能再按员工 ID 折叠为单项目。 +- `projectName` 仅用于展示;`projectCode` 仅作为内部传输字段。 +- 当前项目缓存按租户和登录账号隔离,退出登录时清除。 +- 查询、保存、汇总都携带内部项目值,服务端与授权项目集合再次求交集。 +- 切换项目时创建新的 `conversationId`,清除当前草稿和待确认上下文。 +- 短会话记录补充项目约束,项目不一致时拒绝复用。 + +### 验收场景 + +1. 单项目员工自动进入。 +2. 多项目员工按项目名称选择。 +3. 切换项目后旧对话不进入新项目。 +4. 篡改内部项目值时服务端返回 403。 +5. 更换登录账号后不沿用上一账号的项目。 +6. 390×844 视口下选择面板没有截断、遮挡或误触问题。 + +## 4.3 迭代 2:统一日常工作采集与来源追溯 + +**预计:3-4 人日** +**优先级:P0** + +### 分类体系 + +不让员工在表达前选择分类。系统内部沿用现有枚举并仅补充确有需要的类型,确认卡上使用通俗中文: + +- 到岗/晨会记录。 +- 巡检问题。 +- 业主服务记录。 +- 待跟进事项。 +- 住户画像。 +- 服务线索。 +- 经验案例候选。 +- 项目工作记录。 +- 个人笔记。 + +兼容既有 `SERVICE_LEAD / RESIDENT_PROFILE / CASE / FOLLOW_UP / PROJECT_NOTE / PERSONAL_NOTE`,不重建平行分类系统。 + +### 确认卡 + +本迭代 UI 设计输入:`docs/prototypes/work-assistant-project-context-v3.png`,详见 [§1.3](#13-ui-变化与高保真基准) 和 [§1.4](#14-ui-设计交付清单)。确认卡的项目、日期、来源和业务状态已实现;文字巡检场景运行截图见 §1.5,媒体来源仍需生产真机回归。 + +确认卡至少展示: + +- 当前项目名称。 +- 发生日期。 +- AI 建议类型。 +- 地点或对象。 +- 事项摘要。 +- 跟进时间。 +- 原始文字、语音或媒体入口。 + +操作保持精简: + +- 确认记录。 +- 修改。 +- 暂不保存。 +- 提交公司处理。 + +“提交公司处理”不是默认动作,AI 不得自动替用户执行。 + +### 数据补充 + +在候选记录和确认记录中补充: + +- `work_date`:自然工作日期。 +- `business_status`:本地业务处理状态。 +- `source_snapshot_json`:最小必要来源快照。 + +`source_snapshot_json` 记录: + +- 来源类型:`TEXT/VOICE/IMAGE/VIDEO`。 +- 原始文字或语音转写。 +- 受保护的 OSS 引用。 +- 文件类型、时长等必要元数据。 +- 图片或视频识别出的现场摘要。 + +不建设第二套对象存储,继续复用 `sys_oss/MinIO`。 + +### 媒体保存策略 + +- 用户发送媒体时完成识别,并产生临时来源引用。 +- 用户确认后将来源引用绑定到采集记录。 +- 未确认的临时素材在候选过期后清理。 +- 下载必须重新鉴权,不返回公开 OSS 地址。 +- 正式素材保留时长遵循租户隐私策略。 +- 测试和演示只使用脱敏或合成材料。 + +### 状态模型 + +业务状态: + +- 已记录。 +- 待跟进。 +- 处理中。 +- 已完成。 +- 已作废。 + +外部流转状态: + +- 无需流转。 +- 待流转。 +- 已送达。 +- 失败。 + +两组状态不得互相替代。 + +### 验收场景 + +1. 晨会照片加人数说明,保存为“AI 整理的到岗记录”。 +2. 巡检照片加问题说明,保存为巡检问题。 +3. 住户养老需求生成画像或线索建议,但不自动提交。 +4. 语音“帮我记一下”产生完整确认卡。 +5. 确认后的记录可以回看原始文字、语音或媒体。 +6. 重复确认不会产生重复记录。 + +## 4.4 迭代 3:今日工作成果 + +**预计:2-3 人日** +**优先级:P1** + +### 聚合口径 + +第一层:员工 + 项目 + 自然日。 +第二层:主管 + 项目 + 自然日,聚合有权限员工的记录。 + +禁止按对话轮数或最近 30 分钟聚合。短会话继续使用原有时间窗口。 + +### 报告内容 + +- 当前项目、日期和记录人。 +- 今日已完成工作。 +- 巡检问题及处理状态。 +- 业主服务和待跟进事项。 +- 新增住户画像和服务线索。 +- 到岗/晨会记录摘要。 +- 未闭环事项和下一步。 +- 每项内容的原始记录入口。 + +### 生成原则 + +- 先按记录类型和状态进行确定性分组。 +- LLM 只压缩和润色,不决定记录是否存在。 +- 生成接口必须幂等。 +- 使用一个最小日报快照表,保存租户、员工、项目、工作日期、报告 JSON、来源记录 ID、版本和生成时间。 +- 同一员工、项目和日期重新生成时更新版本,不重复创建日报。 +- 暂不实现未经确认的自动外发。 + +### 页面入口 + +本迭代 UI 设计输入:`docs/prototypes/work-assistant-project-context-v3.png`,详见 [§1.3](#13-ui-变化与高保真基准) 和 [§1.4](#14-ui-设计交付清单)。工作助手摘要入口、员工详情页和主管项目视图均已本地实现并完成 390×844 验证,运行截图见 §1.5。 + +员工端: + +- 今日首页“今日工作成果”。 +- 工作助手顶部入口。 +- “我 → 我的工作记录”。 + +主管端: + +- 今日记录人数。 +- 已形成成果人数。 +- 待跟进数量。 +- 高优先级问题。 +- 按员工查看来源记录。 + +### 验收标准 + +- 同一天多种记录正确归类。 +- 不同项目严格隔离。 +- 跨天记录不串入。 +- 每一项可以回到原始来源。 +- LLM 不可用时仍能生成结构化报告。 + +## 4.5 迭代 4:本地闭环与成果投稿分流 + +**预计:2-3 人日** +**优先级:P1** + +### 巡检和待跟进闭环 + +支持记录状态更新,并保留: + +- 操作人。 +- 操作时间。 +- 修改前后状态。 +- 处理备注。 + +### 成果投稿 + +现有「工作上报」页面改名为「成果投稿」或「经验沉淀」,保留: + +- 优秀案例。 +- 操作视频。 +- 完整 SOP。 +- 有用知识。 +- 人工审核。 + +工作助手识别到高价值内容时,只提供建议: + +> 这段处置过程可能适合作为优秀案例,是否整理成成果投稿? + +用户明确同意后,才进入既有 `aihr_work_report` 审核链。 + +### 主管能力 + +- 查看团队今日成果。 +- 查看待跟进事项。 +- 查看成果投稿审核状态。 +- 不虚构养老线索接收平台或外部工单后台。 + +## 4.6 迭代 5:外部线索、画像与工单接口 + +**预计:正式接口确定后 3-5 人日** +**当前状态:阻塞,不纳入内部 MVP** + +实施前必须取得: + +- 正式接口路径。 +- 请求字段和必填规则。 +- 鉴权方式。 +- 幂等字段。 +- 成功回执。 +- 人工复核状态。 +- 失败重试规则。 +- 奖励回调规则。 +- 测试环境。 + +实现时复用 `aihr_assistant_capture.delivery_status` 作为 Outbox: + +- `COMPANY/PENDING` 记录由 Worker 异步投递。 +- 保留请求摘要、尝试次数、错误和外部记录 ID。 +- “满 100 条”或“每小时推送”只适用于外部线索流转,不应用于全部工作记录。 +- 没有真实成功回执时绝不更新为 `DELIVERED`。 +- 外部现金奖励与当前非现金积分体系保持分离。 + +## 5. 汇报 PPT 计划与交付 + +正式汇报稿已按 16:9、白底、银城红和深灰视觉体系完成,共 5 页;员工和主管页面均使用 2026-07-21 本地 390×844 脱敏运行截图,不用概念图冒充线上效果。 + +权威 PPTX 路径:`docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx`。 + +仓库内绝对路径:`/Users/yuanjiantsui/dev/11-project/wygj/docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx`。 + +### 第 1 页:从零散记录到可追溯成果 + +- 说明语音、照片和文字如何进入同一工作助手。 +- 展示权威高保真图和本地实现/HTTP/390×844 验证状态。 + +### 第 2 页:一线员工体验 + +- 用户按项目名称选择,不输入项目编码。 +- AI 自动建议类型,员工只需确认。 +- “确认记录”与“提交公司处理”明确分开。 + +### 第 3 页:多项目与权限 + +- 展示项目选择、服务端授权求交集、项目化短会话和项目化日报。 +- 明确越权返回 403,外部系统无真实回执时只显示 `PENDING`。 + +### 第 4 页:员工与主管今日工作成果 + +- 员工看个人闭环,主管看项目汇总。 +- 事实由确定性规则聚合,LLM 不决定事实是否存在。 +- 展示幂等版本和状态历史。 + +### 第 5 页:交付状态与下一步 + +- 严格区分已实现、已本地验证、已视觉验证、待部署和待外部接口。 +- 给出备份、迁移、后端/H5 发布、真实账号回归和权限回归顺序。 + +### 交付物 + +- PPTX:`docs/presentations/数字师傅工作助手迭代汇报-20260721.pptx`。 +- PDF。 +- 演讲备注。 +- 脱敏截图素材:`docs/visual-evidence/20260721/`。 +- 无网络环境可播放的录屏备份。 + +## 6. 排期与发布批次 + +按一名熟悉项目的全栈开发者估算: + +| 阶段 | 人日 | 可独立上线 | +|---|---:|---| +| 迭代 0:文档与语义 | 0.5-1 | 是 | +| 迭代 1:项目选择与隔离 | 1.5-2 | 是 | +| 迭代 2:统一采集与来源追溯 | 3-4 | 是 | +| 迭代 3:今日工作成果 | 2-3 | 是 | +| 迭代 4:本地闭环与成果投稿分流 | 2-3 | 是 | +| PPT 与演示材料 | 1-1.5 | 是 | +| 迭代 5:外部接口 | 3-5 | 否,等待正式契约 | + +内部 MVP(迭代 0-4 加 PPT)合计约 10-15 人日。每个迭代单独提交、部署和生产验证,不进行一次性大爆炸发布。 + +推荐发布批次: + +1. **R1:项目上下文** - 迭代 0、迭代 1。 +2. **R2:一线采集闭环** - 迭代 2。 +3. **R3:今日成果与主管查看** - 迭代 3、迭代 4。 +4. **R4:外部流转** - 迭代 5,等待接口后单独发布。 + +## 7. 风险与控制 + +| 风险 | 等级 | 控制措施 | +|---|---|---| +| 多项目串数据 | 高 | 项目选择、服务端权限校验、项目化会话、日报组合唯一约束 | +| 用户被迫理解专业分类 | 高 | AI 自动建议,用户只确认或修改 | +| 把 PENDING 显示为已派单 | 高 | 业务状态和流转状态分离 | +| 原始媒体无法追溯 | 高 | OSS 受保护引用绑定确认记录 | +| 图片和住户信息泄露 | 高 | 临时素材清理、重新鉴权、脱敏测试 | +| 日期边界混乱 | 中 | 服务端按 `Asia/Shanghai` 计算 `work_date` | +| 重复保存和重复上报 | 中 | 确认、状态更新和日报均使用幂等键 | +| LLM 输出不稳定 | 中 | 分类规则和聚合确定性优先,LLM 只润色 | +| 外部接口尚未确定 | 高 | 保持 PENDING,不伪造成功 | + +## 8. 验证与完成口径 + +每个发布批次必须分别报告: + +- 已实现。 +- 本地测试通过。 +- 视觉与交互验证通过。 +- 已部署。 +- 生产验证通过。 +- 仍受外部接口或业务规则阻塞的边界。 + +最小验证链: + +1. 后端目标单元测试和数据库迁移验证。 +2. 移动端类型检查与 H5 构建。 +3. 真实本地 HTTP 链路验证创建、确认、查询、汇总和状态变化。 +4. 390×844 视口验证项目选择、文字/语音/媒体采集、确认卡、状态、今日成果。 +5. 实际操作项目切换、重复确认、来源回看和跨项目隔离。 +6. 代码审查并处理确认问题。 +7. 发布前备份,发布后使用真实试点账号完成生产回归。 + +### 8.1 2026-07-21 本地验证快照 + +- 后端目标测试通过:确认式采集、项目化会话、移动身份、多项目组织同步、今日成果和主管项目权限。 +- 移动端新增目标测试 6/6 通过,`vue-tsc --noEmit` 通过,H5 正式构建通过。 +- 真实本地 HTTP 已验证:手机号登录、两项目列表、越权项目 403、巡检识别、PRIVATE/COMPANY 确认、状态幂等重试、冲突键 409、状态历史、两项目日报隔离、内容不变版本不增长、普通员工团队视图 403、主管跨项目 403。 +- 390×844 实际交互已验证:项目面板、项目切换、文字采集、确认卡、确认保存、今日成果、状态推进、主管项目成果;截图路径见 §1.5。 +- 旧移动端组合合同测试仍有 15 条失败:其中多数是旧首页/旧“工作上报”文案合同与本轮产品定案冲突,另有 uni-app 测试打包器与当前 Vue 版本的既有兼容错误;不得据此声称全量移动端测试通过,提交前需把过期合同迁移或明确列为基线债务。 +- 尚未完成:生产迁移、正式发布、生产真机媒体回看、真实组织多项目 dry-run、外部线索/工单/考勤投递与回执。 + +## 9. 推荐施工顺序 + +内部 MVP 在 R1-R3 中按以下顺序完成: + +1. 项目名称下拉选择。 +2. 项目切换后的会话隔离。 +3. 确认卡展示项目、日期和 AI 建议分类。 +4. 保存晨会、巡检、业主服务、画像和线索。 +5. 原始来源追溯。 +6. 今日工作成果。 + +完成以上六项后,项目将形成可供客户真实体验的一线工作闭环。主管项目汇总、成果投稿联动和外部接口进入后续发布批次。 diff --git a/docs/物业AI人力资源系统业务需求文档BRD.md b/docs/物业AI人力资源系统业务需求文档BRD.md index daf7f574..9e6d3f17 100644 --- a/docs/物业AI人力资源系统业务需求文档BRD.md +++ b/docs/物业AI人力资源系统业务需求文档BRD.md @@ -1,12 +1,13 @@ # 物业行业 AI 人力资源系统 · 业务需求文档(BRD) -> 版本:v1.5(统一版)| 日期:2026-07-19 +> 版本:v1.6(统一版)| 日期:2026-07-21 > 定位:本文是本项目的**唯一事实源(Single Source of Truth)**,收敛此前所有讨论与文档,取代分散的局部材料作为业务需求基准。 > v1.1 变更:第 5 章数据模型方向补充"HR 主干已对齐《手册》卷1 第9章"结论(见 5.2)。 > v1.2 变更:新增阶段二“员工个人 AI 助理 / 个人知识空间”需求(见 4.8),明确个人资料、网页链接、时间检索、来源引用、PPT 增强项和阶段三外部 HR 数据边界。 > v1.3 变更:个人助理新增确认式长期记忆;一级交互收敛为“工作助手 / 查全网”,内部个人资料、项目业务记录和企业知识默认自动路由并透明展示来源。 > v1.4 变更:员工“工作上报”改为复用数字师傅的微信式语音会话,AI 只整理可编辑草稿,用户确认后进入原有审核链,审核通过仍不自动入知识库。 > v1.5 变更:“记一下”收敛为统一采集确认卡,系统建议分类,用户选择仅自己保存或提交公司处理;项目和房屋位置缺失不阻断保存,正式企业接口未接入前只展示待流转。 +> v1.6 变更:统一“工作助手 / 今日工作成果 / 成果投稿”边界;多项目员工按项目名称选择当前项目,用户不输入项目编码。个人笔记可不关联项目,项目工作记录必须先确认当前项目;后续迭代见[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)。 > > **收敛来源**: > - 《物业管家与生活顾问 AI 陪练系统建设方案》(原始业务方案,陪练视角) @@ -161,7 +162,9 @@ - **个人资料收藏**:员工可保存个人工作文字、PDF/Word/Excel/PPT、图片和网页链接;系统记录来源、收藏时间、主题、解析状态和原始附件,支持删除与导出。 - **个人检索与问答**:支持按日期范围、主题和来源查询个人内容,例如“上周收藏了哪些保洁管理资料”;基于选定资料总结、分析和提炼行动建议,回答必须展示引用来源。 - **内部知识自动路由**:员工默认只使用“工作助手”,系统在个人资料、项目业务记录和企业知识之间按意图和权限自动路由并展示实际引用;“资料范围”仅作为次级手动筛选,不要求普通员工预先理解数据域。 -- **确认式统一采集**:显式“记一下/帮我记/保存一下”从文字或语音转写生成可编辑候选卡,系统建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录;项目/楼栋/单元/房号缺失只作可选提示。用户必须选择“仅自己保存”或“提交公司处理”,后者在正式接收接口接入前仅进入待流转状态,不得宣称已生成工单或已上报。 +- **确认式统一采集**:显式“记一下/帮我记/保存一下”从文字或语音转写生成可编辑候选卡,系统建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录。当前版本允许项目/楼栋/单元/房号缺失并作可选提示;后续多项目迭代中,个人笔记仍可不关联项目,晨会、巡检、业主服务、画像、线索等项目工作记录必须先从项目名称下拉列表确认当前项目,项目编码不向用户展示或要求输入。用户必须选择“仅自己保存”或“提交公司处理”,后者在正式接收接口接入前仅进入待流转状态,不得宣称已生成工单或已上报。 +- **今日工作成果**:确认后的日常工作记录按“员工 + 当前项目 + 自然日”聚合为可追溯成果,至少覆盖已完成事项、巡检问题、业主服务、画像/线索、到岗摘要和未闭环事项;问师傅的 30 分钟短会话不得因此全局改成 24 小时记忆。 +- **成果投稿边界**:现有“工作上报”审核链只承载优秀案例、操作视频、完整 SOP 和有用知识,产品语义为“成果投稿”;工作助手可建议投稿,但必须由员工明确确认后进入既有审核链,不得把全部日常记录自动写入 `aihr_work_report`。 - **工作思路整理**:对日常冲突、工作灵感和零散想法生成结论、行动项、待确认事项;AI 仅辅助整理,不自动代替员工对外发布或作出管理决策。 - **内容生成**:阶段二首批提供摘要、提纲和可编辑文字;多资料对比、周报/月报和 PPT 生成作为阶段二增强项,PPT 先确认大纲与模板再生成。 - **分享与入库**:个人资料默认本人可见;分享给团队或沉淀为企业知识时必须由本人明确发起,经过权限校验、脱敏、审核和版本治理。 diff --git a/docs/物业AI人力资源系统开发规格TechSpec.md b/docs/物业AI人力资源系统开发规格TechSpec.md index 7f74f5bc..6dada811 100644 --- a/docs/物业AI人力资源系统开发规格TechSpec.md +++ b/docs/物业AI人力资源系统开发规格TechSpec.md @@ -1,6 +1,6 @@ # 物业行业 AI 人力资源系统 · 开发规格(Tech Spec) -> 版本:v1.6 | 日期:2026-07-19 +> 版本:v1.8 | 日期:2026-07-21 > 定位:**开发层唯一依据**。回答"怎么建"——工程结构、数据表、API 契约、Prompt 规格、集成适配。 > v1.1 变更:数据模型对齐《手册》卷1 第9章 HR 主干,补 3 张 backbone 表(职位职责/雇员绩效/雇用终止),新增附录 D 对照表。 > v1.2 变更:第 6 章按 Codex 设计评审重写——状态机补异常/终止态、COACH_CHECK 工程化、评分引擎分层(P0 单 LLM / P1 融合)、澄清"单场对练评分 vs 跨期能力画像"两套评分、Prompt 加固 + RAG 硬约束、新增 6.5 P0/P1 切分。 @@ -8,6 +8,8 @@ > v1.4 变更:移动端拆为独立 `mobile/` Vue/Vite Web/H5 工程,承载员工端、候选人端、主管端;后台管理端继续在 `frontend/`。 > v1.5 变更:当前用户侧以 `mobile-uni/` 为准;员工工作上报复用 `ChatComposer` 改为语音优先会话,增加无状态 AI 整理草稿契约,确认后继续提交既有 `aihr_work_report` 审核链。 > v1.6 变更:工作助手显式“记一下”复用 `/api/knowledge/query` 的 `memoryCandidate`,确认后写 `aihr_assistant_capture`;位置字段改为可选,增加 PRIVATE/COMPANY 范围、跟进时间和待流转状态。音色不在本次实现范围。 +> v1.7 文档收口:明确当前“工作上报”是四类成果投稿审核链,工作助手负责日常事实采集;项目名称选择、项目化会话、来源追溯和今日工作成果仍属后续迭代,不能从规划文档推断已实现。详见[迭代计划](工作助手与今日工作成果迭代计划-20260721.md)。 +> v1.8 本地实施回填:项目名称选择、项目化会话、确认记录来源/状态、员工今日成果、主管项目成果和“成果投稿”界面名称已完成本地实现与 390×844 验证,尚未部署和生产验证;外部线索、工单和考勤投递仍保持 PENDING。 > 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);2026-07 MVP 执行计划已归档到[《AI人力资源系统一期MVP版作战清单》](archive/2026-07-mvp-delivery/AI人力资源系统一期MVP版作战清单.md)。 > **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。 > 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。 @@ -178,7 +180,10 @@ mobile/ | `/h5/#/pages/user/today/index` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数;旧 `/h5/user` 兼容重定向 | P0跑通 | | `/h5/#/pages/candidate/index/index` | 候选人端首页,手机号登录后可面试练习、查岗位 SOP/案例、上传补充资料;旧 `/h5/candidate` 兼容重定向 | P0跑通 | | `/h5/#/pages/supervisor/index/index` | 主管端首页,查看团队、待复盘、案例/SOP 工具;旧 `/h5/supervisor` 兼容重定向 | P0跑通 | -| `/h5/#/pages/user/report/index` | 员工工作上报;目标交互复用「问」的微信式语音会话,AI 整理确认后提交既有审核链 | 已有表单链路,待交互升级 | +| `/h5/#/pages/user/sop/index` | 工作助手;按项目名称选择当前项目,语音/文字/照片/视频统一采集,确认后形成日常工作记录 | 本地已实现;媒体生产真机回归未完成 | +| `/h5/#/pages/user/work-results/index` | 员工今日工作成果;按员工、项目、自然日汇总确认记录和待跟进 | 本地已实现并完成 390×844 验证 | +| `/h5/#/pages/supervisor/work-results/index` | 主管项目成果;按目标项目权限查看员工成果和高优问题 | 本地已实现并完成 390×844 验证 | +| `/h5/#/pages/user/report/index` | 员工成果投稿;复用「问」的微信式语音会话,AI 整理确认后提交既有审核链 | 会话式页面和接口已部署;本地界面已正式改名;项目归属和媒体内容理解未实现 | | `/train/daily` | 每日一练 | P1 | | `/train/camp` | 专项训练营 | P1 | | `/train/mistakes` | 错题本 | P1 | @@ -257,22 +262,24 @@ POST /api/knowledge/case/organize req: { caseId } resp POST /api/knowledge/case/curate req: { criteria, limit } resp: { selectedCaseIds[] } // 从多案例筛选 ``` -#### 5.3.1 员工工作上报会话化交互 +#### 5.3.1 成果投稿会话化交互 -实现基准:[work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png)。后续 Agent 应复用图中的语音主入口、必要追问、结构化确认卡和显式“确认上报”,但安全、权限、长度校验和审核状态以本 TechSpec 与后端契约为准。 +实现基准:[work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png)。当前页面、整理接口、附件上传、幂等提交和“我的投稿”已经存在;该图用于视觉回归,不再作为待开发 Prompt。安全、权限、长度校验和审核状态以本 TechSpec 与后端契约为准。 -员工端 `mobile-uni/src/pages/user/report/index.vue` 的目标形态复用现有 `ChatComposer` 和「问」页消息语言,不再让四类选择、标题和说明字段占据首屏。后台仍使用 `aihr_work_report` 及现有审核链,避免为视觉改版复制数据模型。 +员工端 `mobile-uni/src/pages/user/report/index.vue` 已复用现有 `ChatComposer` 和「问」页消息语言,不再让四类选择、标题和说明字段占据首屏。后台仍使用 `aihr_work_report` 及现有审核链,避免为视觉改版复制数据模型。 + +当前能力边界:附件可以上传并随正式记录保存,但成果投稿 `organize` 只根据员工文字/ASR 转写、草稿和附件名称整理,附件名称是不可信元数据;该审核链没有对图片或视频内容做视觉理解。不得把“支持发照片/视频”描述成“AI 已看懂现场”。现场媒体理解、确认记录来源绑定与日报追溯由工作助手 `/api/knowledge/query-media` 和 `aihr_assistant_capture` 链路承担,不能反向冒充成果投稿已具备视觉理解。 ```text -进入工作上报 +进入成果投稿 → 默认语音模式,可切换键盘或添加图片/视频 → ASR 转写进入当前页面草稿 → AI 从自然表达推断 CASE / VIDEO / SOP / KNOWLEDGE → 信息不足时只追问必要字段 → 返回可编辑确认卡 - → 用户点击“确认上报” + → 用户点击“确认投稿” → POST /api/aihr/work-report/reports - → 返回 PENDING,并在会话与“我的上报”中展示审核状态 + → 返回 PENDING,并在会话与“我的投稿”中展示审核状态 ``` 最小接口策略:附件继续复用 `POST /api/aihr/work-report/attachment`,正式提交继续复用 `POST /api/aihr/work-report/reports`,历史继续复用 `GET /api/aihr/work-report/reports/mine`。只增加一个无状态整理接口,不新增草稿表或第二套会话表: @@ -316,6 +323,8 @@ POST /api/aihr/work-report/organize - 未实现服务端脱敏时,页面不得出现“提交后将自动隐藏具体房号”等承诺性文案。 - 页面只有一个继续补充入口;确认提交后返回 `PENDING` 状态卡并可从“我的上报”查到同一记录。 +术语边界:本节的 CASE / VIDEO / SOP / KNOWLEDGE 属于成果投稿审核链;工作助手中的晨会、巡检、业主服务、画像、线索和待跟进事项属于 `aihr_assistant_capture`,两者不得自动互写。工作助手只能提出投稿建议,员工再次确认后才能创建 `aihr_work_report`。 + ### 5.4 AI 适配层(P0) ``` diff --git a/docs/银城员工端APP分阶段实施总纲.md b/docs/银城员工端APP分阶段实施总纲.md index df9d2bdb..a816f490 100644 --- a/docs/银城员工端APP分阶段实施总纲.md +++ b/docs/银城员工端APP分阶段实施总纲.md @@ -1,6 +1,6 @@ # 银城员工端 APP 分阶段实施总纲 -> 版本:v0.5 | 日期:2026-07-19 +> 版本:v0.6 | 日期:2026-07-21 > 输入源:《基于AI的银城员工端APP项目立项与开发规划会议(2026-07-07)》纪要 +《BRD v1.1》+《AI陪练二期开发推进计划 v0.8》+《数字师傅学练问报整合方案 v1.1》(docs/20260708/)。 > 定位:立项会把交付物从"AI 人力资源系统"升级为**银城员工端 APP**(员工服务载体)。本总纲定义三阶段顺序与边界;各阶段的执行细节仍由对应专项计划承载(阶段一 = 二期推进计划)。 > 分阶段口径(已定):**第一步陪练相关全部工作 → 第二步银城大喇叭 + 知识学习平台 → 第三步北森人事考勤对接**。 @@ -8,6 +8,7 @@ > v0.3 修订(2026-07-08,客户产品讨论):①阶段二"知识学习平台"扩充**学模块**——标准学习计划/课程/防快进视频/最短阅读时长/不定期考试,见 §4.5;②"新问题挂榜共创"产品形态定案为**开放问题榜**,定向师带徒经会议否决不做,见 §4.4;③新增知识分级访问权限工程需求;④三项待确认已获客户回复(2026-07-08):多端跳转暂缓(只保证能打开小程序页面,D6 不动)、排行榜保留为可选开关、员工评管理层按组织架构权限控制,见 §8 与《数字师傅学练问报整合方案 v1.2》§3.3。 > v0.4 修订(2026-07-11,客户微信补充):阶段二新增**员工个人 AI 助理 / 个人知识空间**——员工收藏个人文件、表格、PPT、文字和网页链接,按时间/主题检索并生成带来源的总结与分析;PPT 生成为增强项;北森、考勤等外部个人数据仍归阶段三。 > v0.5 修订(2026-07-19,客户讨论确认):个人助理新增“候选记忆 → 用户确认 → 分域持久化 → 可追溯召回”闭环。一级交互只保留“工作助手 / 查全网”;个人资料、项目业务记录和企业知识由工作助手内部自动路由并展示来源,不要求普通员工理解数据域。 +> v0.6 收口(2026-07-21):确认式采集已部署;后续把项目名称选择、日常工作按自然日汇总和主管视图纳入工作助手迭代。现有四类“工作上报”审核链产品语义收敛为“成果投稿”,不得与日常记录或今日工作成果混用。 ## 1. 立项会带来的定位变化 @@ -111,7 +112,8 @@ | MVP | 收藏个人文字、PDF/Word/Excel/PPT、图片和网页链接;自动提取正文、标题、来源、收藏时间和主题标签 | 阶段二首批 | | MVP | 按日期范围、主题、来源查询“我哪些天收藏了什么”;基于选定资料问答、总结、提炼工作想法,答案必须带来源 | 阶段二首批 | | MVP | 将冲突处理、工作灵感、保洁管理等问题交给个人助理分析;允许同时引用个人资料与有权限的企业知识 | 阶段二首批 | -| MVP | 显式“记一下/帮我记/保存一下”生成可编辑确认卡;用户选择仅自己保存或提交公司处理,后者在正式接收端接入前只显示待流转 | 已完成本地实现,待发布验证 | +| MVP | 显式“记一下/帮我记/保存一下”生成可编辑确认卡;用户选择仅自己保存或提交公司处理,后者在正式接收端接入前只显示待流转 | 已实现并部署;生产基础存在性已复核,正式账号/真机/业务验收仍待完成 | +| 2026-07-21 本地增量 | 多项目员工按名称选择当前项目;日常工作按员工 + 项目 + 自然日汇总为今日工作成果;现有四类工作上报收敛为成果投稿 | 已完成本地实现、真实 HTTP、H5 构建和 390×844 交互验证;尚未部署和生产验证,详见《工作助手与今日工作成果迭代计划》 | | 增强 | 多份资料对比、周报/月报、结构化汇报提纲、个人知识标签和长期记忆 | 阶段二后半 | | 增强 | 将总结导出为 PPT/汇报材料;先生成可编辑大纲,再生成文件,不把“一键 PPT”作为首批阻塞项 | 阶段二后半/P1 | | 外部数据 | 北森、考勤、请假、组织任职等个人数据接入后再做跨系统分析 | 阶段三 | @@ -122,7 +124,7 @@ - 网页链接需保留原 URL、抓取时间和正文快照;抓取必须防 SSRF、尊重访问权限与版权,登录态/付费内容不绕过授权。 - 回答需区分“个人资料”“企业 SOP”“外部网页”三类引用;未命中时明确提示,不编造来源。 - 公网查询不得由内部 AUTO 路由自动触发;必须由用户明确进入“查全网”,且不得自动携带住户或项目内部信息。 -- 开工前另出专项 TechSpec,至少覆盖个人资料元数据、内容解析、个人检索空间、来源引用、删除/导出和数据保留策略。 +- 个人资料元数据、内容解析、个人检索空间、来源引用、删除/导出和数据保留策略以[《个人AI助理阶段二专项 TechSpec》](个人AI助理阶段二专项TechSpec.md)为准;工作助手日常采集与今日成果的施工顺序以[专项迭代计划](工作助手与今日工作成果迭代计划-20260721.md)为准。 ## 5. 阶段二不做什么(边界) diff --git a/mobile-uni/src/components/chat/ChatComposer.vue b/mobile-uni/src/components/chat/ChatComposer.vue index 6af69dbf..dcdea504 100644 --- a/mobile-uni/src/components/chat/ChatComposer.vue +++ b/mobile-uni/src/components/chat/ChatComposer.vue @@ -248,7 +248,14 @@ const setDraft = (text: string) => { draft.value = text; }; -defineExpose({ teardown, setDraft }); +const hasDraft = () => Boolean(draft.value.trim() || props.mediaName); + +const clearDraft = () => { + draft.value = ''; + panelOpen.value = false; +}; + +defineExpose({ teardown, setDraft, hasDraft, clearDraft }); diff --git a/mobile-uni/src/pages/user/profile/index.vue b/mobile-uni/src/pages/user/profile/index.vue index ef9e8893..c03ccc56 100644 --- a/mobile-uni/src/pages/user/profile/index.vue +++ b/mobile-uni/src/pages/user/profile/index.vue @@ -111,6 +111,16 @@ › + - 工作上报 - 数字师傅帮你整理 + 成果投稿 + 把好经验沉淀给公司复用 - + - 我的上报 - 这里展示你提交给企业审核的工作记录 + 我的投稿 + 这里展示你提交给企业审核的案例、SOP、视频和知识 @@ -37,7 +37,7 @@ - 上报类型:{{ item.typeLabel }} + 投稿类型:{{ item.typeLabel }} {{ item.statusLabel }} {{ item.title }} @@ -47,7 +47,7 @@ 审核意见:{{ item.reviewNote }} - 还没有上报记录 + 还没有投稿记录 @@ -63,7 +63,7 @@ - 今天处理了什么?按住说,也可以发照片或视频。 + 哪段处置经验值得公司复用?按住说,也可以发照片或视频,我会整理成案例、SOP 或知识投稿。 @@ -111,7 +111,7 @@ - {{ uploading ? '附件上传中…' : '正在整理成工作上报…' }} + {{ uploading ? '附件上传中…' : '正在整理成成果投稿…' }} @@ -119,7 +119,7 @@ 已整理,可继续编辑 - 工作上报确认 + 成果投稿确认 @@ -186,7 +186,7 @@ {{ editingDraft ? '完成修改' : '修改' }} @@ -199,7 +199,7 @@ - + {{ submittedReport.status }} · {{ submittedReport.statusLabel }} @@ -662,12 +662,12 @@ const confirmSubmit = async () => { const content = draft.content.trim(); if (!title || !content) { editingDraft.value = true; - uni.showToast({ title: '请补全标题和上报内容', icon: 'none' }); + uni.showToast({ title: '请补全标题和投稿内容', icon: 'none' }); return; } if (title.length > 100 || content.length > 2000) { editingDraft.value = true; - uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '上报内容不能超过2000字', icon: 'none' }); + uni.showToast({ title: title.length > 100 ? '标题不能超过100字' : '投稿内容不能超过2000字', icon: 'none' }); return; } if (privacyReviewRequired.value) { @@ -712,8 +712,8 @@ const confirmSubmit = async () => { } const message = error instanceof Error ? error.message : '提交失败'; pushSystem(conflict - ? message + '。请先到“我的上报”核对已有记录,再完成修改并重新确认。' - : rejected ? message : message + ',提交结果未确认,请点击确认上报重试'); + ? message + '。请先到“我的投稿”核对已有记录,再完成修改并重新确认。' + : rejected ? message : message + ',提交结果未确认,请点击确认投稿重试'); if (conflict) await loadMine(true); } finally { if (requestIsCurrent(generation, submitGeneration, accountKey)) submitting.value = false; @@ -735,7 +735,7 @@ const loadMine = async (force = false) => { } } catch (error) { if (!requestIsCurrent(generation, mineGeneration, accountKey)) return; - historyError.value = error instanceof Error ? error.message : '上报记录加载失败,请重试'; + historyError.value = error instanceof Error ? error.message : '投稿记录加载失败,请重试'; } finally { if (requestIsCurrent(generation, mineGeneration, accountKey)) loadingMine.value = false; } @@ -817,6 +817,11 @@ onShow(() => { loggedIn.value = isLoggedIn(); if (loggedIn.value) { void loadMine(); + const prefill = String(uni.getStorageSync('aihr_work_report_prefill') || '').trim(); + if (prefill) { + uni.removeStorageSync('aihr_work_report_prefill'); + void organizeInput(prefill, { voice: false }); + } } else { clearConversationState(); } diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index 87c1e70d..8bc1bfd7 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -2,12 +2,36 @@ + + + 未绑定项目,可继续查制度;项目工作请联系管理员 + + + + @@ -163,6 +187,7 @@ @@ -205,6 +230,38 @@ @tool="runDataTool(defaultToolCode)" /> + + + + + 选择当前项目 + 后续工作记录和对话只归入这个项目 + + + + + + + 没有匹配的项目 + + + + @@ -285,7 +342,7 @@ + + diff --git a/mobile-uni/src/services/auth.ts b/mobile-uni/src/services/auth.ts index c4d0c8e1..7c98a44d 100644 --- a/mobile-uni/src/services/auth.ts +++ b/mobile-uni/src/services/auth.ts @@ -1,8 +1,9 @@ -import type { LoginResult } from '@/types/api'; +import type { LoginResult, MobileProject } from '@/types/api'; export const tokenKey = 'aihr_mobile_access_token'; export const clientIdKey = 'aihr_mobile_client_id'; export const phoneKey = 'aihr_mobile_phone'; +export const tenantIdKey = 'aihr_mobile_tenant_id'; export const loginRedirectKey = 'aihr_mobile_login_redirect'; export const positionKey = 'aihr_mobile_position'; export const positionSourceKey = 'aihr_mobile_position_source'; @@ -25,6 +26,7 @@ export interface AuthState { token: string; clientId: string; phone: string; + tenantId: string; position: UserPosition | ''; } @@ -112,6 +114,7 @@ export const getAuth = (): AuthState => ({ token: uni.getStorageSync(tokenKey) || '', clientId: uni.getStorageSync(clientIdKey) || '', phone: uni.getStorageSync(phoneKey) || '', + tenantId: uni.getStorageSync(tenantIdKey) || '000000', position: normalizePosition(uni.getStorageSync(positionKey)) }); @@ -134,7 +137,45 @@ export const leaveSupervisorLearnerMode = () => { uni.removeStorageSync(supervisorLearnerModeKey); }; -export const saveAuth = (result: LoginResult, phone: string) => { +const projectStorageKey = (tenantId: string, phone: string) => + `aihr_mobile_current_project:${tenantId || '000000'}:${phone || 'anonymous'}`; + +export const getCurrentProject = (): MobileProject | null => { + const auth = getAuth(); + const stored = uni.getStorageSync(projectStorageKey(auth.tenantId, auth.phone)); + if (!stored || typeof stored !== 'object') return null; + const project = stored as Partial; + return project.projectCode && project.projectName + ? { projectCode: String(project.projectCode), projectName: String(project.projectName) } + : null; +}; + +export const saveCurrentProject = (project: MobileProject) => { + const auth = getAuth(); + uni.setStorageSync(projectStorageKey(auth.tenantId, auth.phone), project); +}; + +export const clearCurrentProject = () => { + const auth = getAuth(); + uni.removeStorageSync(projectStorageKey(auth.tenantId, auth.phone)); +}; + +export const reconcileCurrentProject = (projects: MobileProject[]) => { + const current = getCurrentProject(); + const matched = current && projects.find((project) => project.projectCode === current.projectCode); + if (matched) { + saveCurrentProject(matched); + return matched; + } + clearCurrentProject(); + if (projects.length === 1) { + saveCurrentProject(projects[0]); + return projects[0]; + } + return null; +}; + +export const saveAuth = (result: LoginResult, phone: string, tenantId = '000000') => { const previousPhone = uni.getStorageSync(phoneKey) || ''; if (previousPhone !== phone) { clearOrgPosition(); @@ -144,12 +185,15 @@ export const saveAuth = (result: LoginResult, phone: string) => { uni.setStorageSync(tokenKey, result.access_token); uni.setStorageSync(clientIdKey, result.client_id); uni.setStorageSync(phoneKey, phone); + uni.setStorageSync(tenantIdKey, tenantId); }; export const clearAuth = () => { + clearCurrentProject(); uni.removeStorageSync(tokenKey); uni.removeStorageSync(clientIdKey); uni.removeStorageSync(phoneKey); + uni.removeStorageSync(tenantIdKey); clearOrgPosition(); uni.removeStorageSync(roleKey); leaveSupervisorLearnerMode(); diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index a936c57d..83fbd001 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -81,7 +81,9 @@ export const mergeKnowledgeSnippets = (...groups: RawKnowledgeSnippet[][]): Know export const searchKnowledge = async ( queryText: string, position = getSelectedPosition(), - conversation?: KnowledgeConversationState + conversation?: KnowledgeConversationState, + projectCode?: string, + source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop' ) => { const response = await apiRequest({ url: '/api/knowledge/query', @@ -90,9 +92,11 @@ export const searchKnowledge = async ( queryText, category: 'sop', position, + source, limit: 5, conversationId: conversation?.conversationId, - contextVersion: conversation?.contextVersion + contextVersion: conversation?.contextVersion, + projectCode }, timeout: 30000 }); @@ -104,7 +108,8 @@ export const searchKnowledgeWithMedia = ( queryText: string, position = getSelectedPosition(), onUploadTask?: (task: ReturnType | null) => void, - conversation?: KnowledgeConversationState + conversation?: KnowledgeConversationState, + projectCode?: string ) => new Promise((resolve, reject) => { const task = uni.uploadFile({ url: apiUrl('/api/knowledge/query-media'), @@ -117,7 +122,8 @@ export const searchKnowledgeWithMedia = ( ...(conversation ? { conversationId: conversation.conversationId, contextVersion: String(conversation.contextVersion) - } : {}) + } : {}), + ...(projectCode ? { projectCode } : {}) }, timeout: 360000, success: (response) => { @@ -137,7 +143,8 @@ export const searchKnowledgeWithMedia = ( export const queryKnowledgeTool = async ( toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY', queryText: string, - conversation?: KnowledgeConversationState + conversation?: KnowledgeConversationState, + projectCode?: string ) => { const response = await apiRequest({ url: '/api/knowledge/query', @@ -148,7 +155,8 @@ export const queryKnowledgeTool = async ( limit: 5, toolCode, conversationId: conversation?.conversationId, - contextVersion: conversation?.contextVersion + contextVersion: conversation?.contextVersion, + projectCode }, timeout: 30000 }); diff --git a/mobile-uni/src/services/personal-assistant.ts b/mobile-uni/src/services/personal-assistant.ts index b155b482..24d9afdf 100644 --- a/mobile-uni/src/services/personal-assistant.ts +++ b/mobile-uni/src/services/personal-assistant.ts @@ -5,7 +5,8 @@ import type { MemoryConfirmResult, ServiceMemory } from '@/types/api'; -import { apiRequest } from './api'; +import type { CaptureStatusHistory } from '@/types/api'; +import { apiRequest, apiUrl } from './api'; const candidateBase = '/api/aihr/personal-assistant/memory-candidates'; @@ -52,6 +53,31 @@ export const getAssistantCaptures = async () => timeout: 15000 })); +export const updateAssistantCaptureStatus = ( + captureId: number, + businessStatus: 'RECORDED' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'VOID', + note = '' +) => apiRequest({ + url: `/api/aihr/personal-assistant/assistant-captures/${captureId}/status`, + method: 'POST', + data: { + businessStatus, + note, + idempotencyKey: `capture-status-${captureId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + }, + timeout: 15000 +}); + +export const getAssistantCaptureStatusHistory = (captureId: number) => + apiRequest({ + url: `/api/aihr/personal-assistant/assistant-captures/${captureId}/status-history`, + method: 'GET', + timeout: 15000 + }); + +export const assistantCaptureSourceUrl = (captureId: number) => + apiUrl(`/api/aihr/personal-assistant/assistant-captures/${captureId}/source`); + export const getServiceMemories = async () => { const rows = rowsOf(await apiRequest({ url: '/api/aihr/service-memories', diff --git a/mobile-uni/src/services/position.ts b/mobile-uni/src/services/position.ts index 36d9e575..3cb360e6 100644 --- a/mobile-uni/src/services/position.ts +++ b/mobile-uni/src/services/position.ts @@ -12,6 +12,13 @@ import { type UserPosition } from './auth'; +export const fetchMobileIdentity = () => apiRequest({ + url: '/api/aihr/mobile/me', + method: 'GET', + timeout: 15000, + clearAuthOnUnauthorized: false +}); + export const userTabPages = new Set([ '/pages/user/today/index', '/pages/user/practice/index', @@ -35,12 +42,7 @@ export const routeForMobileRole = (role: RoleKey, requested: string) => { export const hydrateMobileRole = async (): Promise => { try { - const identity = await apiRequest({ - url: '/api/aihr/mobile/me', - method: 'GET', - timeout: 15000, - clearAuthOnUnauthorized: false - }); + const identity = await fetchMobileIdentity(); const role = identity.role === 'supervisor' ? 'supervisor' : 'user'; uni.setStorageSync(roleKey, role); if (role !== 'supervisor') leaveSupervisorLearnerMode(); @@ -71,12 +73,7 @@ export type OrgPositionResult = export const fetchOrgPositionResult = async (): Promise => { try { - const identity = await apiRequest({ - url: '/api/aihr/mobile/me', - method: 'GET', - timeout: 15000, - clearAuthOnUnauthorized: false - }); + const identity = await fetchMobileIdentity(); const officialPosition = saveOrgPosition(identity.positionName); if (!officialPosition) { clearOrgPosition(); diff --git a/mobile-uni/src/services/source-preview.ts b/mobile-uni/src/services/source-preview.ts new file mode 100644 index 00000000..649ce1bb --- /dev/null +++ b/mobile-uni/src/services/source-preview.ts @@ -0,0 +1,49 @@ +import { apiUrl, authHeaders } from './api'; + +type PreviewMediaApi = (options: { + sources: Array<{ url: string; type: 'video' }>; + fail?: () => void; +}) => void; + +let sourceAudio: UniApp.InnerAudioContext | null = null; + +const fail = () => uni.showToast({ title: '原始来源读取失败', icon: 'none' }); + +export const openProtectedSource = (sourceUrl: string, sourceType: string) => { + uni.downloadFile({ + url: apiUrl(sourceUrl), + header: authHeaders(false), + success: (response) => { + if (response.statusCode !== 200 || !response.tempFilePath) { + fail(); + return; + } + if (sourceType === 'IMAGE') { + uni.previewImage({ urls: [response.tempFilePath] }); + return; + } + if (sourceType === 'VOICE') { + sourceAudio?.destroy(); + sourceAudio = uni.createInnerAudioContext(); + sourceAudio.src = response.tempFilePath; + sourceAudio.onError(fail); + sourceAudio.play(); + return; + } + if (sourceType === 'VIDEO') { + const previewMedia = (uni as typeof uni & { previewMedia?: PreviewMediaApi }).previewMedia; + if (!previewMedia) { + uni.showToast({ title: '当前端暂不支持视频预览', icon: 'none' }); + return; + } + previewMedia({ + sources: [{ url: response.tempFilePath, type: 'video' }], + fail + }); + return; + } + uni.showToast({ title: '当前来源类型暂不支持预览', icon: 'none' }); + }, + fail + }); +}; diff --git a/mobile-uni/src/services/work-results.ts b/mobile-uni/src/services/work-results.ts new file mode 100644 index 00000000..ca8e2ed6 --- /dev/null +++ b/mobile-uni/src/services/work-results.ts @@ -0,0 +1,22 @@ +import type { ProjectWorkResult, WorkResult } from '@/types/api'; +import { apiRequest } from './api'; + +const params = (projectCode: string, workDate?: string) => { + const query = new URLSearchParams({ projectCode }); + if (workDate) query.set('workDate', workDate); + return query.toString(); +}; + +export const generateMyWorkResult = (projectCode: string, workDate?: string) => + apiRequest({ + url: `/api/aihr/work-results/mine/generate?${params(projectCode, workDate)}`, + method: 'POST', + timeout: 20000 + }); + +export const getProjectWorkResult = (projectCode: string, workDate?: string) => + apiRequest({ + url: `/api/aihr/work-results/project?${params(projectCode, workDate)}`, + method: 'GET', + timeout: 20000 + }); diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 305d5fcc..005f5f8f 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -11,11 +11,17 @@ export interface LoginResult { export type RoleKey = 'user' | 'supervisor' | 'candidate'; +export interface MobileProject { + projectCode: string; + projectName: string; +} + export interface MobileIdentity { role: RoleKey; roleLabel: string; positionName: string; positionLevel: string; + projects: MobileProject[]; } export interface TtsResponse { @@ -207,6 +213,18 @@ export interface MemoryCandidateDraft { summary: string; detail: string; status: string; + workDate?: string | null; + businessStatus?: string | null; +} + +export interface WorkSourceSnapshot { + sourceType: 'TEXT' | 'VOICE' | 'IMAGE' | 'VIDEO' | string; + transcript?: string | null; + ossId?: number | null; + fileName?: string | null; + mimeType?: string | null; + fileSize?: number | null; + recognizedSummary?: string | null; } export interface MemoryCandidate { @@ -218,6 +236,7 @@ export interface MemoryCandidate { draft: MemoryCandidateDraft; missingFields: string[]; expiresAt: string | null; + source?: WorkSourceSnapshot | null; } export interface MemoryConfirmRequest { @@ -247,8 +266,56 @@ export interface AssistantCapture { detail?: string | null; occurredAt: string; followUpAt?: string | null; + workDate?: string | null; + businessStatus: 'RECORDED' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'VOID' | string; deliveryStatus: 'NOT_REQUIRED' | 'PENDING' | 'DELIVERED' | 'FAILED' | string; createdAt: string; + source?: WorkSourceSnapshot | null; +} + +export interface CaptureStatusHistory { + id: number; + fromStatus: string; + toStatus: string; + note?: string | null; + operatorUserId: number; + createdAt: string; +} + +export interface WorkResultItem { + captureId: number; + suggestedType: string; + category: string; + summary: string; + businessStatus: string; + deliveryStatus: string; + occurredAt: string; + sourceType: string; + sourceUrl: string; +} + +export interface WorkResult { + ownerUserId: number; + ownerName: string; + projectCode: string; + projectName: string; + workDate: string; + version: number; + generatedAt: string; + sections: Record; + total: number; + pending: number; +} + +export interface ProjectWorkResult { + projectCode: string; + projectName: string; + workDate: string; + contributorCount: number; + resultCount: number; + pendingCount: number; + highPriorityCount: number; + members: WorkResult[]; } export interface ServiceMemory { diff --git a/mobile-uni/tests/ask-memory.test.mjs b/mobile-uni/tests/ask-memory.test.mjs index 9e83aa36..e5fa81c3 100644 --- a/mobile-uni/tests/ask-memory.test.mjs +++ b/mobile-uni/tests/ask-memory.test.mjs @@ -29,3 +29,29 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频 assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/); assert.match(page, /已结合本次对话理解/); }); + +test('工作助手按项目名称选择并为文字、媒体和工具请求携带项目上下文', async () => { + const [page, service, auth] = await Promise.all([ + read('../src/pages/user/sop/index.vue'), + read('../src/services/knowledge.ts'), + read('../src/services/auth.ts') + ]); + + assert.match(page, />当前项目 { + const page = await read('../src/pages/user/sop/index.vue'); + + assert.match(page, /inset:\s*0 0 var\(--window-bottom, 0px\)/); + assert.match(page, /let scrollSeq = 0/); + assert.match(page, /threadScrollTop\.value = 100000 \+ \(\+\+scrollSeq \* 200\)/); +}); diff --git a/mobile-uni/tests/request-scope.test.mjs b/mobile-uni/tests/request-scope.test.mjs index c097b13e..477cab98 100644 --- a/mobile-uni/tests/request-scope.test.mjs +++ b/mobile-uni/tests/request-scope.test.mjs @@ -25,6 +25,8 @@ after(async () => { }); const { createPageRequestScope } = await vite.ssrLoadModule('/src/services/request-scope.ts'); +const { getCurrentProject, reconcileCurrentProject, saveAuth, saveCurrentProject } = + await vite.ssrLoadModule('/src/services/auth.ts'); const loginAs = (phone, token) => { storage.set('aihr_mobile_phone', phone); @@ -89,3 +91,21 @@ test('五个账号敏感页在显示时捕获账号并在隐藏和卸载时失 assert.match(source, /requests\.isCurrent\(/); } }); + +test('当前项目按租户和登录账号隔离并在权限变化后重新校验', () => { + saveAuth({ access_token: 'token-a', client_id: 'mobile-client' }, '13800138000', '000000'); + saveCurrentProject({ projectCode: 'P1', projectName: '星河湾一期' }); + assert.equal(getCurrentProject()?.projectCode, 'P1'); + + saveAuth({ access_token: 'token-b', client_id: 'mobile-client' }, '13900139000', '000000'); + assert.equal(getCurrentProject(), null); + assert.equal(reconcileCurrentProject([ + { projectCode: 'P2', projectName: '星河湾二期' } + ])?.projectCode, 'P2'); + + assert.equal(reconcileCurrentProject([ + { projectCode: 'P3', projectName: '星河湾三期' }, + { projectCode: 'P4', projectName: '星河湾四期' } + ]), null); + assert.equal(getCurrentProject(), null); +}); diff --git a/mobile-uni/tests/work-results.test.mjs b/mobile-uni/tests/work-results.test.mjs new file mode 100644 index 00000000..32ddb92f --- /dev/null +++ b/mobile-uni/tests/work-results.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + +test('员工与主管成果页只允许推进待跟进或处理中记录', async () => { + const [employeePage, supervisorPage] = await Promise.all([ + read('../src/pages/user/work-results/index.vue'), + read('../src/pages/supervisor/work-results/index.vue') + ]); + + const actionableStatuses = /\['PENDING',\s*'IN_PROGRESS'\]\.includes\(item\.businessStatus\)/; + assert.match(employeePage, actionableStatuses); + assert.match(supervisorPage, /\['PENDING','IN_PROGRESS'\]\.includes\(item\.businessStatus\)/); + assert.doesNotMatch(employeePage, /!\['COMPLETED',\s*'VOID'\]\.includes\(item\.businessStatus\)/); + assert.doesNotMatch(supervisorPage, /!\['COMPLETED','VOID'\]\.includes\(item\.businessStatus\)/); +}); + +test('员工成果页与主管成果页复用真实项目成果接口', async () => { + const [employeePage, supervisorPage, service] = await Promise.all([ + read('../src/pages/user/work-results/index.vue'), + read('../src/pages/supervisor/work-results/index.vue'), + read('../src/services/work-results.ts') + ]); + + assert.match(employeePage, /generateMyWorkResult/); + assert.match(supervisorPage, /getProjectWorkResult/); + assert.match(service, /\/api\/aihr\/work-results\/mine\/generate/); + assert.match(service, /\/api\/aihr\/work-results\/project/); +}); + +test('员工与主管成果页都能追溯非文字原始来源', async () => { + const [employeePage, supervisorPage, preview] = await Promise.all([ + read('../src/pages/user/work-results/index.vue'), + read('../src/pages/supervisor/work-results/index.vue'), + read('../src/services/source-preview.ts') + ]); + + assert.match(employeePage, /openProtectedSource/); + assert.match(supervisorPage, /原始来源/); + assert.match(supervisorPage, /openProtectedSource/); + assert.match(preview, /createInnerAudioContext/); + assert.match(preview, /previewMedia/); + assert.doesNotMatch(employeePage, /openDocument/); +}); diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index 0bc8dc2f..4df6ff07 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -24,8 +24,10 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql" docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \ -e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 190ab217..b38d4ad3 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -141,14 +141,22 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_m mysql "$DB" <<'SQL' CREATE TABLE aihr_org_snapshot ( id bigint NOT NULL AUTO_INCREMENT, - ext_party_id varchar(100), - PRIMARY KEY (id) + tenant_id varchar(20) NOT NULL, + project_code varchar(50) NOT NULL, + ext_party_id varchar(100) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_org_party (tenant_id, ext_party_id) ) ENGINE=InnoDB; SQL mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql" org_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND column_name='hire_date';")" test "$org_columns" = 'hire_date' +org_multi_project_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260721_org_multi_project_membership_mysql8.sql" +mysql "$DB" < "$org_multi_project_migration" +mysql "$DB" < "$org_multi_project_migration" +org_party_unique="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND index_name='uk_aihr_org_party';")" +test "$org_party_unique" = 'tenant_id,project_code,ext_party_id' mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql" @@ -196,6 +204,17 @@ test "$assistant_capture_columns" = '5' service_memory_version_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_memory_candidate' AND column_name IN ('version','confirm_idempotency_key','confirm_request_hash','source_conversation_id','source_request_id')) OR (table_name = 'aihr_service_memory' AND column_name IN ('project_code','version')) OR (table_name = 'aihr_service_memory_version' AND column_name IN ('memory_id','version')));")" test "$service_memory_version_columns" = '9' +work_assistant_results_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql" +test -f "$work_assistant_results_migration" +mysql "$DB" < "$work_assistant_results_migration" +mysql "$DB" < "$work_assistant_results_migration" +work_assistant_result_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_assistant_capture_status_log','aihr_daily_work_result','aihr_knowledge_conversation');")" +test "$work_assistant_result_tables" = 'aihr_assistant_capture_status_log,aihr_daily_work_result,aihr_knowledge_conversation' +work_assistant_result_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_knowledge_conversation' AND column_name = 'project_code') OR (table_name = 'aihr_memory_candidate' AND column_name IN ('work_date','source_snapshot_json')) OR (table_name = 'aihr_assistant_capture' AND column_name IN ('work_date','business_status','source_snapshot_json')));")" +test "$work_assistant_result_columns" = '6' +work_assistant_snapshot_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_daily_work_result' AND column_name='content_hash';")" +test "$work_assistant_snapshot_columns" = '1' + work_report_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql" test -f "$work_report_migration" mysql "$DB" < "$work_report_migration"