feat: 新增OMS退单回调能力,优化MCP工具与商机查重功能
- 新增OMS退单回调接口,支持更新商机阶段为S4并清空签约信息 - 新增并优化多款MCP工具描述、字段注释查询能力与数据权限控制 - 新增商机查重相似度校验,区分精确重复与疑似重复场景 - 补全数据库字段注释与相关SQL脚本 - 新增多类型拓客搜索、字段注释查询等MCP工具 - 完善前端商机查重弹窗交互逻辑main
parent
52a35e5b20
commit
010d3052c8
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# MCP 优化方案
|
||||||
|
|
||||||
|
> 基于当前 MCP(LLM 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` 支持的 reportType(10 种)
|
||||||
|
|
||||||
|
`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.xml:83 个 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` 也声明了该 reportType(L61);
|
||||||
|
- 但 `query()` 的分发 switch(L87-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 多为可维护性与体验增强,可按迭代排期,不必一次性做完。
|
||||||
|
|
@ -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` 待人工核实框架表列名后补真实值。
|
||||||
|
|
@ -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>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 请求参数
|
||||||
|
|
||||||
|
### Body(application/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`(实际签约金额)表达签约状态,退单时三者一并清空。
|
||||||
|
|
@ -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` 仅用于日志对账,不参与业务判断,可选。
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ public class CrmOrgUserSearchToolProvider extends PermissionedMcpToolProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getToolDescription() {
|
protected String getToolDescription() {
|
||||||
return "查询 CRM 组织、角色、用户通讯录信息。用户结果遵循系统数据权限,组织和角色按当前租户裁剪。";
|
return "查询 SCC 组织、角色、用户通讯录,可用 scope 限定范围并按关键词检索。用于把真实姓名/部门/组织映射为系统用户 ID(ownerUserId/targetUserId/userId 参数需要填 ID 时用它查询),也用于回答组织架构相关问题。用户结果遵循系统数据权限,组织和角色按当前租户裁剪。";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 读取指定表若干字段的中文注释,按「别名→物理列名」映射组装成返回字段说明。
|
||||||
|
* 供各工具在返回结果中附带 fieldDescriptions:aliasToColumn 的 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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 >= #{archivedFrom}</if>
|
||||||
|
<if test="archivedTo != null">and o.archived_at::date <= #{archivedTo}</if>
|
||||||
|
<if test="createdFrom != null">and o.created_at::date >= #{createdFrom}</if>
|
||||||
|
<if test="createdTo != null">and o.created_at::date <= #{createdTo}</if>
|
||||||
|
<if test="minAmount != null">and o.amount >= #{minAmount}</if>
|
||||||
|
<if test="maxAmount != null">and o.amount <= #{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 >= #{archivedFrom}</if>
|
||||||
|
<if test="archivedTo != null">and o.archived_at::date <= #{archivedTo}</if>
|
||||||
|
<if test="createdFrom != null">and o.created_at::date >= #{createdFrom}</if>
|
||||||
|
<if test="createdTo != null">and o.created_at::date <= #{createdTo}</if>
|
||||||
|
<if test="minAmount != null">and o.amount >= #{minAmount}</if>
|
||||||
|
<if test="maxAmount != null">and o.amount <= #{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 >= #{startDate}</if>
|
||||||
|
<if test="endDate != null">and ce.created_at::date <= #{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 >= #{startDate}</if>
|
||||||
|
<if test="endDate != null">and ce.created_at::date <= #{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 > 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 >= #{startDate}
|
||||||
|
and o.archived_at::date <= #{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 >= #{startDate}
|
||||||
|
and s5.archived_at::date <= #{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 >= #{startDate}
|
||||||
|
and o.archived_at::date <= #{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_is:1是/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>
|
||||||
|
|
|
||||||
|
|
@ -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 <> ''
|
||||||
and o.opportunity_name <> ''
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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-900:wonCount/wonAmount 按 S5+archived_at+actual_signed_amount |
|
||||||
|
| B `opportunity_trend` 输赢口径 | ✅ | L961-976:won 按 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+ 条测试不回退。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本报告为只读审计结果,未对任何现有代码/数据库做修改。字段转载与口径说明如需进一步细化,欢迎在此基础上继续。*
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,27 +3521,40 @@ 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">
|
||||||
<button
|
{duplicateCheckExact ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setDuplicateCheckOpen(false)}
|
type="button"
|
||||||
className="crm-btn crm-btn-secondary min-h-0 rounded-2xl px-4 py-3"
|
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"
|
) : (
|
||||||
onClick={() => void handleConfirmCreateWithDuplicate()}
|
<>
|
||||||
className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3"
|
<button
|
||||||
>
|
type="button"
|
||||||
仍然新增
|
onClick={() => setDuplicateCheckOpen(false)}
|
||||||
</button>
|
className="crm-btn crm-btn-secondary min-h-0 rounded-2xl px-4 py-3"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleConfirmCreateWithDuplicate()}
|
||||||
|
className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3"
|
||||||
|
>
|
||||||
|
仍然新增
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue