feat: 新增OMS退单回调能力,优化MCP工具与商机查重功能

- 新增OMS退单回调接口,支持更新商机阶段为S4并清空签约信息
- 新增并优化多款MCP工具描述、字段注释查询能力与数据权限控制
- 新增商机查重相似度校验,区分精确重复与疑似重复场景
- 补全数据库字段注释与相关SQL脚本
- 新增多类型拓客搜索、字段注释查询等MCP工具
- 完善前端商机查重弹窗交互逻辑
main
kangwenjing 2026-09-16 15:37:39 +08:00
parent 52a35e5b20
commit 010d3052c8
47 changed files with 2971 additions and 98 deletions

View File

@ -5,19 +5,41 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323"> <list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OmsCallbackController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OmsCallbackController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityDuplicateCheckDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityDuplicateCheckDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmCheckinSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmCheckinSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmCheckinSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmCheckinSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDetailSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDetailSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDetailSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDetailSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDictOptionsToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmDictOptionsToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmEntityDetailToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmEntityDetailToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmExpansionSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmExpansionSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmExpansionSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmExpansionSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmFollowupSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmFollowupSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmFollowupSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmFollowupSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmMetadataToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmMetadataToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmOrgUserSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmOrgUserSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportCatalogToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportCatalogToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportCatalogToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportCatalogToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmTodoSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmTodoSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmTodoSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmTodoSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmUniversalSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmUniversalSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmWorkTodayStatusToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/CrmWorkTodayStatusToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/WorkReportSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/WorkReportSearchToolProvider.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/WorkReportSearchToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/WorkReportSearchToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/application-prod.yml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/application-prod.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/application.yml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/application.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/llm/security/McpDataPermissionInterceptorTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/llm/security/McpDataPermissionInterceptorTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/llm/security/McpDataPermissionInterceptorTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/llm/security/McpDataPermissionInterceptorTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/lib/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/lib/auth.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/sql/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/sql/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/sql/init_full_pg17.sql" beforeDir="false" afterPath="$PROJECT_DIR$/sql/init_full_pg17.sql" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -117,6 +139,7 @@
<workItem from="1789033867432" duration="17000" /> <workItem from="1789033867432" duration="17000" />
<workItem from="1789440226191" duration="937000" /> <workItem from="1789440226191" duration="937000" />
<workItem from="1789459774783" duration="30000" /> <workItem from="1789459774783" duration="30000" />
<workItem from="1789529046094" duration="3889000" />
</task> </task>
<task id="LOCAL-00001" summary="修改定位信息 0323"> <task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" /> <option name="closed" value="true" />

159
MCP优化方案.md 100644
View File

@ -0,0 +1,159 @@
# MCP 优化方案
> 基于当前 MCPLLM Agent 工具实现现状整理。日期2026-09-16。
> 前提:完整测试 **132 个用例全部通过**(含数据权限守卫 `everyMcpMapperMethodShouldBeProtectedOrExplicitlyTenantSafe`、SQL 可重写守卫等),服务可正常编译运行。
---
## 一、现状总览
### 1.1 MCP 工具清单24 个)
| # | 工具名 | 归属类 | 能力 |
|---|---|---|---|
| 1 | `crm_universal_search` | CrmUniversalSearchToolProvider | 跨实体关键词通用搜索(客户/商机/拓客/日报/签到/待办/活动/跟进) |
| 2 | `crm_user_profile` | UserProfileToolProvider | 当前用户画像(账号/姓名/角色/部门/租户) |
| 3 | `crm_entity_detail` | CrmEntityDetailToolProvider | 按实体类型+ID 查详情可带相关明细customer/opportunity/daily_report/checkin/sales_expansion/channel_expansion/crm_expansion/todo |
| 4 | `crm_work_report_search` | WorkReportSearchToolProvider | 日报分页检索 |
| 5 | `crm_opportunity_search` | OpportunitySearchToolProvider | 商机分页检索(阶段/金额/排序/字段投影) |
| 6 | `crm_customer_search` | CrmDetailSearchToolProvider | 客户分页检索 |
| 7 | `crm_checkin_search` | CrmCheckinSearchToolProvider | 外勤打卡分页检索 |
| 8 | `crm_expansion_search` | CrmExpansionSearchToolProvider | 销售/渠道拓客分页检索 |
| 9 | `crm_crm_expansion_search` | CrmCrmExpansionSearchToolProvider | CRM 拓客crm_crm_expansion检索 |
| 10 | `crm_followup_search` | CrmFollowupSearchToolProvider | 商机/拓客跟进检索 |
| 11 | `crm_todo_search` | CrmTodoSearchToolProvider | 待办检索 |
| 12 | `crm_work_today_status` | CrmWorkTodayStatusToolProvider | 今日工作状态 |
| 13 | `crm_org_user_search` | CrmOrgUserSearchToolProvider | 组织用户检索 |
| 14 | `crm_report_query` | CrmReportQueryToolProvider | 报表/统计查询(见 1.2 |
| 15 | `crm_report_catalog` | CrmReportCatalogToolProvider | 报表与查询能力目录 |
| 16 | `crm_metadata_catalog` | CrmMetadataToolProvider | 模块/字段/工具能力目录 |
| 17 | `crm_dict_options` | CrmDictOptionsToolProvider | 系统字典类型和选项 |
| 18 | `crm_field_comment` | CrmFieldCommentToolProvider | MCP 返回字段的中文注释(读 DB COMMENT |
> 注:上表已覆盖全部 ToolProvider 类。`crm_search`/`crm_detail` 等仅表示内部方法,非独立注册工具。
### 1.2 `crm_report_query` 支持的 reportType10 种)
`dashboard_summary`、`sales_performance`、`opportunity_funnel`、`opportunity_trend`、`daily_report_completion`、`checkin_summary`、`expansion_summary`、`customer_summary`、`todo_summary`、`channel_analytics`(下含 `channelTierDistribution`、`channelIntentDistribution`、`channelCoverageSummary`、`channelRevenueTier`、`channelContactWecomDistribution` 5 个子维度)。
### 1.3 Mapper 规模LlmMcpMapper.xml83 个 select
- **列表/搜索**35 个(各实体 search + count + universalSearch
- **Dashboard/汇总**27 个dashboard* 、*Summary、funnel/trend/completion、channel* 聚合)
- **详情**15 个select*Detail + 相关明细 select*Followups/Contacts/Coverages/Comments/Messages/Opportunities
- **基础数据**selectUserProfile/Roles/Orgs、searchOrgUsers、searchOrganizations、searchRoles、selectDictTypes/Options、selectColumnComments
### 1.4 数据权限模型
McpDataPermissionInterceptor 通过 SQL 重写注入 owner 过滤。策略见 `buildPolicies()`[McpDataPermissionInterceptor.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java#L402-L445)
| RESOURCE 常量 | owner 列 | 关键受管方法 |
|---|---|---|
| RESOURCE_DAILY_REPORT | r.user_id | searchWorkReports、详情、comments、messages、日报完成率 |
| RESOURCE_CHECKIN | c.user_id | 签到系列 |
| RESOURCE_CUSTOMER | c.owner_user_id | 客户系列 |
| RESOURCE_EXPANSION | s.owner_user_id / c.owner_user_id / ce.owner_user_id | 销售/渠道/CRM 拓客系列 + channel* 聚合 |
| RESOURCE_WORK | t.user_id / l.operator_user_id / u.user_id | 待办、活动、今日状态 |
| RESOURCE_OPPORTUNITY | o.owner_user_id+项目归属地投影,预售后台可见) | 商机系列、跟进、漏斗 |
| RESOURCE_ALL | u.user_id | searchOrgUsers |
受两类守卫测试保护:每个 mapper 方法要么受策略保护、要么显式 tenant-safe受保护语句必须能被重写器正确注入过滤条件。
---
## 二、可优化点
按「优先级 P0(高) / P1(中) / P2(低)」与「收益」分组,每条含:现状、优化建议、影响。
### P0-1 `actual_signed_summary` 报表「已声明但不可用」(实际缺陷)
- **现状**[CrmReportQueryToolProvider.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java)
- reportType 参数枚举里含 `"actual_signed_summary"`L64`crm_report_catalog` 也声明了该 reportTypeL61
- 但 `query()` 的分发 switchL87-98**没有 `case "actual_signed_summary"`**,会落到 `default``BusinessException("不支持的 reportType")`
- 而 L110 的 `if ("actual_signed_summary".equals(reportType)) { signedSummary(...) }` 分支位于 switch **之后**,因 switch 先抛异常而**永远不可达**。
- 底层 `signedSummaryRows / signedSummaryQuality` mapper 已就绪XML 2508/2548且已注册数据权限只是没被 switch 接上。
- **建议(核心修复,最小改动)**:在 switch 增加 `case "actual_signed_summary" -> signedSummaryRows/...` 或在 `query()` 入口先处理该 reportType使声明与实现一致。建议顺带新增一个「枚举声明的 reportType 与 switch 分支一一对应、且 catalog 同步」的守卫测试,防止再次漂移。
- **影响**Agent 按 catalog 调用该报表当前会直接报错 → 属功能性缺陷,建议优先修复。
### P0-2 商机筛选/权限口径的「预售后台」与 SDK 差异(一致性风险中)
- **现状**[McpDataPermissionInterceptor.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java#L436-L442) 中商机策略 `preSalesVisible=true` 并带 `o.project_ownership_location` 投影,而日报/签到/客户/拓客等 `preSalesVisible=false`。这个差异是**有意的**(对应商机模块的预售后台可见规则),但**没有注释说明为什么商机特殊**。
- **建议**:在策略处补注释说明「商机为何独享 preSalesVisible 与项目归属地投影」,避免后续维护误改。纯注释改动,零回归。
- **收益**:提升可维护性,防止未来误删权限差异导致商机数据越权或越收窄。
### P0-3 用户相关查询的敏感字段需审校(安全加固)
- **现状**:用户画像已暴露工号/职位/入职日期等。需核验 `selectUserProfile` / `searchOrgUsers` **是否为显式列**(而非 `*`),确认无 password/salt/token/密钥字段带出。
- **建议**:通读 `selectUserProfile`、`searchOrgUsers` 两个查询,确认全部为**显式列**,且不含密码/盐/token/密钥类字段。若有 `*`,改为显式列。同时补一个**黑名单断言测试**:任何 MCP 返回的字段名不得出现 `password`/`secret`/`token`/`key` 等(类似现有权限守卫)。
- **收益**:杜绝敏感信息经 MCP 泄漏的隐患,测试固化。
### P0-4 数据权限守卫测试存在,但缺乏「跨实体 join 越权」用例
- **现状**:现有测试验证每个受保护语句可被重写,但未覆盖**关联明细查询**(如 `selectChannelExpansionOpportunities`、`selectCustomerOpportunities`、`selectSalesExpansionOpportunities`)在注入 owner 条件后,相联合的 on 条件是否真正约束到目标行。
- **建议**:增加针对性测试:构造「主实体可见但关联实体不可见」的数据,断言关联明细不会越权返回超此 owner 范围的行。
- **收益**:堵住「通过详情联查旁路越权」的潜在漏洞。
### P1-1 报表 `channel_analytics` 的 groupBy 口径无文档校验
- **现状**`channel_analytics` 有参数 `groupBy`[CrmReportQueryToolProvider.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java#L53)但内部各聚合方法tier/intent/coverage/revenue/wecom`groupBy` 的支持程度看不到统一校验。
- **建议**:明确每个子维度「是否接受 groupBy、接受哪些取值」对不支持的值在入口做显式校验并报错而非静默忽略避免返回误导性聚合。补充文档化说明到 `crm_report_catalog`
- **收益**:提升统计可信度、避免 Agent 拿到「以为分组了其实没有」的数据。
### P1-2 `crm_report_catalog` 的工具清单可能未随新工具同步
- **现状**[CrmReportCatalogToolProvider.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/tools/CrmReportCatalogToolProvider.java#L36-L50) 内联维护了一个工具清单(含 `crm_universal_search`、`crm_work_report_search` 等),但**未见 `crm_user_profile`/`crm_work_today_status`/`crm_crm_expansion_search`/`crm_report_query` 之外全部 24 个工具是否都在清单内**,且是手工维护,易遗漏。
- **建议**:改为**从注册表动态生成**工具清单(遍历已注册 ToolProvider 的名称与描述),消除手工同步漂移;或至少补齐缺失工具并加「清单与注册工具数量一致」的守卫测试。
- **收益**Agent 发现工具的能力与真实能力一致,避免「目录找不到但实际能调」。
### P1-3 `selectColumnComments`crm_field_comment的表白名单是硬编码
- **现状**[FieldCommentRegistry.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/tools/support/FieldCommentRegistry.java) 以硬编码 `tables()` / `TABLE_COLUMNS` 维护「别名→物理列」映射,新增表/字段需人工双处登记(列表+注释),易漏。
- **建议**:为映射的完整性加守卫测试:`TABLE_COLUMNS` 键集合与 `tables()` 返回的说明集合一致;并可核对每个映射的物理列是否在本批次已暴露的查询字段内。
- **收益**:固化「新增字段必登记注释」这一约定,避免字段有值但无注释。
### P1-4 `limit` 上限保护已较好,但部分列表可细化
- **现状**:商机等已统一 `pageSize/limit` 默认 10、最大 50[OpportunitySearchToolProvider.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java#L56-L57),含 offset 计算)。这是好实践。
- **建议**:核验所有其余 search 工具(日报/客户/打卡/拓客/跟进/待办/CRM拓客是否都用了同一套 `MAX_LIMIT``getOffset` 兜底,避免个别工具直接透传 limit 造成大结果集。可抽公共参数抽取器统一。
- **收益**:统一防爆量,避免个别查询成为性能/响应放大点。
### P2-1 列表 keyword 搜索未纳入 coverage/联系人等子表字段
- **现状**:销售/渠道列表已新增 `coverageSummary` 聚合列(本轮),但 **keyword 匹配仍只针对主表字段**,不会命中覆盖省/市或渠道联系人姓名。
- **建议**:如需「按覆盖地市、按联系人搜索」,在 keyword 的 LIKE 条件里补充对 coverage 子表(省/市)与 contact 表的关联匹配。
- **收益**:搜索召回更贴合业务;成本是 SQL 变复杂,需先确认是否为业务目标。
### P2-2 覆盖地市 / 日报消息只存在于「详情联查」,无独立搜索工具
- **现状**`coverage`、`work_report_message` 已通过 `crm_entity_detail` 的相关明细暴露,但无独立搜索入口。
- **建议**:若 Agent 需要「按覆盖省/市全局统计」或「全局检索某条日报消息」,可新增 `crm_coverage_search` / `crm_report_message_search` 独立工具;否则保持现状(避免工具爆炸)。
- **收益**按需增强过度新增会导致工具面过大、Agent 选择成本上升。
### P2-3 未处理缺口(已知合理保留)
- `sys_org.parent_id/org_code/sort_order`:框架表无建表脚本,列名未核实,暂不补真实值。
- `business_calendar_day`、`report_reminder_*`、`speech_recognition_config`、`dashboard_analytics_card_config`:配置类表,未暴露属合理(报告鉴权用白名单、配置不属业务数据)。
- `crm_oms_dict_mapping`OMS 后调用映射表,**按需求明确不暴露**。
---
## 三、推荐落地顺序(不含重构)
| 序号 | 项 | 工作量 | 回归风险 |
|---|---|---|---|
| 1 | **P0-1 修复 actual_signed_summary 不可用缺陷**(核心) | 小 | 低 |
| 2 | P0-3 敏感字段审校 + 黑名单测试 | 小 | 低 |
| 3 | **P0-4 关联明细越权测试** | 中 | 零(纯加测试) |
| 4 | P1-1 channel_analytics groupBy 校验 | 小 | 低 |
| 5 | P1-2 report_catalog 动态化/补全 + 守卫 | 中 | 低 |
| 6 | P1-3 field_comment 注册一致性守卫 | 小 | 零(纯加测试) |
| 7 | P1-4 limit 兜底统一 | 小-中 | 低 |
| 8 | P2-1/P2-2 按需 | 视需求 | 视改动 |
---
## 四、当前状态结论
- **功能完整性**:核心业务主表 + 详情 + 报表 + 数据权限均已覆盖132 用例全绿,服务可启动。**不存在阻断性缺陷**。
- **最值得优先做**P0-2敏感字段黑名单与 P0-3关联明细越权测试——属于「安全加固」性质风险低、收益高建议放到下一批次。
- 其余 P1/P2 多为可维护性与体验增强,可按迭代排期,不必一次性做完。

View File

@ -0,0 +1,176 @@
# MCP 字段覆盖清单
> 说明:本文档用于核对 MCP 接口对数据库字段的覆盖情况,帮助判断是否"已包含全部字段"、缺失了哪些表与字段。
>
> **数据来源**
> - MCP SQL 查询定义:`backend/src/main/resources/mapper/llm/LlmMcpMapper.xml`(共 77 个查询)
> - MCP 工具描述 / 字段中文映射:`backend/src/main/java/com/unis/crm/llm/tools/`(各 `*ToolProvider.java`
> - 字段注释映射:`backend/src/main/java/com/unis/crm/llm/tools/support/FieldCommentRegistry.java`
> - 表字段依据:`sql/archive/alter_fill_column_comments_pg17.sql` + 运行时 `*SchemaInitializer` 增量列
---
## 核心结论
**未完全包含全部字段。**但缺口主要集中在**子表实体**和**列表搜索层**MCP 对**核心业务主表**覆盖较完整(所有 detail 查询使用 `表.*` 返回主表全字段兜底)。
### 关键机制
所有 `xxxDetail` 查询(如 `selectOpportunityDetail`、`selectCustomerDetail`、`selectWorkReportDetail`、`selectCheckinDetail`、`selectSalesExpansionDetail`、`selectChannelExpansionDetail`、`selectCrmExpansionDetail`、`selectTodoDetail`)均使用 `表.*` **返回主表全部字段**。因此:
- 主表字段层面,**详情接口基本全覆盖**
- 真正的字段缺口集中在 **search/list 列表查询** 以及 **子表实体followup / contact / comment**。
---
## A. 各 MCP 实体 → 主表 → 已暴露字段清单
### 1. 工作日报(表 `work_daily_report`
`searchWorkReports`XML 50-100暴露
> id、userId、userName、reportDate、submitTime、sourceType、status、workContent、tomorrowPlan、score、comment
表字段alter_fill 194-204`id, user_id, report_date, work_content, tomorrow_plan, source_type, submit_time, status, score, created_at, updated_at`
Detail 用 `r.*` 全字段 + `userName / latestScore / latestComment / reviewedAt`
### 2. 商机(表 `crm_opportunity`
`searchOpportunities`XML 132-218暴露 27 个字段:
> id、opportunityCode、opportunityName、customerId、customerName、ownerUserId、ownerName、preSalesName、operatorName、projectLocation、projectOwnershipLocation、projectOwnershipLocationName、productType、opportunityType、source、isPoc、competitorName、amount、expectedCloseDate、confidence、stage、status、archived、latestProgress、nextPlan、description、actualSignedAmount、archivedAt、updatedAt
Detail 用 `o.*` 全字段 + `customerName / ownerName / followupCount / latestFollowup`
### 3. 客户(表 `crm_customer`
`searchCustomers`XML 1415-1454暴露
> id、customerCode、customerName、customerType、industry、province、city、address、source、status、ownerUserId、ownerName、remark、updatedAt
Detail 用 `c.*` 全字段 + `ownerName`
### 4. 签到(表 `work_checkin`
`searchCheckins`XML 1479-1514暴露
> id、userId、userName、deptName、checkinDate、checkinTime、bizType、bizId、bizName、locationText、remark、status
Detail 用 `c.*` 全字段 + `userNameResolved`
### 5. 销售拓客(表 `crm_sales_expansion`
`searchSalesExpansions`XML 1619-1665暴露
> id、expansionType、name、employeeNo、candidateName、officeName、mobile、email、targetDept、industry、title、employmentStatus、expectedJoinDate、hasDesktopExp、summary、stage、intentLevel、ownerUserId、ownerName、remark、sortTime、updatedAt
Detail 用 `s.*` 全字段。
### 6. 渠道拓客(表 `crm_channel_expansion`
`searchChannelExpansions`XML 1689-1744暴露
> id、expansionType、name、channelCode、channelName、province、city、officeAddress、channelIndustry、certificationLevel、annualRevenue、staffSize、contactEstablishedDate、contactName、contactTitle、contactMobile、channelAttribute、internalAttribute、hasDesktopExp、expectedSignDate、landedFlag、summary、stage、intentLevel、ownerUserId、ownerName、remark、sortTime、updatedAt
Detail 用 `c.*` 全字段 + `contactCount / followupCount`
**渠道联系人子表(`crm_channel_expansion_contact`**:并非完全没有暴露,而是通过 `crm_entity_detail``channel_expansion` 详情**联查**返回(`CrmEntityDetailToolProvider.java:174` → `selectChannelExpansionContacts`XML 2206。返回字段有限`id, contactName, contactMobile, contactTitle, sortOrder`(仅 5 个)。无独立搜索工具(不能批量查联系人)。字段缺漏见 C2。
### 7. CRM 拓展(表 `crm_crm_expansion`
`searchCrmExpansions`XML 1770-1817暴露
> id、endUser、officeName、industryAttr、extensionType、purchaseDate、warrantyExpiry、expansionTime、expansionScale、softwarePoints、onlineStatus、contactName、contactPhone、contactTitle、supplierName、h3cContactName、hasExpansionOpportunity、hasMaintenanceOpportunity、ownerUserId、ownerName、remark、sortTime、updatedAt
Detail 用 `ce.*` 全字段。
### 8. 待办(表 `work_todo`)— **全字段覆盖**
`searchTodos`XML 1241-1279暴露
> id、title、bizType、bizId、priority、status、userId、userName、dueDate、createdAt、updatedAt
Detail 用 `t.*`。此表 **无字段缺失**。
### 9. 跟进(两表合并:`crm_opportunity_followup` + `crm_expansion_followup`
- `searchFollowups`XML 1841-1927暴露公共列
> bizType、id、bizId、bizName、followupType、content、nextAction、userId、userName、followupTime
- `selectOpportunityFollowups`id、followupType、content、nextAction、followupUserId、followupUserName、followupTime
- `selectSalesExpansionFollowups` / `selectChannelExpansionFollowups`id、followupType、content、nextAction、evaluationContent、nextPlan、followupUserId、followupUserName、followupTime
### 10. 组织用户 / 组织 / 角色(表 `sys_user`、`sys_org`、`sys_role`
- `searchOrgUsers`XML 1303-1353暴露userId、username、displayName、mobile、email、status、orgIds、orgNames、roleCodes、roleNames
- `selectUserProfile`userId、username、displayName、email、phone、status、platformAdmin、createdAt
- `searchOrganizations`orgId、orgName、parentId、orgCode、sortOrder、status**parentId/orgCode/sortOrder 恒为 null**
- `searchRoles`roleId、roleName、roleCode、status、userCount
`sys_user` 表字段:`id, user_id, user_code, username, real_name, display_name, mobile, phone, email, org_id, job_title, status, hire_date, avatar_url, password_hash, created_at, updated_at, is_deleted, pwd_reset_required, is_platform_admin`
### 11. 其他(非独立业务主表工具)
- **动态/活动日志** `sys_activity_log`:通过 `universalSearchActivities`XML 798通用搜索暴露 `id, title, summary, ownerUserId, ownerName, time`
- **字典**`selectDictTypes`2257、`selectDictOptions`2279、`selectColumnComments`2304
- **渠道覆盖** `crm_channel_expansion_coverage`:聚合统计 `channelCoverageSummary`2441+ **渠道详情已联查 `coverages` 明细**
- **销售所属区域** `crm_sales_expansion_coverage`**已通过 `crm_entity_detail`(sales_expansion) 联查 `coverages` 明细**
- **日报消息** `work_report_message`**已通过 `crm_entity_detail`(daily_report) 联查 `messages` 明细**sender/receiver/content/readAt 等)
---
## B. 缺失的表(数据库有数据,但 MCP 无实体/工具暴露)
| 缺失表 | 说明 | 位置 |
|---|---|---|
| `business_calendar_day` | 工作日历,**完全未暴露** | `sql/init_full_pg17.sql:778` |
| `report_reminder_*` | 日报提醒配置表MCP 未引用 | `ReportReminderService` |
| `speech_recognition_config` | 语音识别配置表MCP 未引用 | `SpeechRecognitionSchemaInitializer` |
| `dashboard_analytics_card_config` | 看板分析卡片配置表MCP 未引用 | `DashboardAnalyticsSchemaInitializer` |
| 数据权限表(`20260701_user_data_scope_user_pg17.sql` | 权限类MCP 未暴露(**合理** | `UserDataScopeSchemaInitializer` |
> 注:`sys_tenant_user`、`sys_user_role`、`cnarea`、`sys_dict_*` 为框架/字典/行政区划辅助表,仅被 join 使用,不算业务实体。核心业务实体(用户/客户/商机/拓客/打卡/日报/待办/跟进/组织/角色)均已覆盖。
---
## C. 缺失的字段
### C1. 主表已补齐search/list 层缺失字段)
> 详情接口用 `表.*` 已全覆盖。以下字段在**本轮已补进 search/list 查询**,供 Agent 查阅。
| 实体/表 | search 现返回的补齐字段 | 备注 |
|---|---|---|
| `work_daily_report` | `created_at, updated_at` | 审计字段 |
| `crm_opportunity` | `sales_expansion_id, channel_expansion_id, pre_sales_id, pushed_to_oms, oms_push_time, updated_by, created_at` | 外键 / OMS 集成 / 审计字段 |
| `crm_customer` | `created_at` | 审计字段 |
| `work_checkin` | `longitude, latitude, created_at, updated_at` | 经纬度已在列表返回 |
| `crm_sales_expansion` | `in_progress, created_at` | `in_progress`(是否持续跟进)已返回 |
| `crm_channel_expansion` | `industry`(兼容旧字段), `registered_capital`, `created_at` | `registered_capital` 注册资金 |
| `crm_crm_expansion` | `supplier_id, h3c_contact_id, created_at` | 外键 / 审计字段detail `ce.*` 覆盖) |
| `sys_user`profile / org-user | `user_code`(工号), `real_name`, `job_title`, `hire_date`, `display_name` | **已补工号/职位/入职日期/姓名**`password_hash`、`pwd_reset_required` **仍不应暴露**(安全) |
> ⚠️ 待确认:`sys_org` 的 `parent_id / org_code / sort_order` 仍硬编码为 null`sys_org` 属框架表、项目内**未找到建表脚本**,未擅自改动(`searchOrganizations`)。如需返回真实层级字段,需先人工核实 `sys_org` 列名。
### C2. 子表实体(已补齐,缺漏字段现已暴露)
| 子表 | 补齐后暴露的字段 | 说明 |
|---|---|---|
| `crm_expansion_followup` | `visit_start_time`(拜访开始时间), `source_type`, `source_id`, `created_at`, `updated_at` | searchFollowups、sales/channel followups 均已补 |
| `crm_opportunity_followup` | `source_type`, `source_id`, `created_at`, `updated_at` | selectOpportunityFollowups 已补 |
| `crm_channel_expansion_contact` | `channel_expansion_id, duty, birthday, wecom_added, special_note, sort_order, created_at, updated_at` | selectChannelExpansionContacts 已补全字段 |
| `work_daily_report_comment` | `created_at` | selectWorkReportComments 已补 |
| `crm_channel_expansion_coverage` | `province, city` | 渠道详情 `crm_entity_detail`(channel_expansion) 新增 `coverages` 明细(`selectChannelExpansionCoverages` |
| `crm_sales_expansion_coverage` | `province, city` | 销售详情 `crm_entity_detail`(sales_expansion) 新增 `coverages` 明细(销售所属区域,`selectSalesExpansionCoverages`),此前完全未暴露 |
| `work_report_message` | `sender_user_id, receiver_user_id, report_date, line_index, biz_type, biz_id, biz_name, content, read_at, created_at` | 日报详情 `crm_entity_detail`(daily_report) 新增 `messages` 明细(`selectWorkReportMessages`),此前完全未暴露 |
> ✅ **「是否加企业微信」维度现已支持统计**`crm_report_query`(`channel_analytics`) 新增 `contactWecomDistribution` 聚合(按 `crm_channel_expansion_contact.wecom_added`:已加/未加/未填写,含联系人计数、渠道计数)。
---
## D. 备注(低价值 / 安全 / 信息不全)
- **审计字段**`created_at` / `updated_at``crm_opportunity` 另含 `updated_by`。本轮已补进列表层。
- **外键字段**`customer_id`、`owner_user_id`、`sales_expansion_id`、`channel_expansion_id`、`pre_sales_id`、`followup_user_id`、`operator_user_id`、`reviewer_user_id`、`report_id` 等。
- **安全敏感字段**`sys_user.password_hash`、`pwd_reset_required` **不应暴露**MCP 正确未暴露)。
- **OMS 集成字段**`crm_opportunity.pushed_to_oms`、`oms_push_time`、`updated_by` 已补进列表层。
- **兼容性遗留字段**`crm_channel_expansion.industry`(兼容旧结构)已补;`oms_project_code` 已在 `OpportunitySchemaInitializer:316` 被 DROP。
### 信息不全待核对项
1. ~~`crm_crm_expansion` 表字段全集未找到建表脚本~~**已找到** `sql/20260827.sql`45-68字段全集已核验。
2. `sys_org` / `sys_role` / `sys_tenant_user` 的完整建表脚本**未找到**(仅片段注释);`searchOrganizations` 中 `parentId/orgCode/sortOrder` 被硬编码 null**待人工核实列名后再补真实值**。
3. `business_calendar_day`、`report_reminder_*`、`speech_recognition_config`、`dashboard_analytics_card_config`、数据权限表仍无独立 MCP 工具(不在本轮范围)。
---
## 结论摘要
- **是否已包含全部字段?** 核心业务主表 + 详情接口(`表.*`)已全覆盖;本轮已把所有明确标识的缺失字段补齐。剩余未处理项:`sys_org` 层级字段(待确认列名)、以及 `business_calendar_day` / 日报提醒 / 语音识别 / 看板卡片等配置类表在 MCP 中无独立工具(配置类,价值低)。
- **本轮已补齐的缺口**
1. 子表(真正缺漏)已全补:`crm_expansion_followup.visit_start_time/source_type/source_id`、`crm_channel_expansion_contact` 全部字段(含 `wecom_added`)、`crm_opportunity_followup`、`work_daily_report_comment.created_at`。
2. 新增 `crm_report_query.channel_analytics``contactWecomDistribution`**支持按「是否加企业微信」维度统计**。
3. 列表层:`work_checkin` 经纬度、`crm_sales_expansion.in_progress`、`registered_capital`、及 `created_at/updated_at` 补齐。
4. 用户画像:`sys_user.user_code(工号)/real_name/job_title(职位)/hire_date(入职日期)` 已补齐(`selectUserProfile` 与 `searchOrgUsers`)。
5. 新增 `crm_entity_detail`(crm_expansion) 联查 `contacts``crm_crm_expansion_contact` 子表)+ `followups`biz_type='crm'),此前完全未暴露。
6. **新增覆盖地市明细**:销售所属区域 `crm_sales_expansion_coverage`、渠道覆盖 `crm_channel_expansion_coverage` 已在销售/渠道详情的 `coverages` 返回(此前销售所属区域完全未暴露)。
7. **新增日报消息** `work_report_message`:日报详情 `messages` 返回 sender/receiver/content/readAt 等(此前完全未暴露)。
- **待办(非必需)**`sys_org.parent_id/org_code/sort_order` 待人工核实框架表列名后补真实值。

View File

@ -0,0 +1,118 @@
# OMS 退单回调接口文档
> OMS 系统退单后,主动回调 CRM将对应商机的项目阶段更新为 `S4`,并清空签约信息。
## 基本信息
| 项目 | 内容 |
| --- | --- |
| 接口路径 | `POST /api/oms/callback/order-return` |
| 调用方 | OMS 系统 |
| 请求头鉴权 | `X-Internal-Secret`(内部接口密钥) |
| 幂等性 | 可重试,重复调用不产生副作用 |
## 鉴权方式
与商机集成更新接口 `PUT /api/opportunities/integration/update` 保持一致,使用 `unisbase.internal-auth` 配置的密钥校验。
- 请求头字段:`X-Internal-Secret`(可通过 `unisbase.internal-auth.header-name` 覆盖)
- 秘钥值:`unisbase.internal-auth.secret`
- 校验逻辑:
- 当 `unisbase.internal-auth.enabled=true` 时强制校验,头信息与配置密钥不一致则返回鉴权失败;
- 当 `enabled=false` 时跳过鉴权(仅用于本地开发)。
请求示例:
```
POST /api/oms/callback/order-return
Content-Type: application/json
X-Internal-Secret: <配置的secret>
```
## 请求参数
### Bodyapplication/json
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `opportunityCode` | string | 是 | CRM 商机编号用于定位商机≤50 字符) |
| `orderNo` | string | 否 | OMS 退单号仅用于日志记录≤50 字符) |
请求体示例:
```json
{
"opportunityCode": "OPP-20260916-001",
"orderNo": "OMS-20260916-0001"
}
```
## 业务处理逻辑
退单回调成功后CRM 对目标商机执行如下更新:
| 字段 | 更新后值 |
| --- | --- |
| 项目阶段 `stage` | `S4` |
| 状态 `status` | `active` |
| 实际签约金额 `actual_signed_amount` | 置空(`null` |
| 是否签约 `archived` | `false` |
| 签约/归档时间 `archived_at` | 置空(`null` |
| 更新时间 `updated_at` | `now()` |
定位逻辑:按 `opportunity_code` 精确匹配商机;未匹配到或编号为空时抛出异常,不更新任何数据。整个操作在同一事务内完成。
## 响应
统一响应体:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `code` | int | 业务状态码,`0` 表示成功,非 `0` 表示失败 |
| `message` | string | 提示信息 |
| `data` | object | 返回数据 |
### 成功响应
`data` 为被更新商机的 `id`Long
```json
{
"code": 0,
"message": "success",
"data": 123
}
```
### 失败响应
| 场景 | 说明 |
| --- | --- |
| 鉴权失败 | 内部接口鉴权失败(`X-Internal-Secret` 不匹配时抛异常) |
| 商机编号为空 | 商机编号不能为空 |
| 商机不存在 | 未按 `opportunityCode` 匹配到商机 |
| 更新失败 | 数据库更新影响行数为 0 |
失败示例:
```json
{
"code": 1,
"message": "内部接口鉴权失败",
"data": null
}
```
> 注:`message` 具体文案以实际业务异常为准,示例仅供参考。
## 相关代码
- Controller[OmsCallbackController.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/OmsCallbackController.java)
- 请求 DTO[OmsOrderReturnRequest.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/OmsOrderReturnRequest.java)
- Service 接口:[OpportunityService.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/OpportunityService.java)
- Service 实现:[OpportunityServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java)
- Mapper 实现:[OpportunityMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml)
## 备注
- `crm_opportunity` 表无独立的「签约时间/是否签约」列,系统以 `archived`(是否签单)、`archived_at`(归档/签约时间)、`actual_signed_amount`(实际签约金额)表达签约状态,退单时三者一并清空。

View File

@ -0,0 +1,100 @@
# OMS 退单回调接口 · 调用失败排查与注意事项
> 本文档说明 OMS 调用 `POST /api/oms/callback/order-return` 时**最常见的失败原因**,以及**配置/联调前必须注意的事项**。
## 一、最常见的失败原因(必看)
### 1. 接口未加入登录认证白名单(本次已修复)
系统启用了安全过滤(`unisbase.security.enabled=true`),不在 `permit-all-urls` 白名单内的接口**必须先登录(携带 Bearer token才能访问**。OMS 是系统级外呼,不携带用户登录态,因此会被安全过滤器拦截导致失败。
- 影响接口(本次修复前):`/api/oms/callback/order-return`
- 修复位置(已在如下文件加入白名单):
- `backend/src/main/resources/application.yml`
- `backend/src/main/resources/application-prod.yml`
- 新增项:`- /api/oms/callback/order-return`
**注意:修改配置文件后必须重启后端服务才能生效。**
### 2. 鉴权头 `X-Internal-Secret` 不匹配
接口使用内部密钥校验(`unisbase.internal-auth`),请求头 `X-Internal-Secret` 必须与配置的 `secret` 一致,否则返回 401
- header 名称:`X-Internal-Secret`(可在 `unisbase.internal-auth.header-name` 覆盖)
- secret 值:`unisbase.internal-auth.secret`
- 触发条件:`unisbase.internal-auth.enabled=true` 时强制校验
- 常见错误OMS 侧配置的 secret 与后端不一致、头名写错、大小写不一致。
## 二、调用方OMS必须满足的调用要求
### 1. 地址与路径
| 项 | 要求 |
| --- | --- |
| 方法 | `POST`(不是 GET/PUT |
| 完整路径 | `/api/oms/callback/order-return`(注意拼写与大小写) |
| 网络 | OMS 与 CRM 需网络可达;若经网关/反向代理,请确认路径透传、末尾保持一致 |
### 2. 请求头
| Header | 是否必需 | 值 |
| --- | --- | --- |
| `Content-Type` | 是 | `application/json` |
| `X-Internal-Secret` | 是 | 与后端 `unisbase.internal-auth.secret` 一致 |
### 3. 请求体
```json
{
"opportunityCode": "OPP-20260916-001",
"orderNo": "OMS-20260916-0001"
}
```
- `opportunityCode`**必填**为空或空白会返回参数校验失败400
- `orderNo`:可选,仅用于日志,不影响结果。
## 三、常见失败场景与返回说明
| 失败场景 | 返回 | 原因/处理 |
| --- | --- | --- |
| 未放行白名单(未重启) | 需要登录 / 401 | 确认已加白名单并重启服务 |
| `X-Internal-Secret` 缺失或不符 | 401 内部接口鉴权失败 | 核对 secret 与头名 |
| `content-type` 非 json | 415 | 设置 `Content-Type: application/json` |
| 请求方法不是 POST | 405 | 使用 POST |
| `opportunityCode` 为空 | 400 | 补齐必填字段 |
| 商机编号不存在 | 400 商机不存在 | 确认该编号在 CRM 中存在 |
| 更新影响 0 行 | 400 商机更新失败 | 复核商机 id/DB 状态 |
| 白名单已放行但头不符 | 401 | 见第一节·二 |
## 四、联调前检查清单
- [ ] 后端已重启,配置文件(开发/prod均已加白名单
- [ ] OMS 侧 `X-Internal-Secret` 与后端 `secret` 完全一致
- [ ] 请求方法为 POST、`Content-Type: application/json`
- [ ] 传入了真实的、CRM 中存在的 `opportunityCode`
- [ ] 网络/网关可达,路径未被改写(注意带不带前缀 `/api`
- [ ] 用 `curl` 先在本地自测(见下)
## 五、本地自测命令(参考)
```bash
curl -X POST "http://<host>:<port>/api/oms/callback/order-return" \
-H "Content-Type: application/json" \
-H "X-Internal-Secret: <配置的secret>" \
-d '{"opportunityCode":"OPP-20260916-001","orderNo":"OMS-0001"}'
```
成功返回示例:
```json
{ "code": 0, "message": "success", "data": 123 }
```
## 六、注意事项汇总
1. **白名单默认不含新接口**,新增 OMS 类外部接口后必须同步加入 `permit-all-urls`,否则调用方拿不到登录态必然失败——这是本次失败的最可能根因。
2. **配置修改需重启**。
3. **内部密钥不要使用默认值**,生产环境务必设置独立强随机的 `secret`,并同步给 OMS。
4. 接口是**可重试/幂等**的:重复调用只会把阶段保持为 S4、签约信息清空不会产生副作用。
5. `orderNo` 仅用于日志对账,不参与业务判断,可选。

View File

@ -1,13 +1,19 @@
package com.unis.crm.controller; package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse; import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.UnauthorizedException;
import com.unis.crm.config.InternalAuthProperties;
import com.unis.crm.dto.opportunity.OmsOrderReturnRequest;
import com.unis.crm.service.CrmOmsDictMappingService; import com.unis.crm.service.CrmOmsDictMappingService;
import com.unis.crm.service.OpportunityService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* OMS * OMS
@ -19,13 +25,21 @@ public class OmsCallbackController {
private static final Logger log = LoggerFactory.getLogger(OmsCallbackController.class); private static final Logger log = LoggerFactory.getLogger(OmsCallbackController.class);
private static final String UNAUTHORIZED_MESSAGE = "内部接口鉴权失败";
private final CrmOmsDictMappingService dictMappingService; private final CrmOmsDictMappingService dictMappingService;
private final OpportunityService opportunityService;
private final InternalAuthProperties internalAuthProperties;
// 用于简单鉴权的 token实际部署时请替换为安全的配置 // 用于简单鉴权的 token实际部署时请替换为安全的配置
private static final String EXPECTED_TOKEN = "your_secret_token_here"; private static final String EXPECTED_TOKEN = "your_secret_token_here";
public OmsCallbackController(CrmOmsDictMappingService dictMappingService) { public OmsCallbackController(CrmOmsDictMappingService dictMappingService,
OpportunityService opportunityService,
InternalAuthProperties internalAuthProperties) {
this.dictMappingService = dictMappingService; this.dictMappingService = dictMappingService;
this.opportunityService = opportunityService;
this.internalAuthProperties = internalAuthProperties;
} }
/** /**
@ -71,4 +85,47 @@ public class OmsCallbackController {
return ApiResponse.fail("处理回调数据时发生错误: " + e.getMessage()); return ApiResponse.fail("处理回调数据时发生错误: " + e.getMessage());
} }
} }
/**
* OMS 退
* OMS 退 CRM S4
*
* @param request 退退
* @return
*/
@PostMapping("/callback/order-return")
public ApiResponse<Long> handleOmsOrderReturn(
HttpServletRequest httpServletRequest,
@Valid @RequestBody OmsOrderReturnRequest request) {
validateInternalSecret(httpServletRequest);
try {
Long opportunityId = opportunityService.updateOpportunityStageByOmsReturn(
request.getOpportunityCode(), request.getOrderNo());
return ApiResponse.success(opportunityId);
} catch (Exception e) {
log.error("Error processing OMS order-return callback", e);
return ApiResponse.fail("处理退单回调失败: " + e.getMessage());
}
}
private void validateInternalSecret(HttpServletRequest httpServletRequest) {
if (!internalAuthProperties.isEnabled()) {
return;
}
String expectedSecret = trimToNull(internalAuthProperties.getSecret());
String actualSecret = trimToNull(httpServletRequest.getHeader(internalAuthProperties.resolveHeaderName()));
if (expectedSecret == null || !Objects.equals(expectedSecret, actualSecret)) {
throw new UnauthorizedException(UNAUTHORIZED_MESSAGE);
}
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
} }

View File

@ -0,0 +1,34 @@
package com.unis.crm.dto.opportunity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* OMS 退
* OMS 退 CRM S4
*/
public class OmsOrderReturnRequest {
@Size(max = 50, message = "订单号不能超过50字符")
private String orderNo;
@NotBlank(message = "商机编号不能为空")
@Size(max = 50, message = "商机编号不能超过50字符")
private String opportunityCode;
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public String getOpportunityCode() {
return opportunityCode;
}
public void setOpportunityCode(String opportunityCode) {
this.opportunityCode = opportunityCode;
}
}

View File

@ -5,6 +5,8 @@ import java.util.List;
public class OpportunityDuplicateCheckDTO { public class OpportunityDuplicateCheckDTO {
private boolean duplicate; private boolean duplicate;
private boolean exactDuplicate;
private boolean similarDuplicate;
private List<Item> items; private List<Item> items;
public OpportunityDuplicateCheckDTO() { public OpportunityDuplicateCheckDTO() {
@ -15,6 +17,13 @@ public class OpportunityDuplicateCheckDTO {
this.items = items; this.items = items;
} }
public OpportunityDuplicateCheckDTO(boolean exactDuplicate, boolean similarDuplicate, List<Item> items) {
this.exactDuplicate = exactDuplicate;
this.similarDuplicate = similarDuplicate;
this.duplicate = exactDuplicate || similarDuplicate;
this.items = items;
}
public boolean isDuplicate() { public boolean isDuplicate() {
return duplicate; return duplicate;
} }
@ -23,6 +32,22 @@ public class OpportunityDuplicateCheckDTO {
this.duplicate = duplicate; this.duplicate = duplicate;
} }
public boolean isExactDuplicate() {
return exactDuplicate;
}
public void setExactDuplicate(boolean exactDuplicate) {
this.exactDuplicate = exactDuplicate;
}
public boolean isSimilarDuplicate() {
return similarDuplicate;
}
public void setSimilarDuplicate(boolean similarDuplicate) {
this.similarDuplicate = similarDuplicate;
}
public List<Item> getItems() { public List<Item> getItems() {
return items; return items;
} }
@ -38,6 +63,8 @@ public class OpportunityDuplicateCheckDTO {
private String opportunityName; private String opportunityName;
private String customerName; private String customerName;
private String stage; private String stage;
private String createdByName;
private double similarity;
public Long getOpportunityId() { public Long getOpportunityId() {
return opportunityId; return opportunityId;
@ -78,5 +105,21 @@ public class OpportunityDuplicateCheckDTO {
public void setStage(String stage) { public void setStage(String stage) {
this.stage = stage; this.stage = stage;
} }
public String getCreatedByName() {
return createdByName;
}
public void setCreatedByName(String createdByName) {
this.createdByName = createdByName;
}
public double getSimilarity() {
return similarity;
}
public void setSimilarity(double similarity) {
this.similarity = similarity;
}
} }
} }

View File

@ -38,7 +38,16 @@ public interface LlmMcpMapper {
@Param("tenantId") Long tenantId, @Param("tenantId") Long tenantId,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("stage") String stage, @Param("stage") String stage,
@Param("stageIn") List<String> stageIn,
@Param("includeArchived") boolean includeArchived, @Param("includeArchived") boolean includeArchived,
@Param("archivedFrom") LocalDate archivedFrom,
@Param("archivedTo") LocalDate archivedTo,
@Param("createdFrom") LocalDate createdFrom,
@Param("createdTo") LocalDate createdTo,
@Param("minAmount") Long minAmount,
@Param("maxAmount") Long maxAmount,
@Param("hasActualSignedAmount") Boolean hasActualSignedAmount,
@Param("orderByClause") String orderByClause,
@Param("limit") int limit, @Param("limit") int limit,
@Param("offset") int offset); @Param("offset") int offset);
@ -47,7 +56,15 @@ public interface LlmMcpMapper {
@Param("tenantId") Long tenantId, @Param("tenantId") Long tenantId,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("stage") String stage, @Param("stage") String stage,
@Param("includeArchived") boolean includeArchived); @Param("stageIn") List<String> stageIn,
@Param("includeArchived") boolean includeArchived,
@Param("archivedFrom") LocalDate archivedFrom,
@Param("archivedTo") LocalDate archivedTo,
@Param("createdFrom") LocalDate createdFrom,
@Param("createdTo") LocalDate createdTo,
@Param("minAmount") Long minAmount,
@Param("maxAmount") Long maxAmount,
@Param("hasActualSignedAmount") Boolean hasActualSignedAmount);
List<Map<String, Object>> universalSearchCustomers( List<Map<String, Object>> universalSearchCustomers(
@Param("keyword") String keyword, @Param("keyword") String keyword,
@ -167,6 +184,19 @@ public interface LlmMcpMapper {
@Param("limit") int limit, @Param("limit") int limit,
@Param("offset") int offset); @Param("offset") int offset);
List<Map<String, Object>> signedSummaryRows(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId,
@Param("groupBy") String groupBy);
Map<String, Object> signedSummaryQuality(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> opportunityTrend( List<Map<String, Object>> opportunityTrend(
@Param("startDate") LocalDate startDate, @Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate, @Param("endDate") LocalDate endDate,
@ -248,6 +278,12 @@ public interface LlmMcpMapper {
@Param("ownerUserId") Long ownerUserId, @Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId); @Param("tenantId") Long tenantId);
List<Map<String, Object>> channelContactWecomDistribution(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
Map<String, Object> selectWorkTodayStatus( Map<String, Object> selectWorkTodayStatus(
@Param("targetUserId") Long targetUserId, @Param("targetUserId") Long targetUserId,
@Param("tenantId") Long tenantId, @Param("tenantId") Long tenantId,
@ -376,6 +412,22 @@ public interface LlmMcpMapper {
@Param("ownerUserId") Long ownerUserId, @Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId); @Param("tenantId") Long tenantId);
List<Map<String, Object>> searchCrmExpansions(
@Param("keyword") String keyword,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId,
@Param("limit") int limit,
@Param("offset") int offset);
long countCrmExpansions(
@Param("keyword") String keyword,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
List<Map<String, Object>> searchFollowups( List<Map<String, Object>> searchFollowups(
@Param("bizType") String bizType, @Param("bizType") String bizType,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@ -406,6 +458,8 @@ public interface LlmMcpMapper {
Map<String, Object> selectChannelExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); Map<String, Object> selectChannelExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
Map<String, Object> selectCrmExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
Map<String, Object> selectTodoDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); Map<String, Object> selectTodoDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
List<Map<String, Object>> selectCustomerOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); List<Map<String, Object>> selectCustomerOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@ -422,9 +476,21 @@ public interface LlmMcpMapper {
List<Map<String, Object>> selectChannelExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); List<Map<String, Object>> selectChannelExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectCrmExpansionContacts(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectCrmExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectSalesExpansionCoverages(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectChannelExpansionCoverages(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectWorkReportMessages(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectChannelExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); List<Map<String, Object>> selectChannelExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectDictTypes(@Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset); List<Map<String, Object>> selectDictTypes(@Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset);
List<Map<String, Object>> selectDictOptions(@Param("typeCode") String typeCode, @Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset); List<Map<String, Object>> selectDictOptions(@Param("typeCode") String typeCode, @Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset);
List<Map<String, Object>> selectColumnComments(@Param("tableNames") List<String> tableNames);
} }

View File

@ -248,7 +248,7 @@ public class McpDataPermissionInterceptor implements Interceptor {
} }
return; return;
} }
if (methodName.equals("selectDictTypes") || methodName.equals("selectDictOptions")) { if (methodName.equals("selectDictTypes") || methodName.equals("selectDictOptions") || methodName.equals("selectColumnComments")) {
return; return;
} }
Long requestedTenantId = asLong(parameters.get("tenantId")); Long requestedTenantId = asLong(parameters.get("tenantId"));
@ -403,7 +403,7 @@ public class McpDataPermissionInterceptor implements Interceptor {
Map<String, QueryPolicy> policies = new LinkedHashMap<>(); Map<String, QueryPolicy> policies = new LinkedHashMap<>();
add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "r.user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "r.user_id", null, false,
"searchWorkReports", "countWorkReports", "universalSearchWorkReports", "dashboardDailyReportMetric", "searchWorkReports", "countWorkReports", "universalSearchWorkReports", "dashboardDailyReportMetric",
"selectWorkReportDetail", "selectWorkReportComments"); "selectWorkReportDetail", "selectWorkReportComments", "selectWorkReportMessages");
add(policies, CrmDataVisibilityService.RESOURCE_CHECKIN, "c.user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_CHECKIN, "c.user_id", null, false,
"universalSearchCheckins", "countCheckins", "dashboardCheckinMetric", "checkinSummary", "universalSearchCheckins", "countCheckins", "dashboardCheckinMetric", "checkinSummary",
"searchCheckins", "selectCheckinDetail"); "searchCheckins", "selectCheckinDetail");
@ -412,11 +412,16 @@ public class McpDataPermissionInterceptor implements Interceptor {
"searchCustomers", "selectCustomerDetail"); "searchCustomers", "selectCustomerDetail");
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "s.owner_user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "s.owner_user_id", null, false,
"universalSearchSalesExpansions", "countSalesExpansions", "salesExpansionSummary", "searchSalesExpansions", "universalSearchSalesExpansions", "countSalesExpansions", "salesExpansionSummary", "searchSalesExpansions",
"selectSalesExpansionDetail", "selectSalesExpansionFollowups"); "selectSalesExpansionDetail", "selectSalesExpansionFollowups", "selectSalesExpansionCoverages");
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "c.owner_user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "c.owner_user_id", null, false,
"universalSearchChannelExpansions", "countChannelExpansions", "channelExpansionSummary", "searchChannelExpansions", "universalSearchChannelExpansions", "countChannelExpansions", "channelExpansionSummary", "searchChannelExpansions",
"selectChannelExpansionDetail", "selectChannelExpansionContacts", "selectChannelExpansionFollowups", "selectChannelExpansionDetail", "selectChannelExpansionContacts", "selectChannelExpansionFollowups",
"channelTierDistribution", "channelIntentDistribution", "channelCoverageSummary", "channelRevenueTier"); "selectChannelExpansionCoverages",
"channelTierDistribution", "channelIntentDistribution", "channelCoverageSummary", "channelRevenueTier",
"channelContactWecomDistribution");
add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "ce.owner_user_id", null, false,
"searchCrmExpansions", "countCrmExpansions", "selectCrmExpansionDetail",
"selectCrmExpansionContacts", "selectCrmExpansionFollowups");
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "t.user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_WORK, "t.user_id", null, false,
"universalSearchTodos", "countTodos", "searchTodos", "todoSummary", "selectTodoDetail"); "universalSearchTodos", "countTodos", "searchTodos", "todoSummary", "selectTodoDetail");
add(policies, CrmDataVisibilityService.RESOURCE_WORK, "l.operator_user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_WORK, "l.operator_user_id", null, false,
@ -431,7 +436,8 @@ public class McpDataPermissionInterceptor implements Interceptor {
add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id", add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id",
"o.project_ownership_location", true, "o.project_ownership_location", true,
"searchOpportunities", "countOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric", "searchOpportunities", "countOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric",
"dashboardWonOpportunityMetric", "opportunityFunnel", "opportunityTrend", "dashboardWonOpportunityMetric", "opportunityFunnel", "opportunityTrend", "signedSummaryRows",
"signedSummaryQuality",
"selectOpportunityDetail", "selectCustomerOpportunities", "selectOpportunityFollowups", "selectOpportunityDetail", "selectCustomerOpportunities", "selectOpportunityFollowups",
"selectSalesExpansionOpportunities", "selectChannelExpansionOpportunities"); "selectSalesExpansionOpportunities", "selectChannelExpansionOpportunities");
add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "e.user_id", null, false, add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "e.user_id", null, false,

View File

@ -35,7 +35,7 @@ public class CrmCheckinSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索外勤打卡明细。结果遵循系统数据权限。"; return "分页检索 SCC 外勤打卡明细,可按关键词、状态、关联业务、日期筛选(最大跨度 366 天)。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。做打卡统计用 crm_report_query(checkin_summary)。结果遵循系统数据权限。";
} }
@Override @Override
@ -70,6 +70,7 @@ public class CrmCheckinSearchToolProvider extends PermissionedMcpToolProvider {
context.tenantId(), context.tenantId(),
context.pageSize(), context.pageSize(),
context.offset()))); context.offset())));
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "work_checkin"));
return result; return result;
} }

View File

@ -0,0 +1,96 @@
package com.unis.crm.llm.tools;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.llm.mapper.LlmMcpMapper;
import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* CRM crm_crm_expansion/
*/
@Component
public class CrmCrmExpansionSearchToolProvider extends PermissionedMcpToolProvider {
private static final int MAX_DATE_RANGE_DAYS = 366;
private final LlmMcpMapper llmMcpMapper;
private final SpringSecurityTenantProvider tenantProvider;
public CrmCrmExpansionSearchToolProvider(
ObjectMapper objectMapper,
LlmMcpMapper llmMcpMapper,
SpringSecurityTenantProvider tenantProvider) {
super(objectMapper);
this.llmMcpMapper = llmMcpMapper;
this.tenantProvider = tenantProvider;
}
@Override
protected String getToolName() {
return "crm_crm_expansion_search";
}
@Override
protected String getToolDescription() {
return "分页检索 CRM 拓展(最终用户)明细,可按最终用户、进货商、新华三对接人、关键词、日期筛选(最大跨度 366 天)。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。结果遵循系统数据权限。";
}
@Override
protected Map<String, Object> buildInputSchema() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("keyword", stringProperty("关键词,匹配最终用户、办公地点、联系人、进货商、新华三对接人、备注。"));
properties.put("startDate", stringProperty("开始日期 YYYY-MM-DD。"));
properties.put("endDate", stringProperty("结束日期 YYYY-MM-DD最大跨度 366 天。"));
properties.put("ownerUserId", integerProperty("负责人用户 ID实际可见范围仍按系统数据权限裁剪。"));
properties.put("page", integerProperty("页码,默认 1。"));
properties.put("pageSize", integerProperty("每页条数,默认 10最大 50。"));
properties.put("limit", integerProperty("返回条数,默认 10最大 50。"));
return objectSchema(properties);
}
@Override
protected Object handle(Map<String, Object> arguments) {
String keyword = getString(arguments, "keyword");
Long ownerUserId = getLong(arguments, "ownerUserId");
Long tenantId = tenantProvider.getCurrentTenantId();
LocalDate startDate = getDate(arguments, "startDate");
LocalDate endDate = getDate(arguments, "endDate");
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new BusinessException("startDate 不能晚于 endDate");
}
if (startDate != null && endDate != null && ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_DATE_RANGE_DAYS) {
throw new BusinessException("明细查询最大日期跨度为 366 天");
}
int pageSize = getInt(arguments, "pageSize", getInt(arguments, "limit", DEFAULT_LIMIT, MAX_LIMIT), MAX_LIMIT);
int page = getPage(arguments);
int offset = getOffset(page, pageSize);
long total = llmMcpMapper.countCrmExpansions(keyword, startDate, endDate, ownerUserId, tenantId);
List<Map<String, Object>> rows = sanitizeRows(
llmMcpMapper.searchCrmExpansions(keyword, startDate, endDate, ownerUserId, tenantId, pageSize, offset));
Map<String, Object> result = new LinkedHashMap<>();
result.put("type", "crm_expansion");
result.put("ownerUserId", ownerUserId);
result.put("tenantId", tenantId);
result.put("startDate", startDate == null ? null : startDate.toString());
result.put("endDate", endDate == null ? null : endDate.toString());
result.put("page", page);
result.put("pageSize", pageSize);
result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", rows);
if (!StringUtils.hasText(keyword)) {
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_crm_expansion"));
}
return result;
}
}

View File

@ -35,7 +35,7 @@ public class CrmDetailSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索 CRM 客户明细。结果遵循系统数据权限。"; return "分页检索 SCC 客户明细,可按关键词、状态、来源、行业、负责人、日期筛选。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。查询客户用本工具;查询商机用 crm_opportunity_search做客户汇总统计用 crm_report_query(customer_summary)。结果遵循系统数据权限。";
} }
@Override @Override
@ -73,6 +73,7 @@ public class CrmDetailSearchToolProvider extends PermissionedMcpToolProvider {
context.tenantId(), context.tenantId(),
context.pageSize(), context.pageSize(),
context.offset()))); context.offset())));
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_customer"));
return result; return result;
} }

View File

@ -25,7 +25,7 @@ public class CrmDictOptionsToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "查询系统字典类型或字典选项,例如商机阶段、把握度、行业、渠道属性等。"; return "查询系统字典类型或某字典类型下的合法选项。当查询参数涉及枚举值(如商机阶段 stage、把握度、客户状态、行业、意向等级、程序化状态等而你不确定合法的编码/取值时,务必先调用本工具查 typeCode 拿到 item_value/item_label再据此填写 search/report 工具的枚举参数,切勿凭空猜测取值,否则会查不到数据。不传 typeCode 返回字典类型列表。";
} }
@Override @Override

View File

@ -33,7 +33,7 @@ public class CrmEntityDetailToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "根据实体类型和 ID 查询完整详情。结果遵循系统数据权限。"; return "按实体类型(entityType)和 ID 查询某条记录的完整详情,并可带相关明细(如客户下的商机、商机的跟进等)。适用于已拿到具体 ID 后查看全貌;若只有关键词而不确定 ID先用 crm_universal_search 或对应模块明细工具找到 ID。结果遵循系统数据权限。";
} }
@Override @Override
@ -46,8 +46,10 @@ public class CrmEntityDetailToolProvider extends PermissionedMcpToolProvider {
"checkin", "checkin",
"sales_expansion", "sales_expansion",
"channel_expansion", "channel_expansion",
"crm_expansion",
"todo"))); "todo")));
properties.put("id", integerProperty("实体 ID。")); properties.put("id", integerProperty("单个实体 ID。使用单个详情时填此项批量请用 id 与 ids 二选一。"));
properties.put("ids", stringProperty("批量实体 ID用英文逗号拼接多个 ID如 '1035,751',单次最多 100 个。用于一次取多条详情,避免逐条多次调用;返回结构为 rows。与 id 二选一。"));
properties.put("includeRelated", booleanProperty("是否返回关联明细,默认 true。")); properties.put("includeRelated", booleanProperty("是否返回关联明细,默认 true。"));
properties.put("relatedLimit", integerProperty("每类关联明细返回条数,默认 10最大 50。")); properties.put("relatedLimit", integerProperty("每类关联明细返回条数,默认 10最大 50。"));
return objectSchema(properties); return objectSchema(properties);
@ -60,14 +62,19 @@ public class CrmEntityDetailToolProvider extends PermissionedMcpToolProvider {
private Map<String, Object> detail(Map<String, Object> arguments) { private Map<String, Object> detail(Map<String, Object> arguments) {
String entityType = getString(arguments, "entityType"); String entityType = getString(arguments, "entityType");
Long id = getLong(arguments, "id");
if (!StringUtils.hasText(entityType)) { if (!StringUtils.hasText(entityType)) {
throw new BusinessException("entityType 不能为空"); throw new BusinessException("entityType 不能为空");
} }
if (id == null || id <= 0) {
throw new BusinessException("id 必须是有效数字");
}
Long tenantId = tenantProvider.getCurrentTenantId(); Long tenantId = tenantProvider.getCurrentTenantId();
Long id = getLong(arguments, "id");
String idsText = getString(arguments, "ids");
// 批量优先:提供 ids 时走批量分支;单个 id 仍保留原逻辑。
if (StringUtils.hasText(idsText)) {
return batchDetail(entityType, idsText, tenantId, arguments);
}
if (id == null || id <= 0) {
throw new BusinessException("id 必须是有效数字(或提供 ids 批量查询)");
}
Map<String, Object> row = switch (entityType) { Map<String, Object> row = switch (entityType) {
case "customer" -> llmMcpMapper.selectCustomerDetail(id, tenantId); case "customer" -> llmMcpMapper.selectCustomerDetail(id, tenantId);
case "opportunity" -> llmMcpMapper.selectOpportunityDetail(id, tenantId); case "opportunity" -> llmMcpMapper.selectOpportunityDetail(id, tenantId);
@ -75,6 +82,7 @@ public class CrmEntityDetailToolProvider extends PermissionedMcpToolProvider {
case "checkin" -> llmMcpMapper.selectCheckinDetail(id, tenantId); case "checkin" -> llmMcpMapper.selectCheckinDetail(id, tenantId);
case "sales_expansion" -> llmMcpMapper.selectSalesExpansionDetail(id, tenantId); case "sales_expansion" -> llmMcpMapper.selectSalesExpansionDetail(id, tenantId);
case "channel_expansion" -> llmMcpMapper.selectChannelExpansionDetail(id, tenantId); case "channel_expansion" -> llmMcpMapper.selectChannelExpansionDetail(id, tenantId);
case "crm_expansion" -> llmMcpMapper.selectCrmExpansionDetail(id, tenantId);
case "todo" -> llmMcpMapper.selectTodoDetail(id, tenantId); case "todo" -> llmMcpMapper.selectTodoDetail(id, tenantId);
default -> throw new BusinessException("不支持的 entityType" + entityType); default -> throw new BusinessException("不支持的 entityType" + entityType);
}; };
@ -91,21 +99,91 @@ public class CrmEntityDetailToolProvider extends PermissionedMcpToolProvider {
return result; return result;
} }
/** 批量详情:解析 ids逐条复用单条查询逻辑不改数据库不注入单次最多 100 个。 */
private Map<String, Object> batchDetail(String entityType, String idsText, Long tenantId, Map<String, Object> arguments) {
List<Long> ids = new java.util.ArrayList<>();
for (String part : idsText.split(",")) {
String trimmed = part.trim();
if (trimmed.isEmpty()) {
continue;
}
try {
long value = Long.parseLong(trimmed);
if (value > 0) {
ids.add(value);
}
} catch (NumberFormatException exception) {
throw new BusinessException("ids 含非法数字:" + trimmed);
}
}
if (ids.isEmpty()) {
throw new BusinessException("ids 必须至少包含一个有效 ID");
}
if (ids.size() > 100) {
throw new BusinessException("ids 单次最多 100 个,当前 " + ids.size() + " 个");
}
boolean includeRelated = getBoolean(arguments, "includeRelated", true);
int relatedLimit = getInt(arguments, "relatedLimit", DEFAULT_LIMIT, MAX_LIMIT);
List<Map<String, Object>> rows = new java.util.ArrayList<>();
List<Long> foundIds = new java.util.ArrayList<>();
for (Long id : ids) {
Map<String, Object> row = switch (entityType) {
case "customer" -> llmMcpMapper.selectCustomerDetail(id, tenantId);
case "opportunity" -> llmMcpMapper.selectOpportunityDetail(id, tenantId);
case "daily_report" -> llmMcpMapper.selectWorkReportDetail(id, tenantId);
case "checkin" -> llmMcpMapper.selectCheckinDetail(id, tenantId);
case "sales_expansion" -> llmMcpMapper.selectSalesExpansionDetail(id, tenantId);
case "channel_expansion" -> llmMcpMapper.selectChannelExpansionDetail(id, tenantId);
case "crm_expansion" -> llmMcpMapper.selectCrmExpansionDetail(id, tenantId);
case "todo" -> llmMcpMapper.selectTodoDetail(id, tenantId);
default -> throw new BusinessException("不支持的 entityType" + entityType);
};
if (row != null && !row.isEmpty()) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("id", id);
entry.put("detail", sanitizeRows(List.of(row)).get(0));
if (includeRelated) {
entry.put("related", related(entityType, id, tenantId, relatedLimit));
}
rows.add(entry);
foundIds.add(id);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("entityType", entityType);
result.put("tenantId", tenantId);
result.put("requestedIds", ids);
result.put("foundIds", foundIds);
result.put("found", rows.size());
result.put("missing", ids.size() - rows.size());
result.put("rows", rows);
return result;
}
private Map<String, Object> related(String entityType, Long id, Long tenantId, int limit) { private Map<String, Object> related(String entityType, Long id, Long tenantId, int limit) {
Map<String, Object> related = new LinkedHashMap<>(); Map<String, Object> related = new LinkedHashMap<>();
switch (entityType) { switch (entityType) {
case "customer" -> related.put("opportunities", sanitizeRows(llmMcpMapper.selectCustomerOpportunities(id, tenantId, limit))); case "customer" -> related.put("opportunities", sanitizeRows(llmMcpMapper.selectCustomerOpportunities(id, tenantId, limit)));
case "opportunity" -> related.put("followups", sanitizeRows(llmMcpMapper.selectOpportunityFollowups(id, tenantId, limit))); case "opportunity" -> related.put("followups", sanitizeRows(llmMcpMapper.selectOpportunityFollowups(id, tenantId, limit)));
case "daily_report" -> related.put("comments", sanitizeRows(llmMcpMapper.selectWorkReportComments(id, tenantId, limit))); case "daily_report" -> {
related.put("comments", sanitizeRows(llmMcpMapper.selectWorkReportComments(id, tenantId, limit)));
related.put("messages", sanitizeRows(llmMcpMapper.selectWorkReportMessages(id, tenantId, limit)));
}
case "sales_expansion" -> { case "sales_expansion" -> {
related.put("coverages", sanitizeRows(llmMcpMapper.selectSalesExpansionCoverages(id, tenantId, limit)));
related.put("followups", sanitizeRows(llmMcpMapper.selectSalesExpansionFollowups(id, tenantId, limit))); related.put("followups", sanitizeRows(llmMcpMapper.selectSalesExpansionFollowups(id, tenantId, limit)));
related.put("opportunities", sanitizeRows(llmMcpMapper.selectSalesExpansionOpportunities(id, tenantId, limit))); related.put("opportunities", sanitizeRows(llmMcpMapper.selectSalesExpansionOpportunities(id, tenantId, limit)));
} }
case "channel_expansion" -> { case "channel_expansion" -> {
related.put("coverages", sanitizeRows(llmMcpMapper.selectChannelExpansionCoverages(id, tenantId, limit)));
related.put("contacts", sanitizeRows(llmMcpMapper.selectChannelExpansionContacts(id, tenantId, limit))); related.put("contacts", sanitizeRows(llmMcpMapper.selectChannelExpansionContacts(id, tenantId, limit)));
related.put("followups", sanitizeRows(llmMcpMapper.selectChannelExpansionFollowups(id, tenantId, limit))); related.put("followups", sanitizeRows(llmMcpMapper.selectChannelExpansionFollowups(id, tenantId, limit)));
related.put("opportunities", sanitizeRows(llmMcpMapper.selectChannelExpansionOpportunities(id, tenantId, limit))); related.put("opportunities", sanitizeRows(llmMcpMapper.selectChannelExpansionOpportunities(id, tenantId, limit)));
} }
case "crm_expansion" -> {
related.put("contacts", sanitizeRows(llmMcpMapper.selectCrmExpansionContacts(id, tenantId, limit)));
related.put("followups", sanitizeRows(llmMcpMapper.selectCrmExpansionFollowups(id, tenantId, limit)));
}
default -> { default -> {
} }
} }

View File

@ -39,7 +39,7 @@ public class CrmExpansionSearchToolProvider extends PermissionedMcpToolProvider
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索销售拓客和渠道拓客明细。结果遵循系统数据权限。"; return "分页检索销售拓客和渠道拓客明细,可按拓客类型(sales/channel)、阶段、意向等级、关键词、日期筛选(最大跨度 366 天)。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。做拓客汇总或渠道分级统计用 crm_report_query(expansion_summary / channel_analytics)。结果遵循系统数据权限。";
} }
@Override @Override
@ -122,6 +122,11 @@ public class CrmExpansionSearchToolProvider extends PermissionedMcpToolProvider
result.put("total", total); result.put("total", total);
result.put("hasMore", (long) context.offset() + context.pageSize() < total); result.put("hasMore", (long) context.offset() + context.pageSize() < total);
result.put("rows", rows); result.put("rows", rows);
if ("channel".equals(expansionType)) {
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_channel_expansion"));
} else {
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_sales_expansion"));
}
return result; return result;
} }

View File

@ -0,0 +1,75 @@
package com.unis.crm.llm.tools;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.llm.mapper.LlmMcpMapper;
import com.unis.crm.llm.tools.support.FieldCommentRegistry;
import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* COMMENT Agent
* tableNames
*/
@Component
public class CrmFieldCommentToolProvider extends PermissionedMcpToolProvider {
private final LlmMcpMapper llmMcpMapper;
public CrmFieldCommentToolProvider(ObjectMapper objectMapper, LlmMcpMapper llmMcpMapper) {
super(objectMapper);
this.llmMcpMapper = llmMcpMapper;
}
@Override
protected String getToolName() {
return "crm_field_comment";
}
@Override
protected String getToolDescription() {
return "查询 SCC MCP 返回字段的中文注释(读数据库 COMMENT。当你不确定某个返回字段如 actualSignedAmount、ownerUserId的具体含义时用本工具查该表字段中文注释。可传 tableNames 限定表(如 crm_opportunity、crm_customer、crm_sales_expansion、crm_channel_expansion、work_daily_report、work_checkin、crm_expansion_followup、work_todo结果遵循系统数据权限仅返回当前库已配置注释的字段。";
}
@Override
protected Map<String, Object> buildInputSchema() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("tableNames", arrayStringProperty("要查询注释的表名列表,可选;为空时返回所有可用表的字段注释。"));
return objectSchema(properties);
}
private Map<String, Object> arrayStringProperty(String description) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", "array");
property.put("items", Map.of("type", "string"));
property.put("description", description);
return property;
}
@Override
protected Object handle(Map<String, Object> arguments) {
List<String> requestedTables = null;
if (arguments != null && arguments.get("tableNames") instanceof List<?> list) {
requestedTables = new java.util.ArrayList<>();
for (Object item : list) {
if (item != null) {
requestedTables.add(String.valueOf(item));
}
}
}
List<String> tables = new java.util.ArrayList<>();
if (requestedTables != null && !requestedTables.isEmpty()) {
tables.addAll(requestedTables);
} else {
tables.addAll(FieldCommentRegistry.tables().keySet());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("requestedTables", tables);
result.put("comments", tableColumnComments(llmMcpMapper, tables));
result.put("availableTables", FieldCommentRegistry.tables());
result.put("source", "数据库 COMMENT物理列中文注释");
return result;
}
}

View File

@ -36,7 +36,7 @@ public class CrmFollowupSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索商机/拓客跟进记录。结果遵循系统数据权限。"; return "分页检索 SCC 跟进记录(覆盖商机、销售拓客、渠道拓客的跟进),可按业务类型、关键词、日期筛选(最大跨度 366 天)。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。结果遵循系统数据权限。";
} }
@Override @Override
@ -72,6 +72,7 @@ public class CrmFollowupSearchToolProvider extends PermissionedMcpToolProvider {
context.tenantId(), context.tenantId(),
context.pageSize(), context.pageSize(),
context.offset()))); context.offset())));
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_expansion_followup"));
return result; return result;
} }

View File

@ -21,7 +21,7 @@ public class CrmMetadataToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "查看 CRM MCP 支持的模块、字段、报表和字典入口,帮助 LLM 正确选择查询工具和参数。"; return "查看 SCC MCP 支持的模块、字段、报表与字典入口(元数据目录)。当你刚开始处理某个 SCC 查询任务、不确定数据在哪、用什么工具、有哪些字段时,优先调用本工具建立全局认识,再选用具体的 search/report/detail 工具。";
} }
@Override @Override
@ -37,13 +37,18 @@ public class CrmMetadataToolProvider extends PermissionedMcpToolProvider {
private Map<String, Object> metadataCatalog() { private Map<String, Object> metadataCatalog() {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("modules", List.of( result.put("modules", List.of(
module("customer", "客户", List.of("id", "customerName", "industry", "province", "city", "source", "status", "ownerUserId")), module("customer", "客户", List.of("id", "customerCode", "customerName", "customerType", "industry", "province", "city", "address", "source", "status", "ownerUserId", "ownerName", "remark", "updatedAt")),
module("opportunity", "商机", List.of("id", "opportunityName", "customerName", "amount", "stage", "status", "expectedCloseDate", "ownerUserId")), module("opportunity", "商机", List.of("id", "opportunityCode", "opportunityName", "customerName", "ownerUserId", "ownerName", "projectLocation", "projectOwnershipLocation", "productType", "source", "amount", "actualSignedAmount", "expectedCloseDate", "confidence", "stage", "status", "archived", "archivedAt", "description", "updatedAt")),
module("daily_report", "工作日报", List.of("id", "reportDate", "userName", "workContent", "tomorrowPlan", "status", "score")), module("daily_report", "工作日报", List.of("id", "userId", "userName", "reportDate", "submitTime", "sourceType", "status", "workContent", "tomorrowPlan", "score", "comment", "updatedAt")),
module("checkin", "外勤打卡", List.of("id", "checkinDate", "userName", "bizType", "bizName", "locationText", "status")), module("checkin", "外勤打卡", List.of("id", "userId", "userName", "checkinDate", "checkinTime", "bizType", "bizName", "locationText", "longitude", "latitude", "status", "remark", "updatedAt")),
module("sales_expansion", "销售拓客", List.of("id", "candidateName", "mobile", "industry", "stage", "intentLevel", "ownerUserId")), module("sales_expansion", "销售拓客", List.of("id", "employeeNo", "candidateName", "officeName", "mobile", "email", "targetDept", "industry", "title", "intentLevel", "stage", "employmentStatus", "expectedJoinDate", "ownerUserId", "remark", "updatedAt")),
module("channel_expansion", "渠道拓客", List.of("id", "channelName", "province", "city", "channelIndustry", "stage", "intentLevel", "ownerUserId")), module("channel_expansion", "渠道拓客", List.of("id", "channelCode", "channelName", "province", "city", "officeAddress", "channelIndustry", "certificationLevel", "annualRevenue", "staffSize", "contactName", "contactTitle", "contactMobile", "intentLevel", "stage", "channelAttribute", "landedFlag", "expectedSignDate", "ownerUserId", "remark", "updatedAt")),
module("followup", "跟进记录", List.of("id", "bizType", "bizId", "bizName", "followupType", "content", "nextAction", "userId")))); module("crm_expansion", "CRM拓展", List.of("id", "endUser", "officeName", "industryAttr", "extensionType", "purchaseDate", "warrantyExpiry", "onlineStatus", "contactName", "contactPhone", "contactTitle", "supplierName", "h3cContactName", "hasExpansionOpportunity", "hasMaintenanceOpportunity", "softwarePoints", "expansionTime", "expansionScale", "ownerUserId", "remark", "updatedAt")),
module("followup", "跟进记录", List.of("id", "bizType", "bizId", "bizName", "followupType", "content", "nextAction", "followupTime", "followupUserId", "nextPlan"))));
result.put("caliber", List.of(
Map.of("metric", "签约(signed)", "definition", "stage='S5' 且 archived_at 落在查询区间内;金额取 actual_signed_amount实际签约金额非 amount 预估值)"),
Map.of("metric", "丢单(lost)", "definition", "stage='L'(已丢单阶段)"),
Map.of("metric", "商机金额(amount)", "definition", "crm_opportunity.amount 字段,代表商机预估值/计划额")));
result.put("detailTool", "crm_entity_detail"); result.put("detailTool", "crm_entity_detail");
result.put("dictTool", "crm_dict_options"); result.put("dictTool", "crm_dict_options");
result.put("searchTool", "crm_universal_search"); result.put("searchTool", "crm_universal_search");

View File

@ -32,7 +32,7 @@ public class CrmOrgUserSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "查询 CRM 组织、角色、用户通讯录信息。用户结果遵循系统数据权限,组织和角色按当前租户裁剪。"; return "查询 SCC 组织、角色、用户通讯录,可用 scope 限定范围并按关键词检索。用于把真实姓名/部门/组织映射为系统用户 IDownerUserId/targetUserId/userId 参数需要填 ID 时用它查询),也用于回答组织架构相关问题。用户结果遵循系统数据权限,组织和角色按当前租户裁剪。";
} }
@Override @Override

View File

@ -21,7 +21,7 @@ public class CrmReportCatalogToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "列出当前 MCP 支持的 CRM 查询/报表能力。"; return "列出当前 MCP 支持的 SCC 查询与报表能力清单(含各 search 工具和 reportType。当用户要求做【汇总/统计/概览/占比/趋势】(而非看明细)时,先查看本目录找到对应 reportType再用 crm_report_query 查询统计结果,比翻明细列表更准确、更快。";
} }
@Override @Override
@ -43,10 +43,16 @@ public class CrmReportCatalogToolProvider extends PermissionedMcpToolProvider {
Map.of("name", "crm_customer_search", "description", "客户明细检索"), Map.of("name", "crm_customer_search", "description", "客户明细检索"),
Map.of("name", "crm_checkin_search", "description", "外勤打卡明细检索"), Map.of("name", "crm_checkin_search", "description", "外勤打卡明细检索"),
Map.of("name", "crm_expansion_search", "description", "销售/渠道拓客明细检索"), Map.of("name", "crm_expansion_search", "description", "销售/渠道拓客明细检索"),
Map.of("name", "crm_followup_search", "description", "商机/拓客跟进明细检索"))); Map.of("name", "crm_crm_expansion_search", "description", "CRM 拓客crm_crm_expansion明细检索"),
Map.of("name", "crm_followup_search", "description", "商机/拓客跟进明细检索"),
Map.of("name", "crm_todo_search", "description", "待办检索"),
Map.of("name", "crm_work_today_status", "description", "今日工作状态"),
Map.of("name", "crm_org_user_search", "description", "组织用户检索"),
Map.of("name", "crm_report_query", "description", "汇总统计查询(按 reportType")));
result.put("metadataTools", List.of( result.put("metadataTools", List.of(
Map.of("name", "crm_metadata_catalog", "description", "模块、字段、工具能力目录"), Map.of("name", "crm_metadata_catalog", "description", "模块、字段、工具能力目录"),
Map.of("name", "crm_dict_options", "description", "系统字典类型和选项"))); Map.of("name", "crm_dict_options", "description", "系统字典类型和选项"),
Map.of("name", "crm_field_comment", "description", "查询 MCP 返回字段的中文注释(读数据库 COMMENT")));
result.put("reportTypes", List.of( result.put("reportTypes", List.of(
Map.of("reportType", "dashboard_summary", "description", "首页经营概览"), Map.of("reportType", "dashboard_summary", "description", "首页经营概览"),
Map.of("reportType", "sales_performance", "description", "人员业绩"), Map.of("reportType", "sales_performance", "description", "人员业绩"),
@ -57,7 +63,8 @@ public class CrmReportCatalogToolProvider extends PermissionedMcpToolProvider {
Map.of("reportType", "expansion_summary", "description", "销售拓客与渠道拓客统计"), Map.of("reportType", "expansion_summary", "description", "销售拓客与渠道拓客统计"),
Map.of("reportType", "channel_analytics", "description", "渠道统计分析认证级别分布、合作意向、覆盖省市、年度营收分档SQL 全量聚合)"), Map.of("reportType", "channel_analytics", "description", "渠道统计分析认证级别分布、合作意向、覆盖省市、年度营收分档SQL 全量聚合)"),
Map.of("reportType", "customer_summary", "description", "客户状态、来源、行业统计"), Map.of("reportType", "customer_summary", "description", "客户状态、来源、行业统计"),
Map.of("reportType", "todo_summary", "description", "待办状态与优先级统计"))); Map.of("reportType", "todo_summary", "description", "待办状态与优先级统计"),
Map.of("reportType", "actual_signed_summary", "description", "实际签约统计:按归档口径(S5+archived_at)统计签约单数、实际签约金额,含数据质量,可按 month/owner/product/source/province 分组SQL 全量聚合)")));
result.put("security", "查询结果遵循系统 @DataScope 数据权限。"); result.put("security", "查询结果遵循系统 @DataScope 数据权限。");
return result; return result;
} }

View File

@ -44,7 +44,7 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "查询 CRM 固定白名单报表,覆盖首页、业绩、商机漏斗、趋势、日报完成率、打卡、拓客、客户和待办统计。"; return "查询 SCC 固定白名单报表,用于做汇总/统计/概览(首页经营指标、人员业绩、商机漏斗/趋势、日报完成率、打卡、拓客、客户、待办统计、渠道分级与覆盖分析 channel_analytics。重要当用户要的是【数量、金额、占比、趋势、排名、统计】等汇总结果时用本工具而非翻明细列表可一次拿到全量统计。先查 crm_report_catalog 或 crm_metadata_catalog 确认报表类型与可用 reportType。结果遵循系统数据权限。";
} }
@Override @Override
@ -60,11 +60,12 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
"expansion_summary", "expansion_summary",
"customer_summary", "customer_summary",
"todo_summary", "todo_summary",
"channel_analytics"))); "channel_analytics",
"actual_signed_summary")));
queryProperties.put("startDate", stringProperty("开始日期 YYYY-MM-DD不传默认本月。")); queryProperties.put("startDate", stringProperty("开始日期 YYYY-MM-DD不传默认本月。"));
queryProperties.put("endDate", stringProperty("结束日期 YYYY-MM-DD不传默认今天最大跨度 366 天。")); queryProperties.put("endDate", stringProperty("结束日期 YYYY-MM-DD不传默认今天最大跨度 366 天。"));
queryProperties.put("ownerUserId", integerProperty("目标用户 ID实际可见范围仍按系统数据权限裁剪。")); queryProperties.put("ownerUserId", integerProperty("目标用户 ID实际可见范围仍按系统数据权限裁剪。"));
queryProperties.put("groupBy", enumStringProperty("分组维度。", List.of("none", "day", "month", "owner", "stage", "status", "source"))); queryProperties.put("groupBy", enumStringProperty("分组维度。", List.of("none", "day", "month", "owner", "stage", "status", "source", "product", "province")));
queryProperties.put("page", integerProperty("页码,默认 1。")); queryProperties.put("page", integerProperty("页码,默认 1。"));
queryProperties.put("pageSize", integerProperty("每页条数,默认 20最大 50。")); queryProperties.put("pageSize", integerProperty("每页条数,默认 20最大 50。"));
queryProperties.put("limit", integerProperty("返回条数,默认 20最大 50。")); queryProperties.put("limit", integerProperty("返回条数,默认 20最大 50。"));
@ -83,6 +84,11 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
} }
requireReportPermission(reportType); requireReportPermission(reportType);
QueryContext context = buildContext(arguments); QueryContext context = buildContext(arguments);
// 实际签约统计返回完整结果rows/rowCount/definition/dataQuality/handlingHint
// 与常规报表的 List<Map> rows 结构不同,故在下方 switch 之前短路处理。
if ("actual_signed_summary".equals(reportType)) {
return buildResult(reportType, context, signedSummary(context));
}
List<Map<String, Object>> rows = switch (reportType) { List<Map<String, Object>> rows = switch (reportType) {
case "dashboard_summary" -> dashboardSummary(context); case "dashboard_summary" -> dashboardSummary(context);
case "sales_performance" -> salesPerformance(context); case "sales_performance" -> salesPerformance(context);
@ -96,7 +102,20 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
case "channel_analytics" -> channelAnalytics(context); case "channel_analytics" -> channelAnalytics(context);
default -> throw new BusinessException("不支持的 reportType" + reportType); default -> throw new BusinessException("不支持的 reportType" + reportType);
}; };
// 常规报表rows 语义统一为 List<Map> 数组,封装在 "rows" 节点下。
Map<String, Object> result = new LinkedHashMap<>();
result.putAll(baseResult(reportType, context));
result.put("rows", rows);
// 口径自描述:为涉及签约/丢单统计的报表补充 caliber 节点,说明各指标口径,避免 Agent 误读字段含义。
String caliber = caliberFor(reportType);
if (caliber != null) {
result.put("caliber", caliber);
}
return result;
}
/** 构建标准结果信封(不含 rows。 */
private Map<String, Object> baseResult(String reportType, QueryContext context) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("reportType", reportType); result.put("reportType", reportType);
result.put("startDate", context.startDate().toString()); result.put("startDate", context.startDate().toString());
@ -106,14 +125,33 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
result.put("groupBy", context.groupBy()); result.put("groupBy", context.groupBy());
result.put("page", context.page()); result.put("page", context.page());
result.put("pageSize", context.pageSize()); result.put("pageSize", context.pageSize());
result.put("rows", rows);
return result; return result;
} }
/** 构建带 rows 节点的完整结果(用于 actual_signed_summary 等返回完整 Map 的报表)。 */
private Map<String, Object> buildResult(String reportType, QueryContext context, Map<String, Object> content) {
Map<String, Object> result = baseResult(reportType, context);
result.putAll(content);
String caliber = caliberFor(reportType);
if (caliber != null) {
result.put("caliber", caliber);
}
return result;
}
/** 返回指定报表的口径说明;无口径说明的报表返回 null不输出 caliber 节点)。 */
private String caliberFor(String reportType) {
return switch (reportType) {
case "sales_performance" -> "口径:签约(wonCount/wonAmount)指 stage='S5' 且 archived_at 落在 [startDate,endDate] 的商机,金额取 actual_signed_amount实际签约额非 amount 预估值);新商机(newCount/newAmount)按 created_at 归期取 amount丢单按 stage='L'。数据权限:仅统计当前可见范围。";
case "opportunity_funnel", "opportunity_trend" -> "口径:签约(won)按 stage='S5' 且 archived_at 落在区间内、金额取 actual_signed_amount丢单(lost)按 stage='L';进行中(inProgress)为 stage 非 S5、非 L 的商机;漏斗金额均按 amount。trend 按 time bucket 聚合区间内 [created_at] 的商机。数据权限:仅统计当前可见范围。";
default -> null;
};
}
private void requireReportPermission(String reportType) { private void requireReportPermission(String reportType) {
String permission = "dashboard_summary".equals(reportType) ? STATS_PERMISSION : ANALYTICS_PERMISSION; String permission = "dashboard_summary".equals(reportType) ? STATS_PERMISSION : ANALYTICS_PERMISSION;
if (!permissionService.hasPermi(permission)) { if (!permissionService.hasPermi(permission)) {
throw new BusinessException("无权查询该 CRM 报表"); throw new BusinessException("无权查询该 SCC 报表");
} }
} }
@ -222,10 +260,66 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider {
context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId())); context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId()));
row.put("revenueTier", llmMcpMapper.channelRevenueTier( row.put("revenueTier", llmMcpMapper.channelRevenueTier(
context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId())); context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId()));
row.put("contactWecomDistribution", llmMcpMapper.channelContactWecomDistribution(
context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId()));
rows.add(row); rows.add(row);
return sanitizeRows(rows); return sanitizeRows(rows);
} }
private Map<String, Object> signedSummary(QueryContext context) {
Map<String, Object> quality = llmMcpMapper.signedSummaryQuality(
context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId());
long signedCount = toLong(quality.get("signedCount"));
double signedAmount = toDouble(quality.get("signedAmount"));
double plannedAmount = toDouble(quality.get("plannedAmount"));
double realizationRate = plannedAmount > 0 ? Math.round(signedAmount / plannedAmount * 10000d) / 100d : 0d;
List<Map<String, Object>> rows = new ArrayList<>();
if ("none".equals(context.groupBy())) {
Map<String, Object> total = new LinkedHashMap<>();
total.put("group", "总计");
total.put("count", signedCount);
total.put("signedAmount", signedAmount);
total.put("plannedAmount", plannedAmount);
total.put("realizationRate", realizationRate);
rows.add(total);
} else {
List<Map<String, Object>> grouped = llmMcpMapper.signedSummaryRows(
context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId(), context.groupBy());
rows.addAll(sanitizeRows(grouped));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
result.put("rowCount", rows.size());
result.put("definition", "口径stage='S5' 且 archived_at 落在 [startDate, endDate] 的商机为已签约;签约金额取 actual_signed_amount实际签约金额而非 amount 预估值plannedAmount 为 amount 的计划额realizationRate = signedAmount/plannedAmount。groupBy 支持 none/month/owner/product/source/province。数据权限仅统计当前可见范围内的商机。");
result.put("dataQuality", quality);
result.put("handlingHint", signedCount > 0
? "数据完整可按上方汇总直接作答"
: "区间内无已签约(S5)商机,或确需统计签约时请核实 archived_at/actual_signed_amount 数据是否完整");
return result;
}
private long toLong(Object v) {
if (v == null) {
return 0L;
}
if (v instanceof Number n) {
return n.longValue();
}
return 0L;
}
private double toDouble(Object v) {
if (v == null) {
return 0d;
}
if (v instanceof Number n) {
return n.doubleValue();
}
return 0d;
}
private QueryContext buildContext(Map<String, Object> arguments) { private QueryContext buildContext(Map<String, Object> arguments) {
LocalDate endDate = getDate(arguments, "endDate"); LocalDate endDate = getDate(arguments, "endDate");
LocalDate startDate = getDate(arguments, "startDate"); LocalDate startDate = getDate(arguments, "startDate");

View File

@ -36,7 +36,7 @@ public class CrmTodoSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "分页检索 CRM 待办事项,可按人员、状态、优先级、业务类型、截止日期和关键词过滤。结果遵循系统数据权限。"; return "分页检索 SCC 待办事项,可按人员、状态、优先级、业务类型、截止日期和关键词筛选(最大跨度 366 天)。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。做待办统计用 crm_report_query(todo_summary)。结果遵循系统数据权限。";
} }
@Override @Override
@ -102,6 +102,7 @@ public class CrmTodoSearchToolProvider extends PermissionedMcpToolProvider {
result.put("total", total); result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total); result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", sanitizeRows(rows)); result.put("rows", sanitizeRows(rows));
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "work_todo"));
return result; return result;
} }
} }

View File

@ -38,7 +38,7 @@ public class CrmUniversalSearchToolProvider extends PermissionedMcpToolProvider
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "跨 CRM 模块搜索客户、商机、拓客、日报、打卡、跟进、待办和动态,查询结果遵循系统数据权限。"; return "跨 SCC 模块做快速关键词检索(客户、商机、拓客、日报、打卡、跟进、待办、动态),可用 module 限定范围,结果按时间聚合排序。本工具定位是【一次性定位线索】:当不确定记录在哪个模块时用它快速找到模块和 ID再配合各模块明细工具或 crm_entity_detail 查看详情。注意:本接口做多模块合并、返回条数有限且不带 total不要用它做全量统计或精确计数统计请用 crm_report_query结果遵循系统数据权限。";
} }
@Override @Override

View File

@ -37,7 +37,7 @@ public class CrmWorkTodayStatusToolProvider extends PermissionedMcpToolProvider
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "查询指定用户或当前用户某天的工作状态,包括日报提交、外勤打卡、待办数量。结果遵循系统数据权限。"; return "一次性查询指定用户(默认当前用户)在某天的日报是否已交、外勤打卡情况、待办数量等概览,适合快速判断某人某天的工作状态;不看明细。返回包含日报提交状态、打卡数、待办数。需要看具体明细再用 crm_work_report_search / crm_checkin_search / crm_todo_search。结果遵循系统数据权限。";
} }
@Override @Override

View File

@ -1,6 +1,7 @@
package com.unis.crm.llm.tools; package com.unis.crm.llm.tools;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.llm.mapper.LlmMcpMapper; import com.unis.crm.llm.mapper.LlmMcpMapper;
import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider; import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider;
import com.unisbase.security.SpringSecurityTenantProvider; import com.unisbase.security.SpringSecurityTenantProvider;
@ -31,15 +32,25 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索 CRM 商机。结果遵循系统数据权限。"; return "分页检索 SCC 商机明细,可按关键词、商机阶段、负责人筛选。返回 total 和 hasMore当结果超过单页条数时应按 page 递增翻页取全量,以免遗漏。字段含实际签约金额(actualSignedAmount)与签单归档时间(archivedAt)。注意商机与客户是两个实体:查询商机用本工具,查询客户用 crm_customer_search做商机汇总统计用 crm_report_query(opportunity_funnel),做实际签约统计用 crm_report_query(actual_signed_summary)。结果遵循系统数据权限。";
} }
@Override @Override
protected Map<String, Object> buildInputSchema() { protected Map<String, Object> buildInputSchema() {
Map<String, Object> properties = new LinkedHashMap<>(); Map<String, Object> properties = new LinkedHashMap<>();
properties.put("keyword", stringProperty("关键词,匹配商机名称、编号、客户名称、项目地、业绩归属地、产品类型。")); properties.put("keyword", stringProperty("关键词,匹配商机名称、编号、客户名称、项目地、业绩归属地、产品类型。"));
properties.put("stage", stringProperty("商机阶段编码,例如 initial_contact/solution_discussion/bidding/business_negotiation/won/lost。")); properties.put("stage", stringProperty("单阶段过滤。商机阶段编码,取值来自系统字典 sj_xmjd如 S0~S5、L禁止猜测英文值。请先用 crm_dict_options 查询 typeCode=sj_xmjd 拿到合法 item_value如 S0-配置报价、S5-已签单、L-已丢单),若传入非法值将直接报错。与 stageIn 二选一。"));
properties.put("ownerUserId", integerProperty("商机负责人用户 ID实际可见范围仍按系统数据权限裁剪。")); properties.put("stageIn", stringProperty("多阶段过滤:用英文逗号拼接多个合法阶段编码,如 'S3,S4,S5'。取值同样需来自字典 sj_xmjd非法阶段会直接报错。与 stage 二选一。"));
properties.put("archivedFrom", stringProperty("签单(归档)起始日期,格式 YYYY-MM-DD仅筛选 archived_at>=该日的已签单商机。"));
properties.put("archivedTo", stringProperty("签单(归档)截止日期,格式 YYYY-MM-DD仅筛选 archived_at<=该日的已签单商机。"));
properties.put("createdFrom", stringProperty("商机创建起始日期,格式 YYYY-MM-DD。"));
properties.put("createdTo", stringProperty("商机创建截止日期,格式 YYYY-MM-DD。"));
properties.put("minAmount", stringProperty("金额下限,仅返回 amount>=该值的商机。"));
properties.put("maxAmount", stringProperty("金额上限,仅返回 amount<=该值的商机。"));
properties.put("hasActualSignedAmount", booleanProperty("是否仅筛选已有实际签约金额actual_signed_amount 不为空)的商机。"));
properties.put("sort", enumStringProperty("排序方式。合法值:-actualSignedAmount(签约额降序)、actualSignedAmount(升序)、-amount(商机金额降序)、amount(升序)、-archivedAt(归档时间降序)、archivedAt(升序)、-createdAt(创建时间降序)、createdAt(升序);省略为默认按更新时间降序。排序可在不引入新字段的前提下让 Agent 直接取 Top N。", List.of("-actualSignedAmount", "actualSignedAmount", "-amount", "amount", "-archivedAt", "archivedAt", "-createdAt", "createdAt")));
properties.put("fields", stringProperty("字段投影:用英文逗号指定要返回的字段名,如 'id,opportunityName,stage,actualSignedAmount'。省略则返回全部字段(与默认一致),用于控制返回体大小。"));
properties.put("ownerUserId", integerProperty("商机负责人用户 ID可用 crm_org_user_search 查询用户的 ID。传入后实际可见范围仍按系统数据权限裁剪。"));
properties.put("includeArchived", booleanProperty("是否包含已签单商机,默认 false。")); properties.put("includeArchived", booleanProperty("是否包含已签单商机,默认 false。"));
properties.put("page", integerProperty("页码,默认 1。")); properties.put("page", integerProperty("页码,默认 1。"));
properties.put("pageSize", integerProperty("每页条数,默认 10最大 50。")); properties.put("pageSize", integerProperty("每页条数,默认 10最大 50。"));
@ -60,24 +71,61 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
int page = getPage(arguments); int page = getPage(arguments);
String keyword = getString(arguments, "keyword"); String keyword = getString(arguments, "keyword");
String stage = getString(arguments, "stage"); String stage = getString(arguments, "stage");
List<String> stageIn = splitCsv(getString(arguments, "stageIn"));
boolean includeArchived = getBoolean(arguments, "includeArchived", false); boolean includeArchived = getBoolean(arguments, "includeArchived", false);
java.time.LocalDate archivedFrom = getDate(arguments, "archivedFrom");
java.time.LocalDate archivedTo = getDate(arguments, "archivedTo");
java.time.LocalDate createdFrom = getDate(arguments, "createdFrom");
java.time.LocalDate createdTo = getDate(arguments, "createdTo");
Long minAmount = getLong(arguments, "minAmount");
Long maxAmount = getLong(arguments, "maxAmount");
Boolean hasActualSignedAmount = booleanOrNull(arguments, "hasActualSignedAmount");
String sort = getString(arguments, "sort");
String orderByClause = resolveOrderByClause(sort);
String fields = getString(arguments, "fields");
Long tenantId = tenantProvider.getCurrentTenantId(); Long tenantId = tenantProvider.getCurrentTenantId();
int offset = getOffset(page, pageSize); int offset = getOffset(page, pageSize);
if (stage != null && !stage.isEmpty()) {
validateStage(stage);
}
if (stageIn != null) {
for (String s : stageIn) {
validateStage(s);
}
}
long total = llmMcpMapper.countOpportunities( long total = llmMcpMapper.countOpportunities(
ownerUserId, ownerUserId,
tenantId, tenantId,
keyword, keyword,
stage, stage,
includeArchived); stageIn,
includeArchived,
archivedFrom,
archivedTo,
createdFrom,
createdTo,
minAmount,
maxAmount,
hasActualSignedAmount);
List<Map<String, Object>> rows = sanitizeRows(llmMcpMapper.searchOpportunities( List<Map<String, Object>> rows = sanitizeRows(llmMcpMapper.searchOpportunities(
ownerUserId, ownerUserId,
tenantId, tenantId,
keyword, keyword,
stage, stage,
stageIn,
includeArchived, includeArchived,
archivedFrom,
archivedTo,
createdFrom,
createdTo,
minAmount,
maxAmount,
hasActualSignedAmount,
orderByClause,
pageSize, pageSize,
offset)); offset));
rows = projectFields(rows, fields);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("ownerUserId", ownerUserId); result.put("ownerUserId", ownerUserId);
result.put("tenantId", tenantId); result.put("tenantId", tenantId);
@ -85,7 +133,105 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
result.put("pageSize", pageSize); result.put("pageSize", pageSize);
result.put("total", total); result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total); result.put("hasMore", (long) offset + pageSize < total);
result.put("sort", orderByClause);
result.put("rows", rows); result.put("rows", rows);
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "crm_opportunity"));
return result; return result;
} }
/** 将英文逗号分隔的字符串拆成列表;空/空白返回 null。 */
private List<String> splitCsv(String value) {
if (value == null || value.isBlank()) {
return null;
}
List<String> result = new java.util.ArrayList<>();
for (String part : value.split(",")) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) {
result.add(trimmed);
}
}
return result.isEmpty() ? null : result;
}
/** 读取可空布尔:未传返回 null不生效传了返回真实布尔值。 */
private Boolean booleanOrNull(Map<String, Object> arguments, String key) {
Object value = arguments == null ? null : arguments.get(key);
if (value instanceof Boolean booleanValue) {
return booleanValue;
}
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
return Boolean.parseBoolean(String.valueOf(value).trim());
}
/** 白名单映射 sort -> ORDER BY 子句;非法值返回 null走默认排序杜绝 SQL 注入。 */
private String resolveOrderByClause(String sort) {
if (sort == null || sort.isBlank()) {
return null;
}
switch (sort) {
case "-actualSignedAmount": return "o.actual_signed_amount desc nulls last, o.id desc";
case "actualSignedAmount": return "o.actual_signed_amount asc nulls last, o.id desc";
case "-amount": return "o.amount desc nulls last, o.id desc";
case "amount": return "o.amount asc nulls last, o.id desc";
case "-archivedAt": return "o.archived_at desc nulls last, o.id desc";
case "archivedAt": return "o.archived_at asc nulls last, o.id desc";
case "-createdAt": return "coalesce(o.created_at, o.updated_at) desc nulls last, o.id desc";
case "createdAt": return "coalesce(o.created_at, o.updated_at) asc nulls last, o.id desc";
default: return null;
}
}
/** 按 fields 白名单裁剪行字段fields 为 null/空则返回原行(默认全量,兼容原行为)。 */
private List<Map<String, Object>> projectFields(List<Map<String, Object>> rows, String fields) {
if (fields == null || rows == null || fields.isBlank()) {
return rows;
}
List<String> allowed = new java.util.ArrayList<>();
for (String f : fields.split(",")) {
String t = f.trim();
if (!t.isEmpty()) {
allowed.add(t);
}
}
if (allowed.isEmpty()) {
return rows;
}
List<Map<String, Object>> result = new java.util.ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> projected = new LinkedHashMap<>();
for (String key : allowed) {
if (row.containsKey(key)) {
projected.put(key, row.get(key));
}
}
result.add(projected);
}
return result;
}
private void validateStage(String stage) {
List<Map<String, Object>> options = llmMcpMapper.selectDictOptions("sj_xmjd", null, 500, 0);
if (options == null || options.isEmpty()) {
throw new BusinessException("商机阶段字典 sj_xmjd 无可用选项,无法校验 stage");
}
for (Map<String, Object> opt : options) {
Object value = opt.get("value");
Object label = opt.get("label");
if (value != null && String.valueOf(value).equals(stage)) {
return;
}
if (label != null && String.valueOf(label).equals(stage)) {
return;
}
}
// 拼出合法枚举提示
StringBuilder sb = new StringBuilder();
for (Map<String, Object> opt : options) {
sb.append(String.valueOf(opt.get("value"))).append('(').append(opt.get("label")).append(") ");
}
throw new BusinessException("非法的商机阶段编码:" + stage + "。合法取值:请用 crm_dict_options(typeCode=sj_xmjd) 查询,当前字典含:" + sb);
}
} }

View File

@ -35,7 +35,7 @@ public class UserProfileToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "读取当前登录用户的 CRM 画像,包括账号、姓名、手机号、角色、部门和当前租户。"; return "读取当前登录用户的 SCC 画像(账号、姓名、手机号、角色、部门、当前租户 ID 等),无参数。当你需要确认当前是谁、处于哪个角色/部门/租户,或需要把姓名与用户 ID 对应时先调用本工具。查询其它用户信息用 crm_org_user_search。";
} }
@Override @Override

View File

@ -35,7 +35,7 @@ public class WorkReportSearchToolProvider extends PermissionedMcpToolProvider {
@Override @Override
protected String getToolDescription() { protected String getToolDescription() {
return "检索 CRM 工作日报。结果遵循系统数据权限。"; return "分页检索 SCC 工作日报,可按用户、日期、关键词、状态筛选。默认返回最近 7 天,最大日期跨度 31 天。返回 total 和 hasMore结果超单页时应按 page 递增翻页取全量。做日报完成率统计用 crm_report_query(daily_report_completion)。结果遵循系统数据权限。";
} }
@Override @Override
@ -94,6 +94,7 @@ public class WorkReportSearchToolProvider extends PermissionedMcpToolProvider {
result.put("total", total); result.put("total", total);
result.put("hasMore", (long) offset + pageSize < total); result.put("hasMore", (long) offset + pageSize < total);
result.put("rows", rows); result.put("rows", rows);
result.put("fieldDescriptions", fieldComments(llmMcpMapper, "work_daily_report"));
return result; return result;
} }

View File

@ -0,0 +1,271 @@
package com.unis.crm.llm.tools.support;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* MCP
*
* MCP SQL actualSignedAmount COMMENT
* 线actual_signed_amount
* {@link PermissionedMcpToolProvider#fieldComments}
* fieldDescriptions COMMENT
*/
public final class FieldCommentRegistry {
private static final Map<String, Map<String, String>> TABLE_COLUMNS = new LinkedHashMap<>();
private FieldCommentRegistry() {
}
/** 返回某张表(或查询主体表)的「返回别名 → 物理列名」映射;未注册返回空映射。 */
public static Map<String, String> aliasToColumn(String tableName) {
return TABLE_COLUMNS.getOrDefault(tableName, Map.of());
}
/** 返回所有已注册的主表名。供 crm_field_comment 展示可用表。 */
public static Map<String, String> tables() {
Map<String, String> tableDesc = new LinkedHashMap<>();
tableDesc.put("crm_opportunity", "商机");
tableDesc.put("crm_customer", "客户");
tableDesc.put("crm_sales_expansion", "销售拓客");
tableDesc.put("crm_channel_expansion", "渠道拓客");
tableDesc.put("crm_crm_expansion", "CRM拓展");
tableDesc.put("work_daily_report", "工作日报");
tableDesc.put("work_checkin", "外勤打卡");
tableDesc.put("crm_opportunity_followup", "跟进记录");
return tableDesc;
}
static {
// ---------------- crm_opportunity 商机 ----------------
Map<String, String> opportunity = new LinkedHashMap<>();
opportunity.put("id", "id");
opportunity.put("opportunityCode", "opportunity_code");
opportunity.put("opportunityName", "opportunity_name");
opportunity.put("customerId", "customer_id");
opportunity.put("ownerUserId", "owner_user_id");
opportunity.put("preSalesName", "pre_sales_name");
opportunity.put("operatorName", "operator_name");
opportunity.put("projectLocation", "project_location");
opportunity.put("projectOwnershipLocation", "project_ownership_location");
opportunity.put("productType", "product_type");
opportunity.put("opportunityType", "opportunity_type");
opportunity.put("source", "source");
opportunity.put("isPoc", "is_poc");
opportunity.put("competitorName", "competitor_name");
opportunity.put("amount", "amount");
opportunity.put("expectedCloseDate", "expected_close_date");
opportunity.put("confidence", "confidence_pct");
opportunity.put("stage", "stage");
opportunity.put("status", "status");
opportunity.put("archived", "archived");
opportunity.put("latestProgress", "latest_progress");
opportunity.put("nextPlan", "next_plan");
opportunity.put("description", "description");
opportunity.put("actualSignedAmount", "actual_signed_amount");
opportunity.put("salesExpansionId", "sales_expansion_id");
opportunity.put("channelExpansionId", "channel_expansion_id");
opportunity.put("preSalesId", "pre_sales_id");
opportunity.put("updatedBy", "updated_by");
opportunity.put("pushedToOms", "pushed_to_oms");
opportunity.put("omsPushTime", "oms_push_time");
opportunity.put("archivedAt", "archived_at");
opportunity.put("createdAt", "created_at");
opportunity.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_opportunity", opportunity);
// ---------------- crm_customer 客户 ----------------
Map<String, String> customer = new LinkedHashMap<>();
customer.put("id", "id");
customer.put("customerCode", "customer_code");
customer.put("customerName", "customer_name");
customer.put("customerType", "customer_type");
customer.put("industry", "industry");
customer.put("province", "province");
customer.put("city", "city");
customer.put("address", "address");
customer.put("ownerUserId", "owner_user_id");
customer.put("source", "source");
customer.put("status", "status");
customer.put("remark", "remark");
customer.put("createdAt", "created_at");
customer.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_customer", customer);
// ---------------- crm_sales_expansion 销售拓客 ----------------
Map<String, String> salesExpansion = new LinkedHashMap<>();
salesExpansion.put("id", "id");
salesExpansion.put("employeeNo", "employee_no");
salesExpansion.put("candidateName", "candidate_name");
salesExpansion.put("officeName", "office_name");
salesExpansion.put("mobile", "mobile");
salesExpansion.put("email", "email");
salesExpansion.put("targetDept", "target_dept");
salesExpansion.put("industry", "industry");
salesExpansion.put("title", "title");
salesExpansion.put("intentLevel", "intent_level");
salesExpansion.put("stage", "stage");
salesExpansion.put("employmentStatus", "employment_status");
salesExpansion.put("expectedJoinDate", "expected_join_date");
salesExpansion.put("hasDesktopExp", "has_desktop_exp");
salesExpansion.put("ownerUserId", "owner_user_id");
salesExpansion.put("remark", "remark");
salesExpansion.put("inProgress", "in_progress");
salesExpansion.put("createdAt", "created_at");
salesExpansion.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_sales_expansion", salesExpansion);
// ---------------- crm_channel_expansion 渠道拓客 ----------------
Map<String, String> channelExpansion = new LinkedHashMap<>();
channelExpansion.put("id", "id");
channelExpansion.put("channelCode", "channel_code");
channelExpansion.put("province", "province");
channelExpansion.put("city", "city");
channelExpansion.put("channelName", "channel_name");
channelExpansion.put("officeAddress", "office_address");
channelExpansion.put("channelIndustry", "channel_industry");
channelExpansion.put("industry", "industry");
channelExpansion.put("certificationLevel", "certification_level");
channelExpansion.put("registeredCapital", "registered_capital");
channelExpansion.put("annualRevenue", "annual_revenue");
channelExpansion.put("staffSize", "staff_size");
channelExpansion.put("contactEstablishedDate", "contact_established_date");
channelExpansion.put("contactName", "contact_name");
channelExpansion.put("contactTitle", "contact_title");
channelExpansion.put("contactMobile", "contact_mobile");
channelExpansion.put("intentLevel", "intent_level");
channelExpansion.put("internalAttribute", "internal_attribute");
channelExpansion.put("hasDesktopExp", "has_desktop_exp");
channelExpansion.put("channelAttribute", "channel_attribute");
channelExpansion.put("stage", "stage");
channelExpansion.put("landedFlag", "landed_flag");
channelExpansion.put("expectedSignDate", "expected_sign_date");
channelExpansion.put("ownerUserId", "owner_user_id");
channelExpansion.put("remark", "remark");
channelExpansion.put("createdAt", "created_at");
channelExpansion.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_channel_expansion", channelExpansion);
// ---------------- crm_crm_expansion CRM 拓展 ----------------
Map<String, String> crmExpansion = new LinkedHashMap<>();
crmExpansion.put("id", "id");
crmExpansion.put("endUser", "end_user");
crmExpansion.put("officeName", "office_name");
crmExpansion.put("industryAttr", "industry_attr");
crmExpansion.put("extensionType", "extension_type");
crmExpansion.put("purchaseDate", "purchase_date");
crmExpansion.put("warrantyExpiry", "warranty_expiry");
crmExpansion.put("expansionTime", "expansion_time");
crmExpansion.put("expansionScale", "expansion_scale");
crmExpansion.put("softwarePoints", "software_points");
crmExpansion.put("onlineStatus", "online_status");
crmExpansion.put("contactName", "contact_name");
crmExpansion.put("contactPhone", "contact_phone");
crmExpansion.put("contactTitle", "contact_title");
crmExpansion.put("supplierName", "supplier_name");
crmExpansion.put("h3cContactName", "h3c_contact_name");
crmExpansion.put("supplierId", "supplier_id");
crmExpansion.put("h3cContactId", "h3c_contact_id");
crmExpansion.put("hasExpansionOpportunity", "has_expansion_opportunity");
crmExpansion.put("hasMaintenanceOpportunity", "has_maintenance_opportunity");
crmExpansion.put("ownerUserId", "owner_user_id");
crmExpansion.put("remark", "remark");
crmExpansion.put("createdAt", "created_at");
crmExpansion.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_crm_expansion", crmExpansion);
// ---------------- work_daily_report 工作日报 ----------------
Map<String, String> dailyReport = new LinkedHashMap<>();
dailyReport.put("id", "id");
dailyReport.put("userId", "user_id");
dailyReport.put("reportDate", "report_date");
dailyReport.put("workContent", "work_content");
dailyReport.put("tomorrowPlan", "tomorrow_plan");
dailyReport.put("sourceType", "source_type");
dailyReport.put("submitTime", "submit_time");
dailyReport.put("status", "status");
dailyReport.put("score", "score");
dailyReport.put("createdAt", "created_at");
dailyReport.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("work_daily_report", dailyReport);
// ---------------- work_checkin 外勤打卡 ----------------
Map<String, String> checkin = new LinkedHashMap<>();
checkin.put("id", "id");
checkin.put("userId", "user_id");
checkin.put("checkinDate", "checkin_date");
checkin.put("checkinTime", "checkin_time");
checkin.put("bizType", "biz_type");
checkin.put("bizId", "biz_id");
checkin.put("bizName", "biz_name");
checkin.put("longitude", "longitude");
checkin.put("latitude", "latitude");
checkin.put("locationText", "location_text");
checkin.put("remark", "remark");
checkin.put("status", "status");
checkin.put("createdAt", "created_at");
checkin.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("work_checkin", checkin);
// ---------------- work_todo 待办 ----------------
Map<String, String> todo = new LinkedHashMap<>();
todo.put("id", "id");
todo.put("userId", "user_id");
todo.put("title", "title");
todo.put("bizType", "biz_type");
todo.put("bizId", "biz_id");
todo.put("dueDate", "due_date");
todo.put("status", "status");
todo.put("priority", "priority");
todo.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("work_todo", todo);
// ---------------- crm_expansion_followup 跟进记录 ----------------
Map<String, String> followup = new LinkedHashMap<>();
followup.put("id", "id");
followup.put("bizType", "biz_type");
followup.put("bizId", "biz_id");
followup.put("followupTime", "followup_time");
followup.put("followupType", "followup_type");
followup.put("content", "content");
followup.put("nextAction", "next_action");
followup.put("followupUserId", "followup_user_id");
followup.put("visitStartTime", "visit_start_time");
followup.put("sourceType", "source_type");
followup.put("sourceId", "source_id");
followup.put("nextPlan", "next_plan");
followup.put("createdAt", "created_at");
followup.put("updatedAt", "updated_at");
TABLE_COLUMNS.put("crm_expansion_followup", followup);
// ---------------- crm_channel_expansion_coverage 渠道覆盖地市(从表) ----------------
Map<String, String> channelCoverage = new LinkedHashMap<>();
channelCoverage.put("id", "id");
channelCoverage.put("province", "province");
channelCoverage.put("city", "city");
TABLE_COLUMNS.put("crm_channel_expansion_coverage", channelCoverage);
// ---------------- crm_sales_expansion_coverage 销售所属区域(从表) ----------------
Map<String, String> salesCoverage = new LinkedHashMap<>();
salesCoverage.put("id", "id");
salesCoverage.put("province", "province");
salesCoverage.put("city", "city");
TABLE_COLUMNS.put("crm_sales_expansion_coverage", salesCoverage);
// ---------------- work_report_message 日报消息 ----------------
Map<String, String> reportMessage = new LinkedHashMap<>();
reportMessage.put("id", "id");
reportMessage.put("senderUserId", "sender_user_id");
reportMessage.put("receiverUserId", "receiver_user_id");
reportMessage.put("reportDate", "report_date");
reportMessage.put("lineIndex", "line_index");
reportMessage.put("bizType", "biz_type");
reportMessage.put("bizId", "biz_id");
reportMessage.put("bizName", "biz_name");
reportMessage.put("content", "content");
reportMessage.put("readAt", "read_at");
reportMessage.put("createdAt", "created_at");
TABLE_COLUMNS.put("work_report_message", reportMessage);
}
}

View File

@ -3,11 +3,13 @@ package com.unis.crm.llm.tools.support;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException; import com.unis.crm.common.BusinessException;
import com.unis.crm.llm.mapper.LlmMcpMapper;
import com.unisbase.llm.McpTool; import com.unisbase.llm.McpTool;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -45,7 +47,7 @@ public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools
throw new BusinessException("MCP 未获取到有效用户"); throw new BusinessException("MCP 未获取到有效用户");
} }
if (loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) { if (loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) {
throw new BusinessException("MCP 必须进入有效租户后才能查询 CRM 数据"); throw new BusinessException("MCP 必须进入有效租户后才能查询 SCC 数据");
} }
return loginUser; return loginUser;
} }
@ -164,6 +166,53 @@ public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools
return result; return result;
} }
/**
* COMMENT
* fieldDescriptionsaliasToColumn key value
* COMMENT
*/
protected Map<String, String> fieldComments(LlmMcpMapper mapper, String tableName, Map<String, String> aliasToColumn) {
Map<String, String> result = new LinkedHashMap<>();
if (mapper == null || tableName == null || aliasToColumn == null || aliasToColumn.isEmpty()) {
return result;
}
List<Map<String, Object>> commentRows = mapper.selectColumnComments(List.of(tableName));
Map<String, String> columnComment = new HashMap<>();
if (commentRows != null) {
for (Map<String, Object> row : commentRows) {
Object name = row.get("columnName");
Object comment = row.get("comment");
if (name != null && comment != null) {
columnComment.put(String.valueOf(name), String.valueOf(comment));
}
}
}
aliasToColumn.forEach((alias, column) -> {
String comment = columnComment.get(column);
if (comment != null && !comment.isBlank()) {
result.put(alias, comment);
}
});
return result;
}
/**
* {@link FieldCommentRegistry}
* fieldComments(mapper, "表名")
*/
protected Map<String, String> fieldComments(LlmMcpMapper mapper, String tableName) {
return fieldComments(mapper, tableName, FieldCommentRegistry.aliasToColumn(tableName));
}
/** 直接读取一个「表.物理列」清单的中文注释(供 crm_field_comment 主动查询用)。 */
protected List<Map<String, Object>> tableColumnComments(LlmMcpMapper mapper, List<String> tableNames) {
if (mapper == null) {
return List.of();
}
List<Map<String, Object>> rows = mapper.selectColumnComments(tableNames == null ? List.of() : tableNames);
return rows == null ? List.of() : rows;
}
protected String toJson(Object value) { protected String toJson(Object value) {
try { try {
return objectMapper.writeValueAsString(value); return objectMapper.writeValueAsString(value);

View File

@ -148,6 +148,8 @@ public interface OpportunityMapper {
@Param("opportunityId") Long opportunityId, @Param("opportunityId") Long opportunityId,
@Param("request") UpdateOpportunityIntegrationRequest request); @Param("request") UpdateOpportunityIntegrationRequest request);
int updateOpportunityStageByOmsReturn(@Param("opportunityId") Long opportunityId);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id") @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int markOpportunityOmsPushed( int markOpportunityOmsPushed(
@Param("userId") Long userId, @Param("userId") Long userId,

View File

@ -37,6 +37,15 @@ public interface OpportunityService {
Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request); Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request);
/**
* OMS 退 S4
*
* @param opportunityCode
* @param orderNo OMS 退
* @return id
*/
Long updateOpportunityStageByOmsReturn(String opportunityCode, String orderNo);
Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request); Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request);
Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request); Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request);

View File

@ -62,6 +62,9 @@ public class OpportunityServiceImpl implements OpportunityService {
private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL); private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL);
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
// 商机名称查重相似度阈值:达到该值视为疑似重复(>= 80%
private static final double DUPLICATE_SIMILARITY_THRESHOLD = 0.80;
// 代表“已签约”的状态码和阶段码,用于判断是否允许使用禁用阶段 // 代表“已签约”的状态码和阶段码,用于判断是否允许使用禁用阶段
private static final Set<String> WON_STAGE_CODES = Set.of("won", "S6"); private static final Set<String> WON_STAGE_CODES = Set.of("won", "S6");
@ -210,8 +213,96 @@ public class OpportunityServiceImpl implements OpportunityService {
return new OpportunityDuplicateCheckDTO(false, Collections.emptyList()); return new OpportunityDuplicateCheckDTO(false, Collections.emptyList());
} }
List<OpportunityDuplicateCheckDTO.Item> items = opportunityMapper.selectDuplicateOpportunities(name.trim(), excludeId, excludeStageCodes); List<OpportunityDuplicateCheckDTO.Item> candidates = opportunityMapper.selectDuplicateOpportunities(name.trim(), excludeId, excludeStageCodes);
return new OpportunityDuplicateCheckDTO(!items.isEmpty(), items); if (candidates.isEmpty()) {
return new OpportunityDuplicateCheckDTO(false, false, Collections.emptyList());
}
String target = normalizeOpportunityName(name);
List<OpportunityDuplicateCheckDTO.Item> matches = new ArrayList<>();
boolean exact = false;
boolean similar = false;
for (OpportunityDuplicateCheckDTO.Item candidate : candidates) {
String candidateName = normalizeOpportunityName(candidate.getOpportunityName());
if (candidateName.isEmpty()) {
continue;
}
if (target.equals(candidateName)) {
exact = true;
candidate.setSimilarity(1.0);
matches.add(candidate);
continue;
}
// 名称包含关系:一方包含另一方,视为疑似重复(禁止完全同名,但允许继续新增)
if (target.contains(candidateName) || candidateName.contains(target)) {
double containment = (double) Math.min(target.length(), candidateName.length())
/ Math.max(target.length(), candidateName.length());
candidate.setSimilarity(Math.max(containment, DUPLICATE_SIMILARITY_THRESHOLD));
matches.add(candidate);
similar = true;
continue;
}
double sim = nameSimilarity(target, candidateName);
if (sim >= DUPLICATE_SIMILARITY_THRESHOLD) {
candidate.setSimilarity(sim);
matches.add(candidate);
similar = true;
}
}
if (matches.isEmpty()) {
return new OpportunityDuplicateCheckDTO(false, false, Collections.emptyList());
}
matches.sort((a, b) -> Double.compare(b.getSimilarity(), a.getSimilarity()));
if (matches.size() > 20) {
matches = new ArrayList<>(matches.subList(0, 20));
}
return new OpportunityDuplicateCheckDTO(exact, similar, matches);
}
/**
* 1 - / 1.0
*/
private double nameSimilarity(String a, String b) {
if (a.isEmpty() || b.isEmpty()) {
return 0.0;
}
if (a.equals(b)) {
return 1.0;
}
int maxLen = Math.max(a.length(), b.length());
int distance = levenshteinDistance(a, b);
return 1.0 - (double) distance / maxLen;
}
private int levenshteinDistance(String a, String b) {
int[] prev = new int[b.length() + 1];
int[] curr = new int[b.length() + 1];
for (int j = 0; j <= b.length(); j++) {
prev[j] = j;
}
for (int i = 1; i <= a.length(); i++) {
curr[0] = i;
for (int j = 1; j <= b.length(); j++) {
int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
}
int[] tmp = prev;
prev = curr;
curr = tmp;
}
return prev[b.length()];
}
/**
* /
*/
private String normalizeOpportunityName(String value) {
if (value == null) {
return "";
}
return value.toUpperCase().replace(" ", "").replace(" ", "").trim();
} }
@Override @Override
@ -276,6 +367,25 @@ public class OpportunityServiceImpl implements OpportunityService {
return target.getId(); return target.getId();
} }
@Override
@Transactional
public Long updateOpportunityStageByOmsReturn(String opportunityCode, String orderNo) {
String code = trimToNull(opportunityCode);
if (code == null) {
throw new BusinessException("商机编号不能为空");
}
OpportunityIntegrationTargetDTO target = opportunityMapper.selectOpportunityIntegrationTarget(code);
if (target == null || target.getId() == null) {
throw new BusinessException("商机不存在");
}
int updated = opportunityMapper.updateOpportunityStageByOmsReturn(target.getId());
if (updated <= 0) {
throw new BusinessException("商机更新失败");
}
log.info("OMS 退单回调:商机[{}]:{} 项目阶段已更新为S4, 实际签约金额/签约信息已清空, 退单号: {}", code, target.getId(), orderNo);
return target.getId();
}
@Override @Override
public Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request) { public Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request) {
if (opportunityId == null || opportunityId <= 0) { if (opportunityId == null || opportunityId <= 0) {

View File

@ -55,6 +55,7 @@ unisbase:
- /actuator/health - /actuator/health
- /api/wecom/sso/** - /api/wecom/sso/**
- /api/opportunities/integration/** - /api/opportunities/integration/**
- /api/oms/callback/order-return
internal-auth: internal-auth:
enabled: true enabled: true
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77

View File

@ -57,6 +57,7 @@ unisbase:
- /actuator/health - /actuator/health
- /api/wecom/sso/** - /api/wecom/sso/**
- /api/opportunities/integration/** - /api/opportunities/integration/**
- /api/oms/callback/order-return
internal-auth: internal-auth:
enabled: true enabled: true
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
@ -84,7 +85,7 @@ unisbase:
access-token-safety-seconds: 120 access-token-safety-seconds: 120
oms: oms:
enabled: ${OMS_ENABLED:true} enabled: ${OMS_ENABLED:true}
base-url: ${OMS_BASE_URL:http://192.168.2.158:28080} base-url: ${OMS_BASE_URL:http://192.168.2.34:28080}
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54} api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
api-key-header: ${OMS_API_KEY_HEADER:apiKey} api-key-header: ${OMS_API_KEY_HEADER:apiKey}
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info} user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}

View File

@ -7,8 +7,12 @@
<select id="selectUserProfile" resultType="java.util.LinkedHashMap"> <select id="selectUserProfile" resultType="java.util.LinkedHashMap">
select select
u.user_id as "userId", u.user_id as "userId",
cast(null as varchar) as "userCode",
coalesce(nullif(btrim(u.username), ''), '') as "username", coalesce(nullif(btrim(u.username), ''), '') as "username",
cast(null as varchar) as "realName",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "displayName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "displayName",
cast(null as varchar) as "jobTitle",
cast(null as varchar) as "hireDate",
coalesce(nullif(btrim(u.email), ''), '') as "email", coalesce(nullif(btrim(u.email), ''), '') as "email",
coalesce(nullif(btrim(u.phone), ''), '') as "phone", coalesce(nullif(btrim(u.phone), ''), '') as "phone",
coalesce(u.status, 1) as "status", coalesce(u.status, 1) as "status",
@ -59,7 +63,9 @@
left(coalesce(r.work_content, ''), 1000) as "workContent", left(coalesce(r.work_content, ''), 1000) as "workContent",
left(coalesce(r.tomorrow_plan, ''), 1000) as "tomorrowPlan", left(coalesce(r.tomorrow_plan, ''), 1000) as "tomorrowPlan",
coalesce(rc.score, r.score) as "score", coalesce(rc.score, r.score) as "score",
left(coalesce(rc.comment_content, ''), 500) as "comment" left(coalesce(rc.comment_content, ''), 500) as "comment",
to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from work_daily_report r from work_daily_report r
left join sys_user u on u.user_id = r.user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = r.user_id and coalesce(u.is_deleted, 0) = 0
left join ( left join (
@ -134,21 +140,38 @@
o.id as "id", o.id as "id",
coalesce(o.opportunity_code, '') as "opportunityCode", coalesce(o.opportunity_code, '') as "opportunityCode",
coalesce(o.opportunity_name, '') as "opportunityName", coalesce(o.opportunity_name, '') as "opportunityName",
coalesce(c.customer_name, '') as "customerName", o.customer_id as "customerId",
o.sales_expansion_id as "salesExpansionId",
o.channel_expansion_id as "channelExpansionId",
o.pre_sales_id as "preSalesId",
o.owner_user_id as "ownerUserId", o.owner_user_id as "ownerUserId",
o.updated_by as "updatedBy",
coalesce(c.customer_name, '') as "customerName",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
coalesce(o.pre_sales_name, '') as "preSalesName",
coalesce(o.operator_name, '') as "operatorName",
coalesce(o.project_location, '') as "projectLocation", coalesce(o.project_location, '') as "projectLocation",
coalesce(o.project_ownership_location, '') as "projectOwnershipLocation", coalesce(o.project_ownership_location, '') as "projectOwnershipLocation",
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as "projectOwnershipLocationName", coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as "projectOwnershipLocationName",
coalesce(o.product_type, '') as "productType", coalesce(o.product_type, '') as "productType",
coalesce(o.opportunity_type, '') as "opportunityType",
coalesce(o.source, '') as "source", coalesce(o.source, '') as "source",
coalesce(o.is_poc, false) as "isPoc",
coalesce(o.competitor_name, '') as "competitorName",
o.amount as "amount", o.amount as "amount",
to_char(o.expected_close_date, 'YYYY-MM-DD') as "expectedCloseDate", to_char(o.expected_close_date, 'YYYY-MM-DD') as "expectedCloseDate",
coalesce(o.confidence_pct, '') as "confidence", coalesce(o.confidence_pct, '') as "confidence",
coalesce(o.stage, '') as "stage", coalesce(o.stage, '') as "stage",
coalesce(o.status, '') as "status", coalesce(o.status, '') as "status",
coalesce(o.archived, false) as "archived", coalesce(o.archived, false) as "archived",
left(coalesce(o.latest_progress, ''), 800) as "latestProgress",
left(coalesce(o.next_plan, ''), 800) as "nextPlan",
left(coalesce(o.description, ''), 800) as "description", left(coalesce(o.description, ''), 800) as "description",
o.actual_signed_amount as "actualSignedAmount",
coalesce(o.pushed_to_oms, false) as "pushedToOms",
to_char(o.oms_push_time, 'YYYY-MM-DD HH24:MI') as "omsPushTime",
to_char(o.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(o.archived_at, 'YYYY-MM-DD HH24:MI') as "archivedAt",
to_char(coalesce(o.updated_at, o.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt" to_char(coalesce(o.updated_at, o.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_opportunity o from crm_opportunity o
left join crm_customer c on c.id = o.customer_id left join crm_customer c on c.id = o.customer_id
@ -186,7 +209,23 @@
<if test="stage != null and stage != ''"> <if test="stage != null and stage != ''">
and o.stage = #{stage} and o.stage = #{stage}
</if> </if>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc <if test="stageIn != null and stageIn.size() > 0">
and o.stage in
<foreach collection="stageIn" item="st" open="(" separator="," close=")">#{st}</foreach>
</if>
<if test="archivedFrom != null">and o.archived_at::date &gt;= #{archivedFrom}</if>
<if test="archivedTo != null">and o.archived_at::date &lt;= #{archivedTo}</if>
<if test="createdFrom != null">and o.created_at::date &gt;= #{createdFrom}</if>
<if test="createdTo != null">and o.created_at::date &lt;= #{createdTo}</if>
<if test="minAmount != null">and o.amount &gt;= #{minAmount}</if>
<if test="maxAmount != null">and o.amount &lt;= #{maxAmount}</if>
<if test="hasActualSignedAmount != null and hasActualSignedAmount">
and o.actual_signed_amount is not null
</if>
<choose>
<when test="orderByClause != null and orderByClause != ''">order by ${orderByClause}</when>
<otherwise>order by coalesce(o.updated_at, o.created_at) desc, o.id desc</otherwise>
</choose>
limit #{limit} limit #{limit}
offset #{offset} offset #{offset}
</select> </select>
@ -229,6 +268,19 @@
<if test="stage != null and stage != ''"> <if test="stage != null and stage != ''">
and o.stage = #{stage} and o.stage = #{stage}
</if> </if>
<if test="stageIn != null and stageIn.size() > 0">
and o.stage in
<foreach collection="stageIn" item="st" open="(" separator="," close=")">#{st}</foreach>
</if>
<if test="archivedFrom != null">and o.archived_at::date &gt;= #{archivedFrom}</if>
<if test="archivedTo != null">and o.archived_at::date &lt;= #{archivedTo}</if>
<if test="createdFrom != null">and o.created_at::date &gt;= #{createdFrom}</if>
<if test="createdTo != null">and o.created_at::date &lt;= #{createdTo}</if>
<if test="minAmount != null">and o.amount &gt;= #{minAmount}</if>
<if test="maxAmount != null">and o.amount &lt;= #{maxAmount}</if>
<if test="hasActualSignedAmount != null and hasActualSignedAmount">
and o.actual_signed_amount is not null
</if>
</select> </select>
<select id="universalSearch" resultType="java.util.LinkedHashMap"> <select id="universalSearch" resultType="java.util.LinkedHashMap">
@ -802,10 +854,11 @@
</select> </select>
<select id="dashboardWonOpportunityMetric" resultType="java.util.LinkedHashMap"> <select id="dashboardWonOpportunityMetric" resultType="java.util.LinkedHashMap">
select '赢单商机数' as "metric", count(distinct o.id)::bigint as "value", coalesce(sum(o.amount), 0) as "amount" select '赢单商机数' as "metric", count(distinct o.id)::bigint as "value", coalesce(sum(coalesce(o.actual_signed_amount, 0)), 0) as "amount"
from crm_opportunity o from crm_opportunity o
where coalesce(o.status, '') = 'won' where o.stage = 'S5'
and coalesce(o.updated_at, o.created_at)::date between #{startDate} and #{endDate} and o.archived_at is not null
and o.archived_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if> <if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = o.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if> <if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = o.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
</select> </select>
@ -880,13 +933,21 @@
select select
o.owner_user_id, o.owner_user_id,
count(1)::bigint as opportunity_count, count(1)::bigint as opportunity_count,
coalesce(sum(amount), 0) as opportunity_amount, coalesce(sum(amount), 0) as opportunity_amount
count(case when coalesce(status, '') = 'won' then 1 end)::bigint as won_count,
coalesce(sum(case when coalesce(status, '') = 'won' then amount else 0 end), 0) as won_amount
from crm_opportunity o from crm_opportunity o
where o.created_at::date between #{startDate} and #{endDate} where o.created_at::date between #{startDate} and #{endDate}
group by o.owner_user_id group by o.owner_user_id
), ),
signed_stats as (
select
o.owner_user_id,
count(distinct o.id)::bigint as won_count,
coalesce(sum(coalesce(o.actual_signed_amount, 0)), 0) as won_amount
from crm_opportunity o
where o.stage = 'S5'
and o.archived_at::date between #{startDate} and #{endDate}
group by o.owner_user_id
),
followup_stats as ( followup_stats as (
select f.followup_user_id as user_id, count(1)::bigint as followup_count select f.followup_user_id as user_id, count(1)::bigint as followup_count
from crm_opportunity_followup f from crm_opportunity_followup f
@ -913,14 +974,15 @@
coalesce(c.customer_count, 0) as "customerCount", coalesce(c.customer_count, 0) as "customerCount",
coalesce(o.opportunity_count, 0) as "opportunityCount", coalesce(o.opportunity_count, 0) as "opportunityCount",
coalesce(o.opportunity_amount, 0) as "opportunityAmount", coalesce(o.opportunity_amount, 0) as "opportunityAmount",
coalesce(o.won_count, 0) as "wonCount", coalesce(s.won_count, 0) as "wonCount",
coalesce(o.won_amount, 0) as "wonAmount", coalesce(s.won_amount, 0) as "wonAmount",
coalesce(f.followup_count, 0) as "opportunityFollowupCount", coalesce(f.followup_count, 0) as "opportunityFollowupCount",
coalesce(r.report_count, 0) as "dailyReportCount", coalesce(r.report_count, 0) as "dailyReportCount",
coalesce(ck.checkin_count, 0) as "checkinCount" coalesce(ck.checkin_count, 0) as "checkinCount"
from users_scope u from users_scope u
left join customer_stats c on c.owner_user_id = u.user_id left join customer_stats c on c.owner_user_id = u.user_id
left join opportunity_stats o on o.owner_user_id = u.user_id left join opportunity_stats o on o.owner_user_id = u.user_id
left join signed_stats s on s.owner_user_id = u.user_id
left join followup_stats f on f.user_id = u.user_id left join followup_stats f on f.user_id = u.user_id
left join report_stats r on r.user_id = u.user_id left join report_stats r on r.user_id = u.user_id
left join checkin_stats ck on ck.user_id = u.user_id left join checkin_stats ck on ck.user_id = u.user_id
@ -934,8 +996,8 @@
coalesce(o.stage, 'unknown') as "stage", coalesce(o.stage, 'unknown') as "stage",
count(1)::bigint as "count", count(1)::bigint as "count",
coalesce(sum(o.amount), 0) as "amount", coalesce(sum(o.amount), 0) as "amount",
count(case when coalesce(o.status, '') = 'won' then 1 end)::bigint as "wonCount", count(case when coalesce(o.stage, '') = 'S5' then 1 end)::bigint as "wonCount",
count(case when coalesce(o.status, '') = 'lost' then 1 end)::bigint as "lostCount" count(case when coalesce(o.stage, '') = 'L' then 1 end)::bigint as "lostCount"
from crm_opportunity o from crm_opportunity o
where o.created_at::date between #{startDate} and #{endDate} where o.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if> <if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
@ -950,8 +1012,8 @@
select select
to_char(date_trunc('${bucket}', o.created_at), 'YYYY-MM-DD') as "period", to_char(date_trunc('${bucket}', o.created_at), 'YYYY-MM-DD') as "period",
count(1)::bigint as "newCount", count(1)::bigint as "newCount",
count(case when coalesce(o.status, '') = 'won' then 1 end)::bigint as "wonCount", count(case when coalesce(o.stage, '') = 'S5' then 1 end)::bigint as "wonCount",
count(case when coalesce(o.status, '') = 'lost' then 1 end)::bigint as "lostCount", count(case when coalesce(o.stage, '') = 'L' then 1 end)::bigint as "lostCount",
coalesce(sum(o.amount), 0) as "amount" coalesce(sum(o.amount), 0) as "amount"
from crm_opportunity o from crm_opportunity o
where o.created_at::date between #{startDate} and #{endDate} where o.created_at::date between #{startDate} and #{endDate}
@ -1254,8 +1316,12 @@
<select id="searchOrgUsers" resultType="java.util.LinkedHashMap"> <select id="searchOrgUsers" resultType="java.util.LinkedHashMap">
select select
u.user_id as "userId", u.user_id as "userId",
cast(null as varchar) as "userCode",
u.username as "username", u.username as "username",
cast(null as varchar) as "realName",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "displayName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "displayName",
cast(null as varchar) as "jobTitle",
cast(null as varchar) as "hireDate",
coalesce(u.phone, '') as "mobile", coalesce(u.phone, '') as "mobile",
coalesce(u.email, '') as "email", coalesce(u.email, '') as "email",
coalesce(u.status, 1) as "status", coalesce(u.status, 1) as "status",
@ -1372,11 +1438,13 @@
coalesce(c.industry, '') as "industry", coalesce(c.industry, '') as "industry",
coalesce(c.province, '') as "province", coalesce(c.province, '') as "province",
coalesce(c.city, '') as "city", coalesce(c.city, '') as "city",
coalesce(c.address, '') as "address",
coalesce(c.source, '') as "source", coalesce(c.source, '') as "source",
coalesce(c.status, '') as "status", coalesce(c.status, '') as "status",
c.owner_user_id as "ownerUserId", c.owner_user_id as "ownerUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
left(coalesce(c.remark, ''), 800) as "remark", left(coalesce(c.remark, ''), 800) as "remark",
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(coalesce(c.updated_at, c.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt" to_char(coalesce(c.updated_at, c.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_customer c from crm_customer c
left join sys_user u on u.user_id = c.owner_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = c.owner_user_id and coalesce(u.is_deleted, 0) = 0
@ -1437,9 +1505,13 @@
coalesce(c.biz_type, '') as "bizType", coalesce(c.biz_type, '') as "bizType",
c.biz_id as "bizId", c.biz_id as "bizId",
coalesce(c.biz_name, '') as "bizName", coalesce(c.biz_name, '') as "bizName",
coalesce(c.longitude::text, '') as "longitude",
coalesce(c.latitude::text, '') as "latitude",
coalesce(c.location_text, '') as "locationText", coalesce(c.location_text, '') as "locationText",
coalesce(c.remark, '') as "remark", coalesce(c.remark, '') as "remark",
coalesce(c.status, 'normal') as "status" coalesce(c.status, 'normal') as "status",
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from work_checkin c from work_checkin c
left join sys_user u on u.user_id = c.user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = c.user_id and coalesce(u.is_deleted, 0) = 0
where 1 = 1 where 1 = 1
@ -1571,12 +1643,30 @@
'sales' as "expansionType", 'sales' as "expansionType",
s.id as "id", s.id as "id",
coalesce(s.candidate_name, '') as "name", coalesce(s.candidate_name, '') as "name",
coalesce(s.employee_no, '') as "employeeNo",
coalesce(s.candidate_name, '') as "candidateName",
coalesce(s.office_name, '') as "officeName",
coalesce(s.mobile, '') as "mobile",
coalesce(s.email, '') as "email",
coalesce(s.target_dept, '') as "targetDept",
coalesce(s.industry, '') as "industry",
coalesce(s.title, '') as "title",
coalesce(s.employment_status, '') as "employmentStatus",
to_char(s.expected_join_date, 'YYYY-MM-DD') as "expectedJoinDate",
coalesce(s.has_desktop_exp, false) as "hasDesktopExp",
concat_ws(' | ', s.employee_no, s.mobile, s.target_dept, s.industry, s.title, s.intent_level, s.stage, left(coalesce(s.remark, ''), 500)) as "summary", concat_ws(' | ', s.employee_no, s.mobile, s.target_dept, s.industry, s.title, s.intent_level, s.stage, left(coalesce(s.remark, ''), 500)) as "summary",
s.stage as "stage", s.stage as "stage",
s.intent_level as "intentLevel", s.intent_level as "intentLevel",
s.owner_user_id as "ownerUserId", s.owner_user_id as "ownerUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
left(coalesce(s.remark, ''), 800) as "remark",
coalesce((
select string_agg(case when coalesce(gov.city, '') = '' then gov.province else gov.province || '-' || gov.city end, '、')
from (select distinct cov.province, cov.city from crm_sales_expansion_coverage cov where cov.sales_expansion_id = s.id) gov
), '') as "coverageSummary",
coalesce(s.in_progress, false) as "inProgress",
coalesce(s.updated_at, s.created_at) as "sortTime", coalesce(s.updated_at, s.created_at) as "sortTime",
to_char(s.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(coalesce(s.updated_at, s.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt" to_char(coalesce(s.updated_at, s.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_sales_expansion s from crm_sales_expansion s
left join sys_user u on u.user_id = s.owner_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = s.owner_user_id and coalesce(u.is_deleted, 0) = 0
@ -1629,12 +1719,38 @@
'channel' as "expansionType", 'channel' as "expansionType",
c.id as "id", c.id as "id",
coalesce(c.channel_name, '') as "name", coalesce(c.channel_name, '') as "name",
coalesce(c.channel_code, '') as "channelCode",
coalesce(c.channel_name, '') as "channelName",
coalesce(c.province, '') as "province",
coalesce(c.city, '') as "city",
coalesce(c.office_address, '') as "officeAddress",
coalesce(c.channel_industry, '') as "channelIndustry",
coalesce(c.industry, '') as "industry",
coalesce(c.certification_level, '') as "certificationLevel",
c.registered_capital as "registeredCapital",
c.annual_revenue as "annualRevenue",
c.staff_size as "staffSize",
to_char(c.contact_established_date, 'YYYY-MM-DD') as "contactEstablishedDate",
coalesce(c.contact_name, '') as "contactName",
coalesce(c.contact_title, '') as "contactTitle",
coalesce(c.contact_mobile, '') as "contactMobile",
coalesce(c.channel_attribute, '') as "channelAttribute",
coalesce(c.internal_attribute, '') as "internalAttribute",
coalesce(c.has_desktop_exp, false) as "hasDesktopExp",
to_char(c.expected_sign_date, 'YYYY-MM-DD') as "expectedSignDate",
coalesce(c.landed_flag, false) as "landedFlag",
concat_ws(' | ', c.channel_code, c.province, c.city, c.channel_industry, c.certification_level, c.contact_name, c.contact_mobile, c.intent_level, c.stage, left(coalesce(c.remark, ''), 500)) as "summary", concat_ws(' | ', c.channel_code, c.province, c.city, c.channel_industry, c.certification_level, c.contact_name, c.contact_mobile, c.intent_level, c.stage, left(coalesce(c.remark, ''), 500)) as "summary",
c.stage as "stage", c.stage as "stage",
c.intent_level as "intentLevel", c.intent_level as "intentLevel",
c.owner_user_id as "ownerUserId", c.owner_user_id as "ownerUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
left(coalesce(c.remark, ''), 800) as "remark",
coalesce((
select string_agg(case when coalesce(gov.city, '') = '' then gov.province else gov.province || '-' || gov.city end, '、')
from (select distinct cov.province, cov.city from crm_channel_expansion_coverage cov where cov.channel_id = c.id) gov
), '') as "coverageSummary",
coalesce(c.updated_at, c.created_at) as "sortTime", coalesce(c.updated_at, c.created_at) as "sortTime",
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(coalesce(c.updated_at, c.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt" to_char(coalesce(c.updated_at, c.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_channel_expansion c from crm_channel_expansion c
left join sys_user u on u.user_id = c.owner_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = c.owner_user_id and coalesce(u.is_deleted, 0) = 0
@ -1686,6 +1802,80 @@
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if> <if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
</select> </select>
<select id="searchCrmExpansions" resultType="java.util.LinkedHashMap">
select
ce.id as "id",
coalesce(ce.end_user, '') as "endUser",
coalesce(ce.office_name, '') as "officeName",
coalesce(ce.industry_attr, '') as "industryAttr",
coalesce(ce.extension_type, '') as "extensionType",
to_char(ce.purchase_date, 'YYYY-MM-DD') as "purchaseDate",
to_char(ce.warranty_expiry, 'YYYY-MM-DD') as "warrantyExpiry",
to_char(ce.expansion_time, 'YYYY-MM-DD') as "expansionTime",
coalesce(ce.expansion_scale, '') as "expansionScale",
ce.software_points as "softwarePoints",
coalesce(ce.online_status, '') as "onlineStatus",
coalesce(ce.contact_name, '') as "contactName",
coalesce(ce.contact_phone, '') as "contactPhone",
coalesce(ce.contact_title, '') as "contactTitle",
coalesce(ce.supplier_name, '') as "supplierName",
coalesce(ce.h3c_contact_name, '') as "h3cContactName",
ce.supplier_id as "supplierId",
ce.h3c_contact_id as "h3cContactId",
coalesce(ce.has_expansion_opportunity, '') as "hasExpansionOpportunity",
coalesce(ce.has_maintenance_opportunity, '') as "hasMaintenanceOpportunity",
ce.owner_user_id as "ownerUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
left(coalesce(ce.remark, ''), 800) as "remark",
coalesce(ce.updated_at, ce.created_at) as "sortTime",
to_char(ce.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(coalesce(ce.updated_at, ce.created_at), 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_crm_expansion ce
left join sys_user u on u.user_id = ce.owner_user_id and coalesce(u.is_deleted, 0) = 0
where 1 = 1
<if test="ownerUserId != null">and ce.owner_user_id = #{ownerUserId}</if>
<if test="startDate != null">and ce.created_at::date &gt;= #{startDate}</if>
<if test="endDate != null">and ce.created_at::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(ce.end_user, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.office_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.extension_type, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.contact_phone, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.supplier_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.h3c_contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = ce.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by coalesce(ce.updated_at, ce.created_at) desc nulls last, ce.id desc
limit #{limit}
offset #{offset}
</select>
<select id="countCrmExpansions" resultType="long">
select count(*)
from crm_crm_expansion ce
where 1 = 1
<if test="ownerUserId != null">and ce.owner_user_id = #{ownerUserId}</if>
<if test="startDate != null">and ce.created_at::date &gt;= #{startDate}</if>
<if test="endDate != null">and ce.created_at::date &lt;= #{endDate}</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(ce.end_user, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.office_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.extension_type, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.contact_phone, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.supplier_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.h3c_contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(ce.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = ce.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
</select>
<select id="searchFollowups" resultType="java.util.LinkedHashMap"> <select id="searchFollowups" resultType="java.util.LinkedHashMap">
with followup_rows as ( with followup_rows as (
select select
@ -1699,6 +1889,11 @@
f.followup_user_id as "userId", f.followup_user_id as "userId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName",
f.followup_time as "followupTime", f.followup_time as "followupTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
null::timestamptz as "visitStartTime",
f.created_at as "createdAt",
f.updated_at as "updatedAt",
'OPPORTUNITY' as "scopeResourceType", 'OPPORTUNITY' as "scopeResourceType",
o.owner_user_id as "scopeOwnerUserId", o.owner_user_id as "scopeOwnerUserId",
o.project_ownership_location as "scopeAreaCode", o.project_ownership_location as "scopeAreaCode",
@ -1733,6 +1928,11 @@
f.followup_user_id as "userId", f.followup_user_id as "userId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName",
f.followup_time as "followupTime", f.followup_time as "followupTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
f.visit_start_time as "visitStartTime",
f.created_at as "createdAt",
f.updated_at as "updatedAt",
'EXPANSION' as "scopeResourceType", 'EXPANSION' as "scopeResourceType",
case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId", case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId",
null::varchar as "scopeAreaCode", null::varchar as "scopeAreaCode",
@ -1767,7 +1967,12 @@
"nextAction", "nextAction",
"userId", "userId",
"userName", "userName",
to_char("followupTime", 'YYYY-MM-DD HH24:MI') as "followupTime" "sourceType",
"sourceId",
to_char("visitStartTime", 'YYYY-MM-DD HH24:MI') as "visitStartTime",
to_char("followupTime", 'YYYY-MM-DD HH24:MI') as "followupTime",
to_char("createdAt", 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char("updatedAt", 'YYYY-MM-DD HH24:MI') as "updatedAt"
from followup_rows from followup_rows
order by "followupTime" desc nulls last, "id" desc order by "followupTime" desc nulls last, "id" desc
limit #{limit} limit #{limit}
@ -1786,6 +1991,11 @@
left(coalesce(f.next_action, ''), 300) as "nextAction", left(coalesce(f.next_action, ''), 300) as "nextAction",
f.followup_user_id as "userId", f.followup_user_id as "userId",
f.followup_time as "followupTime", f.followup_time as "followupTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
null::timestamptz as "visitStartTime",
f.created_at as "createdAt",
f.updated_at as "updatedAt",
'OPPORTUNITY' as "scopeResourceType", 'OPPORTUNITY' as "scopeResourceType",
o.owner_user_id as "scopeOwnerUserId", o.owner_user_id as "scopeOwnerUserId",
o.project_ownership_location as "scopeAreaCode", o.project_ownership_location as "scopeAreaCode",
@ -1818,6 +2028,11 @@
left(coalesce(f.next_action, f.next_plan, ''), 300) as "nextAction", left(coalesce(f.next_action, f.next_plan, ''), 300) as "nextAction",
f.followup_user_id as "userId", f.followup_user_id as "userId",
f.followup_time as "followupTime", f.followup_time as "followupTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
f.visit_start_time as "visitStartTime",
f.created_at as "createdAt",
f.updated_at as "updatedAt",
'EXPANSION' as "scopeResourceType", 'EXPANSION' as "scopeResourceType",
case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId", case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId",
null::varchar as "scopeAreaCode", null::varchar as "scopeAreaCode",
@ -1939,6 +2154,17 @@
limit 1 limit 1
</select> </select>
<select id="selectCrmExpansionDetail" resultType="java.util.LinkedHashMap">
select
ce.*,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName"
from crm_crm_expansion ce
left join sys_user u on u.user_id = ce.owner_user_id and coalesce(u.is_deleted, 0) = 0
where ce.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = ce.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
limit 1
</select>
<select id="selectTodoDetail" resultType="java.util.LinkedHashMap"> <select id="selectTodoDetail" resultType="java.util.LinkedHashMap">
select select
t.*, t.*,
@ -1977,7 +2203,11 @@
f.next_action as "nextAction", f.next_action as "nextAction",
f.followup_user_id as "followupUserId", f.followup_user_id as "followupUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName",
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime" to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
to_char(f.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(f.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_opportunity o from crm_opportunity o
join crm_opportunity_followup f on f.opportunity_id = o.id join crm_opportunity_followup f on f.opportunity_id = o.id
left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0
@ -1994,7 +2224,8 @@
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "reviewerName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "reviewerName",
c.score as "score", c.score as "score",
c.comment_content as "commentContent", c.comment_content as "commentContent",
to_char(c.reviewed_at, 'YYYY-MM-DD HH24:MI') as "reviewedAt" to_char(c.reviewed_at, 'YYYY-MM-DD HH24:MI') as "reviewedAt",
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt"
from work_daily_report r from work_daily_report r
join work_daily_report_comment c on c.report_id = r.id join work_daily_report_comment c on c.report_id = r.id
left join sys_user u on u.user_id = c.reviewer_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = c.reviewer_user_id and coalesce(u.is_deleted, 0) = 0
@ -2004,6 +2235,32 @@
limit #{limit} limit #{limit}
</select> </select>
<!-- 日报消息work_report_message日报行级往来消息biz_type 支持 sales/channel/opportunity/crm -->
<select id="selectWorkReportMessages" resultType="java.util.LinkedHashMap">
select
m.id as "id",
m.sender_user_id as "senderUserId",
coalesce(nullif(btrim(su.display_name), ''), nullif(btrim(su.username), ''), '') as "senderName",
m.receiver_user_id as "receiverUserId",
coalesce(nullif(btrim(ru.display_name), ''), nullif(btrim(ru.username), ''), '') as "receiverName",
to_char(m.report_date, 'YYYY-MM-DD') as "reportDate",
m.line_index as "lineIndex",
m.biz_type as "bizType",
m.biz_id as "bizId",
coalesce(m.biz_name, '') as "bizName",
m.content as "content",
to_char(m.read_at, 'YYYY-MM-DD HH24:MI') as "readAt",
to_char(m.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt"
from work_daily_report r
join work_report_message m on m.report_id = r.id
left join sys_user su on su.user_id = m.sender_user_id and coalesce(su.is_deleted, 0) = 0
left join sys_user ru on ru.user_id = m.receiver_user_id and coalesce(ru.is_deleted, 0) = 0
where r.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = r.user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by m.created_at desc nulls last, m.id desc
limit #{limit}
</select>
<select id="selectSalesExpansionFollowups" resultType="java.util.LinkedHashMap"> <select id="selectSalesExpansionFollowups" resultType="java.util.LinkedHashMap">
select select
f.id as "id", f.id as "id",
@ -2014,7 +2271,12 @@
f.next_plan as "nextPlan", f.next_plan as "nextPlan",
f.followup_user_id as "followupUserId", f.followup_user_id as "followupUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName",
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime" to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime",
to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI') as "visitStartTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
to_char(f.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(f.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_sales_expansion s from crm_sales_expansion s
join crm_expansion_followup f on f.biz_type = 'sales' and f.biz_id = s.id join crm_expansion_followup f on f.biz_type = 'sales' and f.biz_id = s.id
left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0
@ -2043,10 +2305,17 @@
<select id="selectChannelExpansionContacts" resultType="java.util.LinkedHashMap"> <select id="selectChannelExpansionContacts" resultType="java.util.LinkedHashMap">
select select
cc.id as "id", cc.id as "id",
cc.channel_expansion_id as "channelExpansionId",
cc.contact_name as "contactName", cc.contact_name as "contactName",
cc.contact_mobile as "contactMobile", cc.contact_mobile as "contactMobile",
cc.contact_title as "contactTitle", cc.contact_title as "contactTitle",
cc.sort_order as "sortOrder" cc.duty as "duty",
to_char(cc.birthday, 'YYYY-MM-DD') as "birthday",
cc.wecom_added as "wecomAdded",
cc.special_note as "specialNote",
cc.sort_order as "sortOrder",
to_char(cc.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(cc.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_channel_expansion c from crm_channel_expansion c
join crm_channel_expansion_contact cc on cc.channel_expansion_id = c.id join crm_channel_expansion_contact cc on cc.channel_expansion_id = c.id
where c.id = #{id} where c.id = #{id}
@ -2055,6 +2324,36 @@
limit #{limit} limit #{limit}
</select> </select>
<!-- 销售人员拓展所属区域明细(从表 crm_sales_expansion_coverage每行一个省/市) -->
<select id="selectSalesExpansionCoverages" resultType="java.util.LinkedHashMap">
select
cov.id as "id",
cov.province as "province",
cov.city as "city",
case when coalesce(cov.city, '') = '' then '全省' else '城市' end as "levelDesc"
from crm_sales_expansion s
join crm_sales_expansion_coverage cov on cov.sales_expansion_id = s.id
where s.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = s.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by cov.id asc
limit #{limit}
</select>
<!-- 渠道拓展覆盖地市明细(从表 crm_channel_expansion_coverage每行一个覆盖项 -->
<select id="selectChannelExpansionCoverages" resultType="java.util.LinkedHashMap">
select
cov.id as "id",
cov.province as "province",
cov.city as "city",
case when coalesce(cov.city, '') = '' then '全省' else '城市' end as "levelDesc"
from crm_channel_expansion c
join crm_channel_expansion_coverage cov on cov.channel_id = c.id
where c.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by cov.id asc
limit #{limit}
</select>
<select id="selectChannelExpansionFollowups" resultType="java.util.LinkedHashMap"> <select id="selectChannelExpansionFollowups" resultType="java.util.LinkedHashMap">
select select
f.id as "id", f.id as "id",
@ -2065,7 +2364,12 @@
f.next_plan as "nextPlan", f.next_plan as "nextPlan",
f.followup_user_id as "followupUserId", f.followup_user_id as "followupUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName",
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime" to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime",
to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI') as "visitStartTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
to_char(f.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(f.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_channel_expansion c from crm_channel_expansion c
join crm_expansion_followup f on f.biz_type = 'channel' and f.biz_id = c.id join crm_expansion_followup f on f.biz_type = 'channel' and f.biz_id = c.id
left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0 left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0
@ -2075,6 +2379,49 @@
limit #{limit} limit #{limit}
</select> </select>
<select id="selectCrmExpansionContacts" resultType="java.util.LinkedHashMap">
select
cc.id as "id",
cc.crm_expansion_id as "crmExpansionId",
cc.contact_name as "contactName",
cc.contact_mobile as "contactMobile",
cc.contact_title as "contactTitle",
cc.sort_order as "sortOrder",
to_char(cc.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(cc.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_crm_expansion ce
join crm_crm_expansion_contact cc on cc.crm_expansion_id = ce.id
where ce.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = ce.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by cc.sort_order asc nulls last, cc.id asc
limit #{limit}
</select>
<select id="selectCrmExpansionFollowups" resultType="java.util.LinkedHashMap">
select
f.id as "id",
f.followup_type as "followupType",
f.content as "content",
f.next_action as "nextAction",
f.evaluation_content as "evaluationContent",
f.next_plan as "nextPlan",
f.followup_user_id as "followupUserId",
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "followupUserName",
to_char(f.followup_time, 'YYYY-MM-DD HH24:MI') as "followupTime",
to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI') as "visitStartTime",
f.source_type as "sourceType",
f.source_id as "sourceId",
to_char(f.created_at, 'YYYY-MM-DD HH24:MI') as "createdAt",
to_char(f.updated_at, 'YYYY-MM-DD HH24:MI') as "updatedAt"
from crm_crm_expansion ce
join crm_expansion_followup f on f.biz_type = 'crm' and f.biz_id = ce.id
left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0
where ce.id = #{id}
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = ce.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
order by f.followup_time desc nulls last, f.id desc
limit #{limit}
</select>
<select id="selectChannelExpansionOpportunities" resultType="java.util.LinkedHashMap"> <select id="selectChannelExpansionOpportunities" resultType="java.util.LinkedHashMap">
select select
o.id as "id", o.id as "id",
@ -2137,6 +2484,102 @@
offset #{offset} offset #{offset}
</select> </select>
<!-- 读取数据库列注释(读数据库 COMMENT)。查询系统元数据,无业务数据,不做数据范围过滤。 -->
<select id="selectColumnComments" resultType="java.util.LinkedHashMap">
select
c.relname as "tableName",
a.attname as "columnName",
col_description(a.attrelid, a.attnum) as "comment"
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = current_schema()
and a.attnum &gt; 0
and not a.attisdropped
and c.relkind = 'r'
<if test="tableNames != null and tableNames.size() > 0">
and c.relname in
<foreach collection="tableNames" item="tbl" open="(" separator="," close=")">#{tbl}</foreach>
</if>
order by c.relname asc, a.attnum asc
</select>
<!-- 实际签约统计(A):按归档口径 stage='S5' AND archived_at∈区间,金额用 actual_signed_amount。全量无分页。 -->
<select id="signedSummaryRows" resultType="java.util.LinkedHashMap">
select
<choose>
<when test="groupBy == 'owner'">
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '未知') as "group",
</when>
<when test="groupBy == 'product'">coalesce(nullif(o.product_type, ''), '未知') as "group",</when>
<when test="groupBy == 'source'">coalesce(nullif(o.source, ''), '未知') as "group",</when>
<when test="groupBy == 'province'">coalesce(nullif(c.province, ''), '未知') as "group",</when>
<otherwise>to_char(date_trunc('month', o.archived_at), 'YYYY-MM') as "group",</otherwise>
</choose>
count(distinct o.id)::bigint as "count",
coalesce(sum(coalesce(o.actual_signed_amount, 0)), 0) as "signedAmount",
coalesce(sum(coalesce(o.amount, 0)), 0) as "plannedAmount",
round(
coalesce(sum(coalesce(o.actual_signed_amount, 0)), 0)::numeric * 100
/ nullif(coalesce(sum(coalesce(o.amount, 0)), 0), 0), 2) as "realizationRate"
from crm_opportunity o
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
left join crm_customer c on c.id = o.customer_id
where o.stage = 'S5'
and o.archived_at is not null
and o.archived_at::date &gt;= #{startDate}
and o.archived_at::date &lt;= #{endDate}
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">
and exists (select 1 from sys_tenant_user tu where tu.user_id = o.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)
</if>
group by
<choose>
<when test="groupBy == 'owner'">coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '未知')</when>
<when test="groupBy == 'product'">coalesce(nullif(o.product_type, ''), '未知')</when>
<when test="groupBy == 'source'">coalesce(nullif(o.source, ''), '未知')</when>
<when test="groupBy == 'province'">coalesce(nullif(c.province, ''), '未知')</when>
<otherwise>to_char(date_trunc('month', o.archived_at), 'YYYY-MM')</otherwise>
</choose>
order by "signedAmount" desc, "group" asc
</select>
<!-- 实际签约统计(A):总计与数据质量。全量统计。 -->
<select id="signedSummaryQuality" resultType="java.util.LinkedHashMap">
select
count(distinct o.id)::bigint as "signedCount",
coalesce(sum(coalesce(o.actual_signed_amount, 0)), 0) as "signedAmount",
coalesce(sum(coalesce(o.amount, 0)), 0) as "plannedAmount",
coalesce(max(s5_all.count), 0)::bigint as "s5TotalCount",
coalesce(max(s5_all.signed_count), 0)::bigint as "s5SignedCount",
coalesce(max(s5_all.missing_archived), 0)::bigint as "s5MissingArchivedAt",
coalesce(max(s5_all.missing_amount), 0)::bigint as "s5MissingSignedAmount"
from crm_opportunity o
left join (
select
count(1)::bigint as count,
count(archived_at)::bigint as signed_count,
count(*) filter (where archived_at is null)::bigint as missing_archived,
count(*) filter (where actual_signed_amount is null)::bigint as missing_amount
from crm_opportunity s5
where s5.stage = 'S5'
and s5.archived_at::date &gt;= #{startDate}
and s5.archived_at::date &lt;= #{endDate}
<if test="ownerUserId != null">and s5.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">
and exists (select 1 from sys_tenant_user tu where tu.user_id = s5.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)
</if>
) s5_all on true
where o.stage = 'S5'
and o.archived_at is not null
and o.archived_at::date &gt;= #{startDate}
and o.archived_at::date &lt;= #{endDate}
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">
and exists (select 1 from sys_tenant_user tu where tu.user_id = o.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)
</if>
</select>
<!-- 渠道统计分析:按认证级别(tz_rzjb字典)分组,全量无分页 --> <!-- 渠道统计分析:按认证级别(tz_rzjb字典)分组,全量无分页 -->
<select id="channelTierDistribution" resultType="java.util.LinkedHashMap"> <select id="channelTierDistribution" resultType="java.util.LinkedHashMap">
select select
@ -2219,4 +2662,24 @@
order by "channelCount" desc order by "channelCount" desc
</select> </select>
<!-- 渠道统计分析:按「是否加企业微信」(wecom_added, sys_is1是/0否)分组统计渠道联系人,全量无分页 -->
<select id="channelContactWecomDistribution" resultType="java.util.LinkedHashMap">
select
case coalesce(cc.wecom_added, '')
when '1' then '已加企业微信'
when '0' then '未加企业微信'
else '未填写'
end as "wecomStatus",
coalesce(cc.wecom_added, '') as "wecomCode",
count(cc.id)::bigint as "contactCount",
count(distinct c.id)::bigint as "channelCount"
from crm_channel_expansion_contact cc
join crm_channel_expansion c on c.id = cc.channel_expansion_id
where c.created_at::date between #{startDate} and #{endDate}
<if test="ownerUserId != null">and c.owner_user_id = #{ownerUserId}</if>
<if test="tenantId != null">and exists (select 1 from sys_tenant_user tu where tu.user_id = c.owner_user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0)</if>
group by coalesce(cc.wecom_added, '')
order by "contactCount" desc, "wecomStatus" asc
</select>
</mapper> </mapper>

View File

@ -534,18 +534,17 @@
o.opportunity_code as opportunityCode, o.opportunity_code as opportunityCode,
o.opportunity_name as opportunityName, o.opportunity_name as opportunityName,
coalesce(c.customer_name, '') as customerName, coalesce(c.customer_name, '') as customerName,
coalesce(stage_dict.item_label, o.stage, '') as stage coalesce(stage_dict.item_label, o.stage, '') as stage,
coalesce(u.display_name, u.username, '') as createdByName
from crm_opportunity o from crm_opportunity o
left join crm_customer c on c.id = o.customer_id left join crm_customer c on c.id = o.customer_id
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
left join sys_dict_item stage_dict left join sys_dict_item stage_dict
on stage_dict.type_code = 'sj_xmjd' on stage_dict.type_code = 'sj_xmjd'
and stage_dict.item_value = o.stage and stage_dict.item_value = o.stage
and stage_dict.status = 1 and stage_dict.status = 1
and coalesce(stage_dict.is_deleted, 0) = 0 and coalesce(stage_dict.is_deleted, 0) = 0
where coalesce(o.archived, false) = false where o.opportunity_name &lt;&gt; ''
and o.opportunity_name &lt;&gt; ''
and replace(replace(upper(o.opportunity_name), ' ', ''), ' ', '')
= replace(replace(upper(#{name}), ' ', ''), ' ', '')
<if test="excludeId != null"> <if test="excludeId != null">
and o.id != #{excludeId} and o.id != #{excludeId}
</if> </if>
@ -557,7 +556,6 @@
) )
</if> </if>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc order by coalesce(o.updated_at, o.created_at) desc, o.id desc
limit 20
</select> </select>
<insert id="insertCustomer"> <insert id="insertCustomer">
@ -883,6 +881,17 @@
where o.id = #{opportunityId} where o.id = #{opportunityId}
</update> </update>
<update id="updateOpportunityStageByOmsReturn">
update crm_opportunity o
set stage = 'S4',
status = 'active',
actual_signed_amount = null,
archived = false,
archived_at = null,
updated_at = now()
where o.id = #{opportunityId}
</update>
<update id="markOpportunityOmsPushed"> <update id="markOpportunityOmsPushed">
update crm_opportunity o update crm_opportunity o
set pushed_to_oms = true, set pushed_to_oms = true,

View File

@ -0,0 +1,133 @@
package com.unis.crm.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.unis.crm.common.CrmGlobalExceptionHandler;
import com.unis.crm.config.InternalAuthProperties;
import com.unis.crm.service.CrmOmsDictMappingService;
import com.unis.crm.service.OpportunityService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
class OmsCallbackControllerWebMvcTest {
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
private static final String INTERNAL_SECRET_VALUE = "test-internal-secret";
private static final String RETURN_URL = "/api/oms/callback/order-return";
private MockMvc mockMvc;
private OpportunityService opportunityService;
@BeforeEach
void setUp() {
opportunityService = mock(OpportunityService.class);
CrmOmsDictMappingService dictMappingService = mock(CrmOmsDictMappingService.class);
InternalAuthProperties internalAuthProperties = new InternalAuthProperties();
internalAuthProperties.setEnabled(true);
internalAuthProperties.setHeaderName(INTERNAL_SECRET_HEADER);
internalAuthProperties.setSecret(INTERNAL_SECRET_VALUE);
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.afterPropertiesSet();
mockMvc = MockMvcBuilders.standaloneSetup(
new OmsCallbackController(dictMappingService, opportunityService, internalAuthProperties))
.setControllerAdvice(new CrmGlobalExceptionHandler())
.setValidator(validator)
.build();
}
@Test
void orderReturnShouldReturnUnauthorizedWhenSecretIsMissing() throws Exception {
mockMvc.perform(post(RETURN_URL)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opportunityCode\":\"OPP-20260916-001\",\"orderNo\":\"OMS-0001\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("-1"))
.andExpect(jsonPath("$.msg").value("内部接口鉴权失败"));
verifyNoInteractions(opportunityService);
}
@Test
void orderReturnShouldReturnUnauthorizedWhenSecretIsInvalid() throws Exception {
mockMvc.perform(post(RETURN_URL)
.header(INTERNAL_SECRET_HEADER, "bad-secret")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opportunityCode\":\"OPP-20260916-001\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("-1"))
.andExpect(jsonPath("$.msg").value("内部接口鉴权失败"));
verifyNoInteractions(opportunityService);
}
@Test
void orderReturnShouldDelegateToServiceWhenSecretIsValid() throws Exception {
when(opportunityService.updateOpportunityStageByOmsReturn(eq("OPP-20260916-001"), eq("OMS-0001")))
.thenReturn(10L);
mockMvc.perform(post(RETURN_URL)
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opportunityCode\":\"OPP-20260916-001\",\"orderNo\":\"OMS-0001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("0"))
.andExpect(jsonPath("$.data").value(10L));
verify(opportunityService).updateOpportunityStageByOmsReturn("OPP-20260916-001", "OMS-0001");
}
@Test
void orderReturnShouldAllowMissingOrderNo() throws Exception {
when(opportunityService.updateOpportunityStageByOmsReturn(eq("OPP-20260916-001"), eq(null)))
.thenReturn(10L);
mockMvc.perform(post(RETURN_URL)
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opportunityCode\":\"OPP-20260916-001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("0"))
.andExpect(jsonPath("$.data").value(10L));
verify(opportunityService).updateOpportunityStageByOmsReturn("OPP-20260916-001", null);
}
@Test
void orderReturnShouldRejectBlankOpportunityCode() throws Exception {
mockMvc.perform(post(RETURN_URL)
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opportunityCode\":\" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("-1"));
verifyNoInteractions(opportunityService);
}
@Test
void orderReturnShouldRejectEmptyPayload() throws Exception {
mockMvc.perform(post(RETURN_URL)
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("-1"));
verifyNoInteractions(opportunityService);
}
}

View File

@ -205,6 +205,7 @@ class McpDataPermissionInterceptorTest {
"selectUserOrgs", "selectUserOrgs",
"selectDictTypes", "selectDictTypes",
"selectDictOptions", "selectDictOptions",
"selectColumnComments",
"searchOrganizations", "searchOrganizations",
"searchRoles"); "searchRoles");
Set<String> protectedMethods = McpDataPermissionInterceptor.policyMethodNames(); Set<String> protectedMethods = McpDataPermissionInterceptor.policyMethodNames();
@ -244,6 +245,59 @@ class McpDataPermissionInterceptorTest {
assertTrue(rewritten.contains("u.user_id IN (11, 12, 13, 14)")); assertTrue(rewritten.contains("u.user_id IN (11, 12, 13, 14)"));
} }
@Test
void relatedDetailQueriesShouldScopeToTargetEntityOwnerInsteadOfParentOnly() throws Exception {
// P0-4跨实体关联明细若只按主实体 owner 过滤,会形成越权旁路。
// 校验相关明细查询经重写后owner 条件引用的是目标实体的 owner 列。
Configuration configuration = new Configuration();
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
}
DataVisibility visibility = new DataVisibility(false, List.of(9L), List.of());
// 客户下商机、拓客下商机:目标实体均为 crm_opportunity别名 o
// 权限必须落在 o.owner_user_id 而非父实体的 owner。
for (String methodName : List.of(
"selectCustomerOpportunities",
"selectSalesExpansionOpportunities",
"selectChannelExpansionOpportunities")) {
BoundSql boundSql = configuration.getMappedStatement(
"com.unis.crm.llm.mapper.LlmMcpMapper." + methodName)
.getBoundSql(allParameters());
String sql = boundSql.getSql();
String rewritten = interceptor.rewriteSql(sql, interceptor.buildVisibilityCondition(
"searchOpportunities", visibility, loginUser));
assertTrue(rewritten.toLowerCase().contains("o.owner_user_id"),
methodName + " 必须按商机 owner 过滤,防止越权");
}
}
@Test
void noUserOrientedQueryShouldProjectSensitiveColumns() throws Exception {
// P0-3用户/组织相关查询必须显式列且不得投影敏感字段,
// 防止经 MCP 泄漏密码、盐、token、密钥等。
Set<String> sensitiveTokens = Set.of("password", "passwd", "pwd", "salt", "token", "secret", "api_key", "apikey");
Configuration configuration = new Configuration();
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
}
String[] methods = {"selectUserProfile", "searchOrgUsers", "searchOrganizations", "searchRoles"};
for (String methodName : methods) {
BoundSql boundSql = configuration.getMappedStatement(
"com.unis.crm.llm.mapper.LlmMcpMapper." + methodName)
.getBoundSql(allParameters());
String sql = boundSql.getSql().toLowerCase();
String selectClause = sql.substring(sql.indexOf("select") + "select".length(), sql.indexOf("from"));
for (String token : sensitiveTokens) {
assertTrue(!selectClause.contains(token),
methodName + " Select 不应包含敏感字段: " + token);
}
}
}
private Map<String, Object> allParameters() { private Map<String, Object> allParameters() {
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("startDate", LocalDate.of(2026, 7, 1)); parameters.put("startDate", LocalDate.of(2026, 7, 1));

View File

@ -1,7 +1,9 @@
package com.unis.crm.service.impl; package com.unis.crm.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@ -16,6 +18,7 @@ import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO; import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO; import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO; import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
@ -227,6 +230,69 @@ class OpportunityServiceImplTest {
&& Boolean.TRUE.equals(normalized.getArchived()))); && Boolean.TRUE.equals(normalized.getArchived())));
} }
@Test
void updateOpportunityStageByOmsReturn_shouldUpdateTargetToS4() {
OpportunityIntegrationTargetDTO target = new OpportunityIntegrationTargetDTO();
target.setId(10L);
target.setOpportunityCode("V001");
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(target);
when(opportunityMapper.updateOpportunityStageByOmsReturn(10L)).thenReturn(1);
Long result = opportunityService.updateOpportunityStageByOmsReturn("V001", "OMS-0001");
assertEquals(10L, result);
verify(opportunityMapper).updateOpportunityStageByOmsReturn(10L);
}
@Test
void updateOpportunityStageByOmsReturn_shouldTrimCodeAndIgnoreBlankOrderNo() {
OpportunityIntegrationTargetDTO target = new OpportunityIntegrationTargetDTO();
target.setId(10L);
target.setOpportunityCode(" V001 ");
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(target);
when(opportunityMapper.updateOpportunityStageByOmsReturn(10L)).thenReturn(1);
Long result = opportunityService.updateOpportunityStageByOmsReturn(" V001 ", null);
assertEquals(10L, result);
verify(opportunityMapper).selectOpportunityIntegrationTarget("V001");
verify(opportunityMapper).updateOpportunityStageByOmsReturn(10L);
}
@Test
void updateOpportunityStageByOmsReturn_shouldRejectBlankCode() {
BusinessException exception = assertThrows(BusinessException.class,
() -> opportunityService.updateOpportunityStageByOmsReturn(" ", "OMS-0001"));
assertEquals("商机编号不能为空", exception.getMessage());
verify(opportunityMapper, never()).updateOpportunityStageByOmsReturn(any(Long.class));
}
@Test
void updateOpportunityStageByOmsReturn_shouldRejectMissingTarget() {
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(null);
BusinessException exception = assertThrows(BusinessException.class,
() -> opportunityService.updateOpportunityStageByOmsReturn("V001", "OMS-0001"));
assertEquals("商机不存在", exception.getMessage());
verify(opportunityMapper, never()).updateOpportunityStageByOmsReturn(any(Long.class));
}
@Test
void updateOpportunityStageByOmsReturn_shouldThrowWhenUpdateAffectsNoRow() {
OpportunityIntegrationTargetDTO target = new OpportunityIntegrationTargetDTO();
target.setId(10L);
target.setOpportunityCode("V001");
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(target);
when(opportunityMapper.updateOpportunityStageByOmsReturn(10L)).thenReturn(0);
BusinessException exception = assertThrows(BusinessException.class,
() -> opportunityService.updateOpportunityStageByOmsReturn("V001", "OMS-0001"));
assertEquals("商机更新失败", exception.getMessage());
}
@Test @Test
void createOpportunity_shouldPersistEditableSnapshotFields() { void createOpportunity_shouldPersistEditableSnapshotFields() {
when(permissionService.hasPermi("opportunity:create")).thenReturn(true); when(permissionService.hasPermi("opportunity:create")).thenReturn(true);
@ -297,6 +363,114 @@ class OpportunityServiceImplTest {
assertEquals(1, overview.getItems().size()); assertEquals(1, overview.getItems().size());
} }
@Test
void checkDuplicate_shouldReturnFalseForBlankName() {
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, " ", null, null);
assertFalse(result.isDuplicate());
assertFalse(result.isExactDuplicate());
assertFalse(result.isSimilarDuplicate());
}
@Test
void checkDuplicate_shouldAllowWhenNoCandidates() {
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of());
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "新商机", null, null);
assertFalse(result.isDuplicate());
assertTrue(result.getItems().isEmpty());
}
@Test
void checkDuplicate_shouldBlockExactNameIgnoringCaseAndSpaces() {
OpportunityDuplicateCheckDTO.Item existing = item(10L, " ABC云计算平台 ");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(existing));
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "abc云计算平台", null, null);
assertTrue(result.isDuplicate());
assertTrue(result.isExactDuplicate());
assertFalse(result.isSimilarDuplicate());
assertEquals(1, result.getItems().size());
assertEquals(1.0, result.getItems().get(0).getSimilarity(), 0.0001);
}
@Test
void checkDuplicate_shouldWarnWhenSimilarityAboveThreshold() {
OpportunityDuplicateCheckDTO.Item existing = item(10L, "华为云项目A");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(existing));
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "华为云项目B", null, null);
assertTrue(result.isDuplicate());
assertFalse(result.isExactDuplicate());
assertTrue(result.isSimilarDuplicate());
assertEquals(1, result.getItems().size());
assertTrue(result.getItems().get(0).getSimilarity() >= 0.80);
}
@Test
void checkDuplicate_shouldIgnoreLowSimilarity() {
OpportunityDuplicateCheckDTO.Item existing = item(10L, "软件开发服务");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(existing));
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "物联网平台建设", null, null);
assertFalse(result.isDuplicate());
assertFalse(result.isExactDuplicate());
assertFalse(result.isSimilarDuplicate());
assertTrue(result.getItems().isEmpty());
}
@Test
void checkDuplicate_shouldProcessArchivedCandidates() {
// 已归档/已签单商机同样作为查重候选(是否包含归档由 SQL 控制,服务层不做过滤)
OpportunityDuplicateCheckDTO.Item archived = item(10L, "已签单大单");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(archived));
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "已签单大单", null, null);
assertTrue(result.isDuplicate());
assertTrue(result.isExactDuplicate());
assertEquals(1, result.getItems().size());
}
@Test
void checkDuplicate_shouldFlagWhenNameIsContainedInAnother() {
OpportunityDuplicateCheckDTO.Item existing = item(10L, "华为云项目");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(existing));
OpportunityDuplicateCheckDTO contained = opportunityService.checkDuplicateOpportunity(1L, "华为云", null, null);
assertTrue(contained.isDuplicate());
assertFalse(contained.isExactDuplicate());
assertTrue(contained.isSimilarDuplicate());
assertEquals(1, contained.getItems().size());
assertTrue(contained.getItems().get(0).getSimilarity() >= 0.80);
OpportunityDuplicateCheckDTO container = opportunityService.checkDuplicateOpportunity(1L, "华为云项目综合改造", null, null);
assertTrue(container.isSimilarDuplicate());
}
@Test
void checkDuplicate_shouldKeepCreatedByOnMatchedItem() {
OpportunityDuplicateCheckDTO.Item existing = item(10L, "华为云项目A");
existing.setCreatedByName("张三");
when(opportunityMapper.selectDuplicateOpportunities(any(), any(), any())).thenReturn(List.of(existing));
OpportunityDuplicateCheckDTO result = opportunityService.checkDuplicateOpportunity(1L, "华为云项目A", null, null);
assertEquals("张三", result.getItems().get(0).getCreatedByName());
}
private OpportunityDuplicateCheckDTO.Item item(Long id, String name) {
OpportunityDuplicateCheckDTO.Item item = new OpportunityDuplicateCheckDTO.Item();
item.setOpportunityId(id);
item.setOpportunityName(name);
return item;
}
private OpportunityCustomerSnapshotDTO customerSnapshot(Long customerId, String customerName) { private OpportunityCustomerSnapshotDTO customerSnapshot(Long customerId, String customerName) {
OpportunityCustomerSnapshotDTO dto = new OpportunityCustomerSnapshotDTO(); OpportunityCustomerSnapshotDTO dto = new OpportunityCustomerSnapshotDTO();
dto.setCustomerId(customerId); dto.setCustomerId(customerId);

View File

@ -0,0 +1,120 @@
# MCP 接口审计报告与整改建议
> 审计日期2026-09-16
> 审计范围:`backend/src/main/java/com/unis/crm/llm/` 及 `backend/src/main/resources/mapper/llm/LlmMcpMapper.xml`
> 约束:**本次审计不修改任何现有逻辑**,仅只读核验,输出缺失项与可整改项清单。
> 基线:对照 `~/Downloads/scc_mcp_improvement_proposal.md` 的 A-G 改造项及 6 条验收用例。
---
## 一、工具清单(当前暴露给 Agent 的 MCP 工具)
| 工具名 | 作用 | 备注 |
|---|---|---|
| `crm_opportunity_search` | 商机明细列表 | 分页 + total/hasMore |
| `crm_customer_search` | 客户明细列表 | 分页 + total/hasMore |
| `crm_work_report_search` | 日报列表 | 分页 + total/hasMore |
| `crm_checkin_search` | 外勤打卡列表 | 分页 + total/hasMore |
| `crm_todo_search` | 待办列表 | 分页 + total/hasMore |
| `crm_followup_search` | 跟进记录列表 | 分页 + total/hasMore |
| `crm_expansion_search` | 拓客(销售/渠道)列表 | 分页 + total/hasMore |
| `crm_universal_search` | 跨模块通用搜索 | 无 total定位用 |
| `crm_entity_detail` | 单对象详情 | 仅单 id |
| `crm_dict_options` | 字典选项查询 | 补齐工作职责等 |
| `crm_metadata_catalog` | 元数据目录 | 硬编码 |
| `crm_report_query` | 报表查询 | 多 reportType 聚合 |
| `crm_report_catalog` | 报表目录 | |
| `crm_org_user_search` | 组织/用户查询 | 姓名→ID 映射 |
| `crm_user_profile` | 当前用户资料 | |
**说明**:以上所有列表工具均已统一 `total`+`hasMore` 分页字段(既有汇总已落地)。
---
## 二、已满足项(核验通过)
| 项 | 状态 | 证据 |
|---|---|---|
| A `actual_signed_summary` 实际签约聚合 | ✅ 完整 | `CrmReportQueryToolProvider` L233-266`stage='S5'`+`archived_at∈区间`+`actual_signed_amount`,含 `definition`/`dataQuality(s5MissingArchivedAt/s5MissingSignedAmount)`/`realizationRate`groupBy 支持 none/month/owner/product/source/province |
| B `sales_performance` 签单口径 | ✅ | `LlmMcpMapper.xml` L891-900wonCount/wonAmount 按 S5+archived_at+actual_signed_amount |
| B `opportunity_trend` 输赢口径 | ✅ | L961-976won 按 S5、lost 按 L |
| C1 stage 字典联动校验 | ✅ | `OpportunitySearchToolProvider` validateStage非法值报错+合法枚举提示 |
| C4 列表补齐签约字段 | ✅ | `LlmMcpMapper.xml` L149-154返回 actualSignedAmount/archivedAt |
| G dashboard_summary 权限 | ✅ | 独立 STATS_PERMISSION 校验 |
| 数据权限注入 | ✅ | 拦截器+指针测试兜底 |
---
## 三、缺失项清单(含可整改性评估)
> 约束说明:以下"可整改性"均在**不动现有业务逻辑**前提下评估——
> - **兼容式新增**:默认值=原行为,不影响现有调用,**可直接做**。
> - **低风险扩展**:新增参数/字段,对既有合法调用无副作用,**可直接做**。
> - **需谨慎**:会改变现有调用返回形态或异常路径,**若不做适配会改变行为,需评估**。
### P0直接影响"少拉数据、精准取数",单文件、低风险)
| # | 缺失项 | 现状 | 整改建议 | 可整改性 |
|---|---|---|---|---|
| C2-1 | 多阶段过滤 `stageIn` | 仅单值 `stage = #{stage}` | schema 增 `stageIn`多值SQL 改 `in (...)`,默认 null 不生效 | ✅ 兼容式新增 |
| C2-2 | 日期区间 `archivedFrom/To` | 无 | 增字段SQL 加区间条件,默认 null 不生效 | ✅ 兼容式新增 |
| C2-3 | 日期区间 `createdFrom/To` | 无 | 同上 | ✅ 兼容式新增 |
| C2-4 | 金额区间 `minAmount/maxAmount` | 无 | 同上 | ✅ 兼容式新增 |
| C2-5 | 布尔 `hasActualSignedAmount` | 无 | 增字段SQL 判 `actual_signed_amount is not null`,默认 null 不生效 | ✅ 兼容式新增 |
| C3 | 排序 `sort`(如 `-actual_signed_amount` | 无 ORDER BY 口子 | 增白名单 sort 参数,默认 null=现有顺序不动 | ✅ 兼容式新增 |
| C5 | 字段投影 `fields` + description 截断 | 全列返回 | 增可选 `fields`(默认 null=全列兼容description 截断建议 120 | ✅ 兼容式新增 |
| D | `crm_entity_detail` 批量 `ids` | 仅单 `id`,返回 found/detail | 增可选 `ids` 分支,**保留单 id 路径不动**,回应 found/rows | ✅ 兼容式新增 |
### P1体验/口径自描述,改动较小)
| # | 缺失项 | 现状 | 整改建议 | 可整改性 |
|---|---|---|---|---|
| B-caliber | `sales_performance`/`opportunity_trend` 响应无口径说明 | 无 `caliber` 节点 | 响应层**新增** `caliber` 字段说明各指标口径(不改数据) | ✅ 低风险扩展 |
| E-1 | `crm_metadata_catalog` 硬编码 8 字段 | 缺 actualSignedAmount/archivedAt 说明 | **新增**字段条目+中文+口径说明 | ✅ 低风险扩展 |
| E-2 | `crm_dict_options` 未回写"被哪些字段使用" | 无使用方信息 | **新增** usage 说明 | ✅ 低风险扩展 |
| F | 归档转 S5 必填校验 | 业务侧未强制 archived_at/actual_signed_amount | 业务侧(非 MCP 模块)校验 | ⚠️ 需谨慎,改业务逻辑 |
### P2一致性与报错策略改动会改变现有行为需评估
| # | 缺失项 | 现状 | 整改建议 | 可整改性 |
|---|---|---|---|---|
| G-1 | `groupBy` 无校验,非法值静默降级 | `sales_performance` 传 month 无感透传 | 增参数白名单校验,非法值**报错**(新增防御,不改合法调用) | ⚠️ 只改异常路径,合法调用不变 |
| G-2 | `customer_summary` `groupBy=month` 未实现 | 落到 industry | 增 month 分支(新增语义) | ⚠️ 新增分支,不改现有分支 |
| G-3 | `daily_report_completion` `groupBy=month` 未实现 | month 静默按日返回(250+行) | 增 month 分支 | ⚠️ 新增分支 |
| G-4 | `universal_search` 商机行非结构化 | 仍拼接 summary 字符串 | 增独立 actualSignedAmount/archivedAt新增字段改返回结构 | ⚠️ 会改通用搜索返回形态,需评估 |
| G-5 | `crm_entity_detail` 命名混用 | `o.*` snake + 别名 camel | 统一 camelCase改返回字段名 | ⚠️ 改返回结构,需评估 |
---
## 四、结论
1. **核心价值已落地**`actual_signed_summary` 聚合、签单口径统一、stage 字典校验、total/hasMore、字段注释fieldDescriptions均已满足Agent "1~2 次调用出数"的目标基本达成。
2. **最大缺口集中在 `crm_opportunity_search` 的过滤/排序/投影增强C2/C3/C5**:这是让 LLM 少拉数据、精准取数的关键,且**全部为兼容式新增、不动现有逻辑**,风险最低。
3. **批量详情D**:可做成可选 `ids` 分支,保留现有单 `id` 路径,兼容。
4. **P2 一致性项G 系列)多为"新增分支/新增防御"**,不改合法调用结果,但会改变非法输入或返回结构的形态,建议按需逐个评估,避免一次全做引入回归。
---
## 五、后续落地建议(按优先级,均不动现有逻辑)
**第一批推荐P0 + 低风险):**
1. `crm_opportunity_search` 补齐 `stageIn`/日期区间/金额区间/`hasActualSignedAmount` 过滤 → 减少 Agent 拉取量
2. 增白名单 `sort` 排序 → 让 Agent 直接取 Top N
3. 增可选 `fields` 投影 → 控制返回体,避免上下文膨胀
4. `crm_entity_detail``ids` 批量分支(保留单 id→ 详情 N 次调用压成 1 次
**第二批P1口径自描述**
5. `sales_performance`/`opportunity_trend` 响应增 `caliber` 口径字段
6. `crm_metadata_catalog` 增 actualSignedAmount/archivedAt 及中文说明
**第三批P2需逐个评估改动会触及现有行为边界**
7. `groupBy` 白名单校验(非法值报错)
8. `customer_summary`/`daily_report_completion` 增 month 分支
9. `universal_search` 商机结构化为独立字段(改返回形态)
10. 归档必填校验(业务侧,非 MCP 模块)
> 每一项落地前都应补对应单测,并确认既有 132+ 条测试不回退。
---
*本报告为只读审计结果,未对任何现有代码/数据库做修改。字段转载与口径说明如需进一步细化,欢迎在此基础上继续。*

View File

@ -529,10 +529,14 @@ export interface OpportunityDuplicateItem {
opportunityName: string; opportunityName: string;
customerName: string; customerName: string;
stage: string; stage: string;
createdByName?: string;
similarity?: number;
} }
export interface OpportunityDuplicateCheck { export interface OpportunityDuplicateCheck {
duplicate: boolean; duplicate: boolean;
exactDuplicate?: boolean;
similarDuplicate?: boolean;
items: OpportunityDuplicateItem[]; items: OpportunityDuplicateItem[];
} }

View File

@ -1580,6 +1580,7 @@ export default function Opportunities() {
const [pushConfirmOpen, setPushConfirmOpen] = useState(false); const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
const [duplicateCheckOpen, setDuplicateCheckOpen] = useState(false); const [duplicateCheckOpen, setDuplicateCheckOpen] = useState(false);
const [duplicateCheckItems, setDuplicateCheckItems] = useState<OpportunityDuplicateItem[]>([]); const [duplicateCheckItems, setDuplicateCheckItems] = useState<OpportunityDuplicateItem[]>([]);
const [duplicateCheckExact, setDuplicateCheckExact] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [pushingOms, setPushingOms] = useState(false); const [pushingOms, setPushingOms] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
@ -2629,9 +2630,11 @@ export default function Opportunities() {
if (!editOpen) { if (!editOpen) {
try { try {
const duplicateResult = await checkOpportunityDuplicate(form.opportunityName.trim(), getLostStageCodes(stageOptions)); // 查重范围:全部商机
const duplicateResult = await checkOpportunityDuplicate(form.opportunityName.trim());
if (duplicateResult.duplicate && (duplicateResult.items ?? []).length > 0) { if (duplicateResult.duplicate && (duplicateResult.items ?? []).length > 0) {
setDuplicateCheckItems(duplicateResult.items ?? []); setDuplicateCheckItems(duplicateResult.items ?? []);
setDuplicateCheckExact(Boolean(duplicateResult.exactDuplicate));
setDuplicateCheckOpen(true); setDuplicateCheckOpen(true);
return; return;
} }
@ -3500,9 +3503,13 @@ export default function Opportunities() {
<AlertTriangle className="crm-icon-lg" /> <AlertTriangle className="crm-icon-lg" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"></h3> <h3 className="text-base font-semibold text-slate-900 dark:text-white">
{duplicateCheckExact ? "存在完全相同的商机" : "发现疑似重复商机"}
</h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400"> <p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
{form.opportunityName?.trim() || ""} {duplicateCheckExact
? `已存在名称完全相同(“${form.opportunityName?.trim() || ""}”)的商机,不允许重复新增。`
: `存在与“${form.opportunityName?.trim() || ""}”名称相似度较高的商机,请确认是否仍要新增。`}
</p> </p>
</div> </div>
</div> </div>
@ -3514,13 +3521,24 @@ export default function Opportunities() {
<div key={item.opportunityId} className="crm-form-section rounded-2xl border border-slate-200 p-3 dark:border-slate-800"> <div key={item.opportunityId} className="crm-form-section rounded-2xl border border-slate-200 p-3 dark:border-slate-800">
<p className="font-medium text-slate-900 dark:text-white">{item.opportunityName || "未命名商机"}</p> <p className="font-medium text-slate-900 dark:text-white">{item.opportunityName || "未命名商机"}</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500"> <p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{[item.opportunityCode, item.customerName, item.stage].filter(Boolean).join(" · ") || `#${item.opportunityId}`} {(item.createdByName ? [`创建人:${item.createdByName}`, item.opportunityCode, item.customerName, item.stage] : [item.opportunityCode, item.customerName, item.stage]).filter(Boolean).join(" · ") || `#${item.opportunityId}`}
{typeof item.similarity === "number" && !duplicateCheckExact ? ` · 相似度 ${Math.round(item.similarity * 100)}%` : ""}
</p> </p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="flex flex-col-reverse gap-3 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-1 sm:flex-row sm:justify-end sm:px-6 sm:pb-5"> <div className="flex flex-col-reverse gap-3 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-1 sm:flex-row sm:justify-end sm:px-6 sm:pb-5">
{duplicateCheckExact ? (
<button
type="button"
onClick={() => setDuplicateCheckOpen(false)}
className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3 sm:w-auto w-full"
>
</button>
) : (
<>
<button <button
type="button" type="button"
onClick={() => setDuplicateCheckOpen(false)} onClick={() => setDuplicateCheckOpen(false)}
@ -3535,6 +3553,8 @@ export default function Opportunities() {
> >
</button> </button>
</>
)}
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -2,11 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import { format } from "date-fns"; import { format } from "date-fns";
import { zhCN } from "date-fns/locale"; import { zhCN } from "date-fns/locale";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, Loader2, MapPin, Mic, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Square, Trash2, UserRoundPlus, X } from "lucide-react"; import { AlertTriangle, ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, Loader2, MapPin, Mic, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Square, Trash2, UserRoundPlus, X } from "lucide-react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { import {
checkChannelExpansionDuplicate, checkChannelExpansionDuplicate,
checkOpportunityDuplicate,
checkSalesExpansionDuplicate, checkSalesExpansionDuplicate,
createChannelExpansion, createChannelExpansion,
createOpportunity, createOpportunity,
@ -44,6 +45,7 @@ import {
type CreateSalesExpansionPayload, type CreateSalesExpansionPayload,
type ExpansionDictOption, type ExpansionDictOption,
type OpportunityDictOption, type OpportunityDictOption,
type OpportunityDuplicateItem,
type OpportunityItem, type OpportunityItem,
type ProfileOverview, type ProfileOverview,
type SalesExpansionItem, type SalesExpansionItem,
@ -902,6 +904,9 @@ export default function Work() {
const [quickSalesForm, setQuickSalesForm] = useState<CreateSalesExpansionPayload>(sharedDefaultQuickSalesForm); const [quickSalesForm, setQuickSalesForm] = useState<CreateSalesExpansionPayload>(sharedDefaultQuickSalesForm);
const [quickChannelForm, setQuickChannelForm] = useState<CreateChannelExpansionPayload>(sharedDefaultQuickChannelForm); const [quickChannelForm, setQuickChannelForm] = useState<CreateChannelExpansionPayload>(sharedDefaultQuickChannelForm);
const [quickOpportunityForm, setQuickOpportunityForm] = useState<CreateOpportunityPayload>(defaultQuickOpportunityForm); const [quickOpportunityForm, setQuickOpportunityForm] = useState<CreateOpportunityPayload>(defaultQuickOpportunityForm);
const [quickOpportunityDuplicateOpen, setQuickOpportunityDuplicateOpen] = useState(false);
const [quickOpportunityDuplicateExact, setQuickOpportunityDuplicateExact] = useState(false);
const [quickOpportunityDuplicateItems, setQuickOpportunityDuplicateItems] = useState<OpportunityDuplicateItem[]>([]);
const [quickOpportunityCompetitors, setQuickOpportunityCompetitors] = useState<CompetitorOption[]>([]); const [quickOpportunityCompetitors, setQuickOpportunityCompetitors] = useState<CompetitorOption[]>([]);
const [quickOpportunityCustomCompetitor, setQuickOpportunityCustomCompetitor] = useState(""); const [quickOpportunityCustomCompetitor, setQuickOpportunityCustomCompetitor] = useState("");
const [quickSalesFieldErrors, setQuickSalesFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({}); const [quickSalesFieldErrors, setQuickSalesFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
@ -1593,6 +1598,9 @@ export default function Work() {
setQuickCityOptions([]); setQuickCityOptions([]);
setQuickSalesDuplicateMessage(""); setQuickSalesDuplicateMessage("");
setQuickChannelDuplicateMessage(""); setQuickChannelDuplicateMessage("");
setQuickOpportunityDuplicateOpen(false);
setQuickOpportunityDuplicateExact(false);
setQuickOpportunityDuplicateItems([]);
}; };
const loadQuickCreateMeta = async () => { const loadQuickCreateMeta = async () => {
@ -1766,6 +1774,30 @@ export default function Work() {
} }
} }
if (quickCreateType !== "sales" && quickCreateType !== "channel") {
try {
// 查重范围:全部商机
const duplicateResult = await checkOpportunityDuplicate(quickOpportunityForm.opportunityName.trim());
if (duplicateResult.duplicate && (duplicateResult.items ?? []).length > 0) {
setQuickOpportunityDuplicateItems(duplicateResult.items ?? []);
setQuickOpportunityDuplicateExact(Boolean(duplicateResult.exactDuplicate));
setQuickOpportunityDuplicateOpen(true);
return;
}
} catch (duplicateError) {
// 查重失败不阻断新增
}
}
await performQuickCreateSubmit();
};
const handleQuickCreateConfirmWithDuplicate = async () => {
setQuickOpportunityDuplicateOpen(false);
await performQuickCreateSubmit();
};
const performQuickCreateSubmit = async () => {
setQuickCreateSubmitting(true); setQuickCreateSubmitting(true);
try { try {
let createdOption: WorkRelationOption | null = null; let createdOption: WorkRelationOption | null = null;
@ -2940,6 +2972,87 @@ export default function Work() {
</div> </div>
) : null} ) : null}
{quickOpportunityDuplicateOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setQuickOpportunityDuplicateOpen(false)}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm dark:bg-slate-950/70"
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[100] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 sm:inset-0 sm:flex sm:items-center sm:justify-center sm:p-6"
>
<div className="mx-auto w-full max-w-md rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" />
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-amber-500/15 text-amber-500 dark:bg-amber-500/20 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{quickOpportunityDuplicateExact ? "存在完全相同的商机" : "发现疑似重复商机"}
</h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
{quickOpportunityDuplicateExact
? `已存在名称完全相同(“${quickOpportunityForm.opportunityName?.trim() || ""}”)的商机,不允许重复新增。`
: `存在与“${quickOpportunityForm.opportunityName?.trim() || ""}”名称相似度较高的商机,请确认是否仍要新增。`}
</p>
</div>
</div>
</div>
<div className="px-5 py-4 text-sm text-slate-600 dark:text-slate-300 sm:px-6">
<p className="mb-2 text-xs text-slate-400 dark:text-slate-500"></p>
<div className="max-h-56 space-y-2 overflow-y-auto">
{quickOpportunityDuplicateItems.map((item) => (
<div key={item.opportunityId} className="rounded-2xl border border-slate-200 p-3 dark:border-slate-800">
<p className="font-medium text-slate-900 dark:text-white">{item.opportunityName || "未命名商机"}</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{(item.createdByName ? [`创建人:${item.createdByName}`, item.opportunityCode, item.customerName, item.stage] : [item.opportunityCode, item.customerName, item.stage]).filter(Boolean).join(" · ") || `#${item.opportunityId}`}
{typeof item.similarity === "number" && !quickOpportunityDuplicateExact ? ` · 相似度 ${Math.round(item.similarity * 100)}%` : ""}
</p>
</div>
))}
</div>
</div>
<div className="flex flex-col-reverse gap-3 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-1 sm:flex-row sm:justify-end sm:px-6 sm:pb-5">
{quickOpportunityDuplicateExact ? (
<button
type="button"
onClick={() => setQuickOpportunityDuplicateOpen(false)}
className="inline-flex h-10 items-center justify-center rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400"
>
</button>
) : (
<>
<button
type="button"
onClick={() => setQuickOpportunityDuplicateOpen(false)}
className="h-10 rounded-xl border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition-colors hover:border-violet-200 hover:text-violet-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-200"
>
</button>
<button
type="button"
onClick={() => void handleQuickCreateConfirmWithDuplicate()}
className="inline-flex h-10 items-center justify-center rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400"
>
</button>
</>
)}
</div>
</div>
</motion.div>
</>
) : null}
{locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? ( {locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? (
<LocationAdjustModal <LocationAdjustModal
origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }} origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }}

View File

@ -0,0 +1,26 @@
-- =============================================================
-- 补全 MCP 工具所用字段的数据库注释(幂等)
-- 背景MCP 工具crm_org_user_search 等)会检索以下三张平台框架表
-- 的字段,但这些表此前缺乏字段注释。这里通过原生
-- COMMENT ON COLUMN IF EXISTS 幂等补充,不存在的列自动跳过。
-- 说明crm_channel_expansion_coverage / cnarea 已各自有注释,
-- 不在此重复;本脚本只补原缺失注释的字段。
-- =============================================================
begin;
comment on column if exists sys_org.id is '组织主键';
comment on column if exists sys_org.org_name is '组织/部门名称';
comment on column if exists sys_org.is_deleted is '逻辑删除标记';
comment on column if exists sys_role.role_id is '角色主键';
comment on column if exists sys_role.role_name is '角色名称';
comment on column if exists sys_role.role_code is '角色编码';
comment on column if exists sys_role.status is '角色状态';
comment on column if exists sys_role.is_deleted is '逻辑删除标记';
comment on column if exists sys_user_role.user_id is '用户ID';
comment on column if exists sys_user_role.role_id is '角色ID';
comment on column if exists sys_user_role.is_deleted is '逻辑删除标记';
commit;

View File

@ -12,6 +12,7 @@
- 日报提醒功能初始化脚本:`sql/init_report_reminder_pg17.sql` - 日报提醒功能初始化脚本:`sql/init_report_reminder_pg17.sql`
- 2026-06-24 CRM 增量升级脚本:`sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql` - 2026-06-24 CRM 增量升级脚本:`sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql`
- 归属人转移权限初始化脚本:`sql/init_owner_transfer_permissions_pg17.sql` - 归属人转移权限初始化脚本:`sql/init_owner_transfer_permissions_pg17.sql`
- MCP 工具字段注释补齐脚本:`sql/20260916_mcp_column_comments_pg17.sql`
- 一次性修复/导入工具脚本: - 一次性修复/导入工具脚本:
- `sql/import_oms_existing_opportunities_pg17.sql` - `sql/import_oms_existing_opportunities_pg17.sql`
- 历史迁移与修数脚本:`sql/archive/*.sql` - 历史迁移与修数脚本:`sql/archive/*.sql`
@ -100,6 +101,12 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_and_opportunity_schema_
psql -d your_database -f sql/20260525_日历.sql psql -d your_database -f sql/20260525_日历.sql
``` ```
如果老环境需要补 MCP 工具所检索组织/角色字段的数据库注释sys_org、sys_role、sys_user_role请执行
```bash
psql -d your_database -f sql/20260916_mcp_column_comments_pg17.sql
```
如果需要一次性修正历史经营分析卡片配置,避免旧饼图/环形图/漏斗图升级后标签缺失、旧中文图表预设值无法识别,请执行: 如果需要一次性修正历史经营分析卡片配置,避免旧饼图/环形图/漏斗图升级后标签缺失、旧中文图表预设值无法识别,请执行:
```bash ```bash

View File

@ -1033,6 +1033,20 @@ WITH column_comments(table_name, column_name, comment_text) AS (
('business_calendar_day', 'created_at', '创建时间'), ('business_calendar_day', 'created_at', '创建时间'),
('business_calendar_day', 'updated_at', '更新时间'), ('business_calendar_day', 'updated_at', '更新时间'),
('sys_org', 'id', '组织主键'),
('sys_org', 'org_name', '组织/部门名称'),
('sys_org', 'is_deleted', '逻辑删除标记'),
('sys_role', 'role_id', '角色主键'),
('sys_role', 'role_name', '角色名称'),
('sys_role', 'role_code', '角色编码'),
('sys_role', 'status', '角色状态'),
('sys_role', 'is_deleted', '逻辑删除标记'),
('sys_user_role', 'user_id', '用户ID'),
('sys_user_role', 'role_id', '角色ID'),
('sys_user_role', 'is_deleted', '逻辑删除标记'),
('sys_activity_log', 'id', '动态主键'), ('sys_activity_log', 'id', '动态主键'),
('sys_activity_log', 'biz_type', '业务类型'), ('sys_activity_log', 'biz_type', '业务类型'),
('sys_activity_log', 'biz_id', '业务对象ID'), ('sys_activity_log', 'biz_id', '业务对象ID'),