feat: 完成CRM系统多模块迭代优化

本次提交包含了大量功能改进与基础优化:
1.  重命名项目标识与前端展示文案,统一为SCC(Sales-Channel-Customer)
2.  新增CRM拓展业务相关DTO、Mapper与服务接口,支持CRM拓展管理
3.  优化签到、日报等业务的biz_type校验,新增crm类型支持
4.  拆分前端Vite打包配置,实现依赖分包优化
5.  新增全局异常处理器,完善资源不存在等场景的错误返回
6.  新增商机查重、仪表盘多卡片批量查询接口
7.  完善渠道拓展、销售拓展的区域覆盖字段支持
8.  新增OMS字典映射服务与回调控制器,实现跨系统数据转换
9.  新增渠道与CRM拓展互转功能,支持业务类型迁移
10. 修复部分前端ECharts按需引入配置,优化打包体积

同时补充了初始化SQL脚本与系统测试记录文档。
main
kangwenjing 2026-09-08 15:12:01 +08:00
parent 80eb1a493f
commit abb76e472f
88 changed files with 9970 additions and 13257 deletions

View File

@ -0,0 +1,163 @@
# 渠道拓展 ⇄ CRM拓展 数据互移功能方案
> 版本V1.0  日期2026-08-28  适用范围CRM 系统渠道拓展 / CRM拓展
## 一、功能概述
1. 在「渠道拓展详情」底部操作区新增「移至CRM拓展」按钮将整条渠道拓展数据主表 + 联系人子表 + 跟进记录)迁移为一条 CRM 拓展记录,删除源渠道记录,并同步更新所有引用该渠道的地方。
2. 对称地在「CRM拓展详情」底部操作区新增「移至渠道拓展」按钮实现 CRM 拓展数据反向迁移为渠道拓展,逻辑对称。
3. 点击按钮后先弹出「迁移前补填」表单,用户补填源数据中缺失的目标必填字段,确认后执行迁移。
4. 按钮权限与现有「编辑资料」按钮保持一致:仅记录本人(负责人)可见可点。
## 二、权限控制
- 复用前端 `canEditSelectedItem = ownerUserId === currentUserId`,与编辑按钮同一套权限逻辑,不新增权限点。
- 非本人记录:按钮禁用并显示「仅本人可操作」,行为与编辑按钮一致。
- 后端同样校验记录存在且 `owner_user_id = 当前用户`,防止越权调用。
## 三、后端接口设计
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/expansion/channel/{id}/move-to-crm` | 渠道 → CRM请求体携带弹窗补填字段 |
| POST | `/api/expansion/crm/{id}/move-to-channel` | CRM → 渠道,请求体携带弹窗补填字段 |
服务层新增方法(均使用 `@Transactional`,任一步失败整体回滚):
```java
Long moveChannelToCrm(Long userId, Long channelId, MoveChannelToCrmRequest payload);
Long moveCrmToChannel(Long userId, Long crmId, MoveCrmToChannelRequest payload);
```
迁移流程(以渠道 → CRM 为例):
1. 校验记录存在且归属当前用户(复用 `countOwnedChannelExpansion`)。
2. 查询源主表数据(复用现有单条查询)。
3. 自动映射字段 + 合并弹窗补填字段,构造 `CreateCrmExpansionRequest`(不走表单校验)。
4. `insertCrmExpansion` 创建目标主表,获取新 id。
5. 联系人子表逐条复制(`name / mobile / title / sort_order`)。
6. 引用同步详见第四节跟进记录、外勤打卡、日报消息迁移至新记录商机、其它CRM进货商引用置空。
7. 删除源:先删联系人子表,再删渠道主表。
## 四、引用同步清单(决策①:删除源 + 同步引用)
### 4.1 渠道 → CRM删除渠道源记录后
| 引用表.字段 | 原值 | 处理后 | 说明 |
|---|---|---|---|
| `crm_opportunity.channel_expansion_id` | 源渠道id | 置空 NULL | 存在外键约束,删除前必须置空,否则删除失败 |
| `crm_crm_expansion.supplier_id` | 源渠道id | 置空 NULL | 存在外键约束,删除前必须置空 |
| `crm_expansion_followup.biz_type / biz_id` | `'channel'` / 源id | `'crm'` / 新id | 拜访/跟进记录随迁 |
| `work_checkin.biz_type / biz_id / biz_name` | `'channel'` / 源id / 旧名 | `'crm'` / 新id / 新名 | 外勤打卡关联同步 |
| `work_report_message.biz_type / biz_id / biz_name` | `'channel'` / 源id / 旧名 | `'crm'` / 新id / 新名 | 日报消息关联同步 |
### 4.2 CRM → 渠道删除CRM源记录后
| 引用表.字段 | 原值 | 处理后 | 说明 |
|---|---|---|---|
| `crm_expansion_followup.biz_type / biz_id` | `'crm'` / 源id | `'channel'` / 新id | 拜访/跟进记录随迁 |
| `work_checkin.biz_type / biz_id / biz_name` | `'crm'` / 源id / 旧名 | `'channel'` / 新id / 新名 | 外勤打卡关联同步 |
| `work_report_message.biz_type / biz_id / biz_name` | `'crm'` / 源id / 旧名 | `'channel'` / 新id / 新名 | 日报消息关联同步 |
### 补充说明
- `crm_channel_expansion_contact` 子表外键为 `ON DELETE CASCADE`,删除渠道主表时联系人自动级联删除,无需单独处理。
- `work_todo`(待办)仅用于日报「明日计划」待办(`biz_type='report'`),不涉及渠道/CRM拓展无需同步。
- `sys_activity_log`(首页动态日志)为历史遗留表,当前代码无写入路径;如存在历史引用建议一并清理(可选)。
- `crm_crm_expansion.h3c_contact_id` 指向销售拓展(`crm_sales_expansion`),与本次迁移无关,保持不变。
## 五、字段对应关系 + 弹窗补填范围(决策②:迁移前弹窗填写)
### 5.1 渠道拓展 → CRM拓展自动映射有对应关系
| 渠道拓展字段(列) | 渠道拓展含义 | CRM拓展字段 | CRM拓展含义 / 说明 |
|---|---|---|---|
| `channel_name` | 渠道名称 | `end_user` | 最终用户 |
| `province` | 省份 | `office_name` | 代表处(按 tz_bsc 字典转换,未匹配保留原值) |
| `channel_industry` | 聚焦行业 | `industry_attr` | 行业属性(按 tz_sshy 字典转换) |
| `contact_established_date` | 建立联系时间 | `purchase_date` | 采购时间 |
| `contact_name / mobile / title` | 主联系人 | `contact_name / phone / title` | 冗余字段 |
| `crm_channel_expansion_contact`(子表) | 多联系人 | `crm_crm_expansion_contact`(子表) | 逐条平移 |
| `remark` | 备注 | `remark` | 写入DB列CRM表单无备注字段 |
| `owner_user_id` | 负责人 | `owner_user_id` | 保持不变 |
**渠道特有、CRM 无对应 → 不迁移:** `channel_code`、`city`、`office_address`、`certification_level`、`annual_revenue`、`staff_size`、`intent_level`、`has_desktop_exp`、`channel_attribute`、`internal_attribute`、`stage`、`landed_flag`、`expected_sign_date`
**弹窗补填CRM无来源的必填字段**
| 弹窗字段 | 默认值 | 说明 |
|---|---|---|
| `extension_type`(类型) | 空 | 字典 `crm_extension_type` 单选,必填 |
| `online_status`(在线情况) | 空 | 字典 `crm_online_status` 单选,必填 |
| `has_expansion_opportunity`(是否有扩容机会) | 空 | 字典 `sys_is` 单选,必填 |
| `supplier_id`(进货商) | 空 | AdaptiveSelect 选渠道,必填(不能选自身) |
| `h3c_contact_id`(新华三对接人) | 空 | AdaptiveSelect 选销售拓展,必填 |
| `warranty_expiry`(过保时间) | 采购时间+3年 | 预填可改 |
### 5.2 CRM拓展 → 渠道拓展:自动映射(有对应关系)
| CRM拓展字段 | CRM拓展含义 | 渠道拓展字段(列) | 渠道拓展含义 / 说明 |
|---|---|---|---|
| `end_user` | 最终用户 | `channel_name` | 渠道名称 |
| `office_name` | 代表处 | `province` | 省份(反查 tz_bsc未匹配保留原值 |
| `industry_attr` | 行业属性 | `channel_industry` | 聚焦行业 |
| `purchase_date` | 采购时间 | `contact_established_date` | 建立联系时间 |
| `contact_name / phone / title` | 冗余联系人 | `contact_name / mobile / title` | 主联系人 |
| `crm_crm_expansion_contact`(子表) | 多联系人 | `crm_channel_expansion_contact`(子表) | 逐条平移 |
| `remark` | 备注 | `remark` | 直接映射 |
| `owner_user_id` | 负责人 | `owner_user_id` | 保持不变 |
**CRM特有、渠道无对应 → 不迁移:** `extension_type`、`warranty_expiry`、`online_status`、`supplier_id`、`h3c_contact_id`、`has_expansion_opportunity`
**弹窗补填(渠道无来源的必填字段):**
| 弹窗字段 | 默认值 | 说明 |
|---|---|---|
| `city`(市) | 空 | 必填 |
| `office_address`(办公地址) | 空 | 必填 |
| `certification_level`(认证级别) | 空 | 字典单选,必填 |
| `annual_revenue`(年度营业额·万元) | 空 | 数字>0必填 |
| `staff_size`(人员规模) | 空 | 正整数,必填 |
| `channel_attribute`(渠道属性) | 空 | 字典单选,必填 |
| `internal_attribute`(内部属性) | 空 | 字典单选,必填 |
| `intent_level`(合作意向) | `medium` | 可改 |
| `has_desktop_exp`(桌面扩展能力) | `false` | 可改 |
| `stage`(阶段) | `initial_contact` | 预填 |
| `landed_flag`(是否落地) | `false` | 预填 |
| `expected_sign_date`(预计签约时间) | 空 | 可空 |
> 说明:渠道表单编辑时校验 `province / city / certificationLevel / officeAddress / channelIndustry / annualRevenue / staffSize / channelAttribute / internalAttribute` 等字段非空,因此迁移弹窗将这些字段设为必填,确保迁移后的记录完整、可继续编辑保存。
## 六、并发与重复防护
迁移为「删除源 + 创建目标」的复合写操作,必须保证并发安全与幂等,防止重复迁移、竞态丢失或脏数据。
### 6.1 后端并发控制
- 迁移接口在事务内对源记录执行 `SELECT ... FOR UPDATE`(行锁),锁定源记录,避免与并发的编辑/其它迁移操作产生竞态。
- 若源记录已被其它事务迁移或删除,`SELECT FOR UPDATE` 返回空 → 抛出「记录不存在或已被迁移」提示。
- 引用同步、目标创建、源删除均在同一个事务内完成,任一步失败整体回滚,不产生中间态。
### 6.2 幂等与重复提交防护
- 迁移成功后源记录即被删除;重复调用接口时源记录不存在,后端返回明确错误(「记录不存在或已被迁移」),不会重复创建目标。
- 前端提交后立即进入 loading 态并禁用按钮,防止用户重复点击。
### 6.3 迁移与编辑的互斥
- 迁移期间源记录被行锁锁定,其它用户的编辑操作(`updateChannelExpansion` / `updateCrmExpansion`)在锁释放前等待,避免「迁移了旧数据」的脏读。
- 弹窗补填的字段在后端二次校验(必填、数值范围、进货商/对接人不能为空),校验失败整体回滚。
## 七、前端实现
- 按钮位置:详情页底部操作区(现有「编辑资料」按钮旁),按 `selectedItem.type` 显示:`type='channel'` 显示「移至CRM拓展」`type='crm'` 显示「移至渠道拓展」,`type='sales'` 不显示。
- 样式与交互:与编辑按钮同款按钮样式;点击后弹「迁移前补填」表单(复用现有表单字段组件与字典选项),校验通过后二次确认,再调用接口。
- 状态处理:提交时 loading 态(按钮禁用防重复提交);成功后刷新列表、关闭详情并切换到新记录;失败时 toast 展示错误信息(如「记录不存在或已被迁移」)。
## 八、实现清单(后续开发步骤)
1. 后端:新增 `MoveChannelToCrmRequest` / `MoveCrmToChannelRequest` DTO`ExpansionController` 新增两个接口;`ExpansionService` 新增两个 `@Transactional` 方法。
2. Mapper新增按源 id 更新 `work_checkin` / `work_report_message``biz_type` / `biz_id` / `biz_name`、商机 `channel_expansion_id` 置空、其它CRM `supplier_id` 置空、删除渠道/CRM主表等 SQL。
3. 并发防护:迁移查询源记录时使用 `SELECT ... FOR UPDATE` 行锁;重复迁移返回「记录不存在或已被迁移」;后端对弹窗补填字段二次校验。
4. 前端:新增两个补填表单弹窗组件 + 两个迁移按钮 + 二次确认与成功刷新逻辑,提交时 loading 禁用防重复点击。
5. 验证渠道→CRM 与 CRM→渠道 各执行一遍,核对主表、联系人、跟进记录、打卡/日报关联、商机引用均正确迁移或置空。

View File

@ -5,14 +5,50 @@
</component>
<component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/common/WorkCheckInSchemaInitializer.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/common/WorkCheckInSchemaInitializer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/DashboardController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/DashboardController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/ExpansionController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/ExpansionController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionOverviewDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionOverviewDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.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/DashboardAnalyticsConfigService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/DashboardService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/DashboardService.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/ExpansionService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/ExpansionService.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OmsClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OmsClient.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.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/java/com/unis/crm/service/impl/WorkServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" 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/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.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/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$/backend/src/main/resources/mapper/work/WorkMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/_metadata.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/_metadata.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/motion_react.js" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/motion_react.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/motion_react.js.map" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/node_modules/.vite/deps/motion_react.js.map" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/App.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/App.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/components/AdaptiveSelect.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/components/AdaptiveSelect.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/components/Layout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/components/Layout.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/components/dashboard/DashboardAnalyticsEChart.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/components/dashboard/DashboardAnalyticsEChart.tsx" 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/Dashboard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Dashboard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Login.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Login.tsx" 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$/frontend/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/vite.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/dist/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/dist/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/@ant-design_icons.js" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/@ant-design_icons.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/_metadata.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/_metadata.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/antd.js" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/node_modules/.vite/deps/antd.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/src/features/dashboard-analytics/components/AnalyticsChartPreview.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/src/features/dashboard-analytics/components/AnalyticsChartPreview.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend1/vite.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend1/vite.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/sql/init_full_pg17.sql" beforeDir="false" afterPath="$PROJECT_DIR$/sql/init_full_pg17.sql" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -107,6 +143,7 @@
<workItem from="1776238420843" duration="21000" />
<workItem from="1787113214298" duration="3043000" />
<workItem from="1787211862072" duration="4949000" />
<workItem from="1787721570035" duration="18307000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />

View File

@ -0,0 +1,12 @@
# 2026-09-03 系统测试
- 对 unis_crm 做了全系统测试后端8080 + 前端3000/3002 + frontend1产出《系统测试报告-20260903.md》
- 关键发现:
- 严重:后端信任 X-User-Id 头(任何登录用户可冒充任意 userIdIDOR
- 严重:/sys/api/admin 的 wecom-app-config、report-reminder-config、dashboard-analytics-config 无鉴权(普通用户可读企微 secret、可写配置、跨租户speech-recognition 与 user-data-scope 有权限校验(对照组)
- 高:全局异常处理器缺 MissingServletRequestParameter/HttpMessageNotReadable 等 → 大量参数错误返回 500
- 高:运行环境使用仓库默认 JWT 密钥 change-me-please-change-me-32bytes签名验证通过实证有 Redis 会话绑定兜底
- 中CORS 反射任意 Origin + Allow-Credentials单测 2/113 失败ExpansionServiceImplTest 渠道重名 vs 联系人必填校验顺序)
- 接入要点:前端 vite 代理把 /api/sys/* 重写为 /sys/*;业务接口需同时带 Authorization + X-User-Id 两个头
- 测试账号admin_crm/crm@123 (userId=19)13752913297/123456 (周瑾 userId=20部门领导全租户数据权限)
- 遗留测试数据商机2228/2229、签到1025、日报999均带【自动化测试】前缀企微配置曾意外清空已恢复

View File

@ -0,0 +1,21 @@
# 2026-09-08 只读复检(未改代码)
- 用户要求只检查不动代码。后端已重启(运行的是当日12:10编译的最新代码IDE加载target/classes)。
- 复检结论9/3 报告的严重问题均未修复:
1. X-User-Id 伪造身份仍可复现(普通用户带 X-User-Id:19 拿到管理员 profile
2. /sys/api/admin 的 wecom-app-config(secret明文)/report-reminder-config/dashboard-analytics-config 普通用户仍可读
3. JWT 默认密钥 change-me-please-change-me-32bytes 仍在(application.yml + application-prod.yml 第53/51行)
4. 参数缺失仍返回 500仅补了 404 NoResourceFoundException 的 handler
5. CORS 仍反射任意 Origin + Allow-Credentials:true
- 新增改动9/3后工作区未提交57文件/约5.7k行ExpansionServiceImpl +566 行新增渠道↔CRM 互转(moveChannelToCrm/moveCrmToChannel结构合理有归属校验+关联迁移+事务)Opportunity OMS 反写阶段 normalizeStageValue、S5已签约同步Work 日报正文 stripCrmEditorBody前端 auth.ts/Expansion.tsx 等大改
- 单测仍 113 个 2 失败ExpansionServiceImplTest 渠道重名校验 vs 联系人必填校验顺序与9/3相同根因测试改过仍不过
- 前端 tsc --noEmit 通过
- 核心读接口全 200互转/跟进等对不存在 id 错误处理正确
- 测试账号 token 存 /tmp/crm_tokens2.env9/8新登录原9/3 token已过期/被刷新)
## 9/8 追加:商机名称查重功能只读复查(未改代码)
- 用户新增"商机名称查重"请求复查。检查期间用户仍在改代码并反复编译target 14:11→14:15→14:16 更新最终磁盘版XML selectDuplicateOpportunities 去掉了 select distinct曾有一版 `select distinct + order by coalesce(updated_at...)` 在 PG 必报 InvalidColumnReferencepsycopg2 直连 202 库实测复现DTO 重构为 OpportunityDuplicateCheckDTO.Item 内嵌类(OpportunityDuplicateItemDTO 已删)Mapper/Service/Controller 全部配套。
- 功能形态Controller /api/opportunities/duplicate-check(name, excludeId?, excludeStageCodes?) → Service 全表同名精确匹配(去空格+upper归档剔除excludeId/排除阶段支持) → 前端提交时才查,命中弹窗"仍然新增"可继续(软提示),编辑不查重。
- 实证(运行实例):单值 excludeStageCodes=L 正常;**excludeStageCodes 传≥2 个值(或逗号分隔) 接口 500**overview 同病),前端 URLSearchParams 会逐个 append → 当前字典只有 1 个丢单阶段(L-已丢单) 所以线上不触发;字典一旦有 ≥2 个丢单/放弃阶段即致查重 500 且前端 catch 吞错放行(静默失效)。dashboard cardKeys 多值 200 排除 Spring 绑定问题 → 疑在商机 SQL/MyBatis foreach 路径,未拿到堆栈(无落盘日志)。
- 其余发现:查重 SQL 无 userId/可见性过滤(对照列表/详情均带 visibility)→ 全库同名探测+返回他人商机明细(越权读取/泄露,中高);后端 create/update 无唯一校验、表无唯一约束,"仍然新增"按钮无 submitting 防抖,双击可并发建两条同名(2233 即在测试中被创建);编辑可改名但不查重且 excludeId 参数前端从不传(功能未闭环);查重谓词函数包裹列无索引(量级小暂可);规范化只去空格+ASCII大写全角字符/NBSP 可绕过。
- 环境疑点:运行实例数据(id 2228/2229/2230/2231/2233 等)与 application.yml 指向的 192.168.124.202/unis_crm_dev 不一致(该库实为 2228/2229/2232) → IDE 运行配置数据源被覆盖(profile/env)测试库≠配置文件库发版前需核对。psycopg2-binary 已装入 ~/.workbuddy/binaries/python/envs/default202 库 postgres/unis@123 只读排查用)

View File

@ -108,6 +108,13 @@ public class CrmGlobalExceptionHandler {
return compatibleErrorResponse(HttpStatus.CONFLICT, resolveConstraintMessage(ex), request.getRequestURI());
}
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoResourceFoundException(
org.springframework.web.servlet.resource.NoResourceFoundException ex,
HttpServletRequest request) {
return compatibleErrorResponse(HttpStatus.NOT_FOUND, "请求的资源不存在", request.getRequestURI());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpectedException(Exception ex, HttpServletRequest request) {
log.error("Unexpected request failure on {}", request.getRequestURI(), ex);

View File

@ -48,7 +48,7 @@ public class WorkCheckInSchemaInitializer implements ApplicationRunner {
) then
alter table work_checkin
add constraint work_checkin_biz_type_check
check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity'));
check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity', 'crm'));
end if;
end $$;
""");

View File

@ -7,6 +7,7 @@ import com.unis.crm.dto.dashboard.DashboardHomeDTO;
import com.unis.crm.service.DashboardService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.constraints.Min;
import java.util.List;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -58,4 +59,11 @@ public class DashboardController {
@RequestParam(value = "dimension", required = false) String dimension) {
return ApiResponse.success(dashboardService.getAnalyticsCardDetail(CurrentUserUtils.requireCurrentUserId(userId), cardKey, dimension));
}
@GetMapping("/analytics-cards/data")
public ApiResponse<List<DashboardAnalyticsCardDTO>> getAnalyticsCardData(
@RequestHeader("X-User-Id") @Min(1) Long userId,
@RequestParam("cardKeys") List<String> cardKeys) {
return ApiResponse.success(dashboardService.getAnalyticsCardData(CurrentUserUtils.requireCurrentUserId(userId), cardKeys));
}
}

View File

@ -4,13 +4,17 @@ import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.CrmIdJacksonConfig;
import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateCrmExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.MoveChannelToCrmRequest;
import com.unis.crm.dto.expansion.MoveCrmToChannelRequest;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateCrmExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.service.ExpansionService;
import com.unisbase.common.annotation.Log;
@ -86,6 +90,15 @@ public class ExpansionController {
CurrentUserUtils.requireCurrentUserId(userId), channelName, excludeId));
}
@RequestMapping(value = "/crm/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
public ApiResponse<ExpansionDuplicateCheckDTO> checkCrmDuplicate(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("endUser") String endUser,
@RequestParam(value = "excludeId", required = false) Long excludeId) {
return ApiResponse.success(expansionService.checkCrmEndUserDuplicate(
CurrentUserUtils.requireCurrentUserId(userId), endUser, excludeId));
}
@PostMapping("/sales")
@Log(type = "拓展管理", value = "新增售前拓展")
public ApiResponse<Object> createSales(
@ -133,4 +146,52 @@ public class ExpansionController {
@Valid @RequestBody CreateExpansionFollowUpRequest request) {
return ApiResponse.success(expansionService.createFollowUp(CurrentUserUtils.requireCurrentUserId(userId), bizType, bizId, request));
}
@GetMapping("/crm/overview")
public ApiResponse<ExpansionOverviewDTO> getCrmOverview(
@RequestHeader("X-User-Id") Long userId,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
@RequestParam(value = "limit", required = false) Integer limit) {
return ApiResponse.success(expansionService.getCrmOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, includeDetails, limit));
}
@PostMapping("/crm")
@Log(type = "拓展管理", value = "新增CRM拓展")
public ApiResponse<Object> createCrm(
@RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateCrmExpansionRequest request) {
Long id = expansionService.createCrmExpansion(CurrentUserUtils.requireCurrentUserId(userId), request);
return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(id));
}
@PutMapping("/crm/{id}")
@Log(type = "拓展管理", value = "编辑CRM拓展")
public ApiResponse<Void> updateCrm(
@RequestHeader("X-User-Id") Long userId,
@PathVariable("id") Long id,
@Valid @RequestBody UpdateCrmExpansionRequest request) {
expansionService.updateCrmExpansion(CurrentUserUtils.requireCurrentUserId(userId), id, request);
return ApiResponse.success(null);
}
@PostMapping("/channel/{id}/move-to-crm")
@Log(type = "拓展管理", value = "渠道拓展移至CRM拓展")
public ApiResponse<Object> moveChannelToCrm(
@RequestHeader("X-User-Id") Long userId,
@PathVariable("id") Long id,
@Valid @RequestBody MoveChannelToCrmRequest request) {
Long newId = expansionService.moveChannelToCrm(CurrentUserUtils.requireCurrentUserId(userId), id, request);
return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(newId));
}
@PostMapping("/crm/{id}/move-to-channel")
@Log(type = "拓展管理", value = "CRM拓展移至渠道拓展")
public ApiResponse<Object> moveCrmToChannel(
@RequestHeader("X-User-Id") Long userId,
@PathVariable("id") Long id,
@Valid @RequestBody MoveCrmToChannelRequest request) {
Long newId = expansionService.moveCrmToChannel(CurrentUserUtils.requireCurrentUserId(userId), id, request);
return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(newId));
}
}

View File

@ -0,0 +1,74 @@
package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.service.CrmOmsDictMappingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* OMS
* OMS
*/
@RestController
@RequestMapping("/api/oms")
public class OmsCallbackController {
private static final Logger log = LoggerFactory.getLogger(OmsCallbackController.class);
private final CrmOmsDictMappingService dictMappingService;
// 用于简单鉴权的 token实际部署时请替换为安全的配置
private static final String EXPECTED_TOKEN = "your_secret_token_here";
public OmsCallbackController(CrmOmsDictMappingService dictMappingService) {
this.dictMappingService = dictMappingService;
}
/**
* OMS
* OMS
*
* @param token token
* @param payload OMS JSON
* @return
*/
@PostMapping("/callback")
public ApiResponse<String> handleOmsCallback(
@RequestHeader("X-Callback-Token") String token,
@RequestBody Map<String, Object> payload) {
// 1. 简单鉴权
if (!EXPECTED_TOKEN.equals(token)) {
log.warn("OMS callback failed: invalid token");
return ApiResponse.fail("鉴权失败");
}
try {
log.info("Received OMS callback payload: {}", payload);
// 2. 解析数据并进行字典映射处理
// 假设 OMS 传来的数据包含 projectStage 和 projectCode
String omsStage = (String) payload.get("projectStage");
String projectCode = (String) payload.get("projectCode");
if (omsStage != null && projectCode != null) {
// 核心映射逻辑:将 OMS 阶段码值转换为 CRM 码值
String crmStage = dictMappingService.mapStageToCrm(omsStage);
log.info("Mapped OMS stage '{}' to CRM stage '{}' for project '{}'", omsStage, crmStage, projectCode);
// TODO: 在这里添加根据 projectCode 更新 CRM 数据库中商机阶段的逻辑
// 例如opportunityService.updateStageByProjectCode(projectCode, crmStage);
}
return ApiResponse.success("回调处理成功");
} catch (Exception e) {
log.error("Error processing OMS callback", e);
return ApiResponse.fail("处理回调数据时发生错误: " + e.getMessage());
}
}
}

View File

@ -5,6 +5,7 @@ import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
@ -58,6 +59,16 @@ public class OpportunityController {
return ApiResponse.success(opportunityService.getDetail(CurrentUserUtils.requireCurrentUserId(userId), opportunityId));
}
@GetMapping("/duplicate-check")
public ApiResponse<OpportunityDuplicateCheckDTO> checkDuplicate(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("name") String name,
@RequestParam(value = "excludeId", required = false) Long excludeId,
@RequestParam(value = "excludeStageCodes", required = false) List<String> excludeStageCodes) {
return ApiResponse.success(opportunityService.checkDuplicateOpportunity(
CurrentUserUtils.requireCurrentUserId(userId), name, excludeId, excludeStageCodes));
}
@GetMapping("/oms/pre-sales")
public ApiResponse<List<OmsPreSalesOptionDTO>> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) {
return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId)));

View File

@ -21,6 +21,7 @@ public class DashboardAnalyticsCardDTO {
private String errorMessage;
private Integer totalCount;
private Boolean hasMore;
private Boolean dataLoaded;
private java.util.List<DashboardAnalyticsChartPointDTO> chartData;
public Long getId() {
@ -175,6 +176,14 @@ public class DashboardAnalyticsCardDTO {
this.hasMore = hasMore;
}
public Boolean getDataLoaded() {
return dataLoaded;
}
public void setDataLoaded(Boolean dataLoaded) {
this.dataLoaded = dataLoaded;
}
public java.util.List<DashboardAnalyticsChartPointDTO> getChartData() {
return chartData;
}

View File

@ -4,9 +4,13 @@ public class ChannelExpansionContactDTO {
private Long id;
private Long channelExpansionId;
private String duty;
private String name;
private String mobile;
private String title;
private String birthday;
private String wecomAdded;
private String specialNote;
public Long getId() {
return id;
@ -24,6 +28,14 @@ public class ChannelExpansionContactDTO {
this.channelExpansionId = channelExpansionId;
}
public String getDuty() {
return duty;
}
public void setDuty(String duty) {
this.duty = duty;
}
public String getName() {
return name;
}
@ -47,4 +59,28 @@ public class ChannelExpansionContactDTO {
public void setTitle(String title) {
this.title = title;
}
public String getBirthday() {
return birthday;
}
public void setBirthday(String birthday) {
this.birthday = birthday;
}
public String getWecomAdded() {
return wecomAdded;
}
public void setWecomAdded(String wecomAdded) {
this.wecomAdded = wecomAdded;
}
public String getSpecialNote() {
return specialNote;
}
public void setSpecialNote(String specialNote) {
this.specialNote = specialNote;
}
}

View File

@ -1,10 +1,24 @@
package com.unis.crm.dto.expansion;
import java.time.LocalDate;
public class ChannelExpansionContactRequest {
private String duty;
private String name;
private String mobile;
private String title;
private LocalDate birthday;
private String wecomAdded;
private String specialNote;
public String getDuty() {
return duty;
}
public void setDuty(String duty) {
this.duty = duty;
}
public String getName() {
return name;
@ -29,4 +43,28 @@ public class ChannelExpansionContactRequest {
public void setTitle(String title) {
this.title = title;
}
public LocalDate getBirthday() {
return birthday;
}
public void setBirthday(LocalDate birthday) {
this.birthday = birthday;
}
public String getWecomAdded() {
return wecomAdded;
}
public void setWecomAdded(String wecomAdded) {
this.wecomAdded = wecomAdded;
}
public String getSpecialNote() {
return specialNote;
}
public void setSpecialNote(String specialNote) {
this.specialNote = specialNote;
}
}

View File

@ -15,6 +15,10 @@ public class ChannelExpansionItemDTO {
private String province;
private String cityCode;
private String city;
private String coverageProvince;
private String coverageCity;
/** 省-市配对明细("省|市"以中文逗号分隔city 为空表示全省),用于列表/详情按对应关系展示 */
private String coverageItems;
private String officeAddress;
private String channelIndustryCode;
private String channelIndustry;
@ -22,6 +26,7 @@ public class ChannelExpansionItemDTO {
private String annualRevenue;
private String revenue;
private Integer size;
private String registeredCapital;
private String primaryContactName;
private String primaryContactTitle;
private String primaryContactMobile;
@ -128,6 +133,30 @@ public class ChannelExpansionItemDTO {
this.city = city;
}
public String getCoverageProvince() {
return coverageProvince;
}
public void setCoverageProvince(String coverageProvince) {
this.coverageProvince = coverageProvince;
}
public String getCoverageCity() {
return coverageCity;
}
public void setCoverageCity(String coverageCity) {
this.coverageCity = coverageCity;
}
public String getCoverageItems() {
return coverageItems;
}
public void setCoverageItems(String coverageItems) {
this.coverageItems = coverageItems;
}
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
@ -180,6 +209,14 @@ public class ChannelExpansionItemDTO {
this.size = size;
}
public String getRegisteredCapital() {
return registeredCapital;
}
public void setRegisteredCapital(String registeredCapital) {
this.registeredCapital = registeredCapital;
}
public String getPrimaryContactName() {
return primaryContactName;
}

View File

@ -0,0 +1,182 @@
package com.unis.crm.dto.expansion;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* CRM
*/
public class ChannelExpansionMoveSourceDTO {
private Long id;
private Long ownerUserId;
private String name;
private String province;
private String city;
private String officeAddress;
private String channelIndustry;
private String certificationLevel;
private BigDecimal annualRevenue;
private Integer staffSize;
private LocalDate establishedDate;
private String intentLevel;
private Boolean hasDesktopExp;
private String channelAttribute;
private String internalAttribute;
private String stage;
private Boolean landedFlag;
private LocalDate expectedSignDate;
private String remark;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getProvince() {
return province;
}
public void setProvince(String province) {
this.province = province;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
public String getChannelIndustry() {
return channelIndustry;
}
public void setChannelIndustry(String channelIndustry) {
this.channelIndustry = channelIndustry;
}
public String getCertificationLevel() {
return certificationLevel;
}
public void setCertificationLevel(String certificationLevel) {
this.certificationLevel = certificationLevel;
}
public BigDecimal getAnnualRevenue() {
return annualRevenue;
}
public void setAnnualRevenue(BigDecimal annualRevenue) {
this.annualRevenue = annualRevenue;
}
public Integer getStaffSize() {
return staffSize;
}
public void setStaffSize(Integer staffSize) {
this.staffSize = staffSize;
}
public LocalDate getEstablishedDate() {
return establishedDate;
}
public void setEstablishedDate(LocalDate establishedDate) {
this.establishedDate = establishedDate;
}
public String getIntentLevel() {
return intentLevel;
}
public void setIntentLevel(String intentLevel) {
this.intentLevel = intentLevel;
}
public Boolean getHasDesktopExp() {
return hasDesktopExp;
}
public void setHasDesktopExp(Boolean hasDesktopExp) {
this.hasDesktopExp = hasDesktopExp;
}
public String getChannelAttribute() {
return channelAttribute;
}
public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute;
}
public String getInternalAttribute() {
return internalAttribute;
}
public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute;
}
public String getStage() {
return stage;
}
public void setStage(String stage) {
this.stage = stage;
}
public Boolean getLandedFlag() {
return landedFlag;
}
public void setLandedFlag(Boolean landedFlag) {
this.landedFlag = landedFlag;
}
public LocalDate getExpectedSignDate() {
return expectedSignDate;
}
public void setExpectedSignDate(LocalDate expectedSignDate) {
this.expectedSignDate = expectedSignDate;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}

View File

@ -0,0 +1,27 @@
package com.unis.crm.dto.expansion;
/**
* /
* province city
*/
public class CoverageItemDTO {
private String province;
private String city;
public String getProvince() {
return province;
}
public void setProvince(String province) {
this.province = province;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
}

View File

@ -16,6 +16,14 @@ public class CreateChannelExpansionRequest {
private String city;
private String certificationLevel;
@Size(max = 500, message = "覆盖省份不能超过500字符")
private String coverageProvince;
@Size(max = 2000, message = "覆盖市/区/县不能超过2000字符")
private String coverageCity;
private List<CoverageItemDTO> coverageItems;
@NotBlank(message = "渠道名称不能为空")
@Size(max = 200, message = "渠道名称不能超过200字符")
private String channelName;
@ -23,6 +31,7 @@ public class CreateChannelExpansionRequest {
private String province;
private BigDecimal annualRevenue;
private Integer staffSize;
private BigDecimal registeredCapital;
private LocalDate contactEstablishedDate;
private String intentLevel;
private Boolean hasDesktopExp;
@ -82,6 +91,30 @@ public class CreateChannelExpansionRequest {
this.certificationLevel = certificationLevel;
}
public String getCoverageProvince() {
return coverageProvince;
}
public void setCoverageProvince(String coverageProvince) {
this.coverageProvince = coverageProvince;
}
public String getCoverageCity() {
return coverageCity;
}
public void setCoverageCity(String coverageCity) {
this.coverageCity = coverageCity;
}
public List<CoverageItemDTO> getCoverageItems() {
return coverageItems;
}
public void setCoverageItems(List<CoverageItemDTO> coverageItems) {
this.coverageItems = coverageItems;
}
public String getChannelName() {
return channelName;
}
@ -114,6 +147,14 @@ public class CreateChannelExpansionRequest {
this.staffSize = staffSize;
}
public BigDecimal getRegisteredCapital() {
return registeredCapital;
}
public void setRegisteredCapital(BigDecimal registeredCapital) {
this.registeredCapital = registeredCapital;
}
public LocalDate getContactEstablishedDate() {
return contactEstablishedDate;
}

View File

@ -0,0 +1,250 @@
package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class CreateCrmExpansionRequest {
@NotBlank(message = "最终用户不能为空")
private String endUser;
@NotBlank(message = "代表处不能为空")
private String officeName;
@NotBlank(message = "行业属性不能为空")
private String industryAttr;
@NotBlank(message = "类型不能为空")
private String extensionType;
@NotNull(message = "采购时间不能为空")
private LocalDate purchaseDate;
@NotNull(message = "过保时间不能为空")
private LocalDate warrantyExpiry;
@NotBlank(message = "在线情况不能为空")
private String onlineStatus;
@NotEmpty(message = "请至少填写一位联系人")
private List<CrmExpansionContactRequest> contacts = new ArrayList<>();
/** 冗余字段:由 Service 从 contacts 第一个联系人派生,用于主表搜索/展示 */
private String contactName;
private String contactPhone;
private String contactTitle;
/** 进货商:下拉选中时存 id手动输入时为空名称存 supplierName */
private Long supplierId;
/** 进货商名称:下拉选中冗余存名,手动输入时存文字 */
private String supplierName;
/** 新华三对接人:下拉选中时存 id手动输入时为空名称存 h3cContactName */
private Long h3cContactId;
/** 新华三对接人名称:下拉选中冗余存名,手动输入时存文字 */
private String h3cContactName;
@NotBlank(message = "是否有扩容机会不能为空")
private String hasExpansionOpportunity;
/** 软件点数(整数,必填) */
@NotNull(message = "软件点数不能为空")
private Integer softwarePoints;
/** 扩容时间(可选) */
private LocalDate expansionTime;
/** 扩容规模(可选) */
private String expansionScale;
/** 是否有维保项目机会sys_is1是 / 0否 */
@NotBlank(message = "是否有维保项目机会不能为空")
private String hasMaintenanceOpportunity;
private Long id;
/** 备注CRM 表单不展示,迁移时保留源数据) */
private String remark;
public String getEndUser() {
return endUser;
}
public void setEndUser(String endUser) {
this.endUser = endUser;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public String getIndustryAttr() {
return industryAttr;
}
public void setIndustryAttr(String industryAttr) {
this.industryAttr = industryAttr;
}
public String getExtensionType() {
return extensionType;
}
public void setExtensionType(String extensionType) {
this.extensionType = extensionType;
}
public LocalDate getPurchaseDate() {
return purchaseDate;
}
public void setPurchaseDate(LocalDate purchaseDate) {
this.purchaseDate = purchaseDate;
}
public LocalDate getWarrantyExpiry() {
return warrantyExpiry;
}
public void setWarrantyExpiry(LocalDate warrantyExpiry) {
this.warrantyExpiry = warrantyExpiry;
}
public String getOnlineStatus() {
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus) {
this.onlineStatus = onlineStatus;
}
public List<CrmExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<CrmExpansionContactRequest> contacts) {
this.contacts = contacts;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getContactTitle() {
return contactTitle;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
}
public Long getSupplierId() {
return supplierId;
}
public void setSupplierId(Long supplierId) {
this.supplierId = supplierId;
}
public String getSupplierName() {
return supplierName;
}
public void setSupplierName(String supplierName) {
this.supplierName = supplierName;
}
public Long getH3cContactId() {
return h3cContactId;
}
public void setH3cContactId(Long h3cContactId) {
this.h3cContactId = h3cContactId;
}
public String getH3cContactName() {
return h3cContactName;
}
public void setH3cContactName(String h3cContactName) {
this.h3cContactName = h3cContactName;
}
public String getHasExpansionOpportunity() {
return hasExpansionOpportunity;
}
public void setHasExpansionOpportunity(String hasExpansionOpportunity) {
this.hasExpansionOpportunity = hasExpansionOpportunity;
}
public Integer getSoftwarePoints() {
return softwarePoints;
}
public void setSoftwarePoints(Integer softwarePoints) {
this.softwarePoints = softwarePoints;
}
public LocalDate getExpansionTime() {
return expansionTime;
}
public void setExpansionTime(LocalDate expansionTime) {
this.expansionTime = expansionTime;
}
public String getExpansionScale() {
return expansionScale;
}
public void setExpansionScale(String expansionScale) {
this.expansionScale = expansionScale;
}
public String getHasMaintenanceOpportunity() {
return hasMaintenanceOpportunity;
}
public void setHasMaintenanceOpportunity(String hasMaintenanceOpportunity) {
this.hasMaintenanceOpportunity = hasMaintenanceOpportunity;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}

View File

@ -3,11 +3,15 @@ package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
public class CreateSalesExpansionRequest {
private Long id;
/** 所属区域(省-市配对明细多选city 为空表示全省) */
private List<CoverageItemDTO> regionItems;
@NotBlank(message = "工号不能为空")
@Size(max = 50, message = "工号不能超过50字符")
private String employeeNo;
@ -38,6 +42,14 @@ public class CreateSalesExpansionRequest {
this.id = id;
}
public List<CoverageItemDTO> getRegionItems() {
return regionItems;
}
public void setRegionItems(List<CoverageItemDTO> regionItems) {
this.regionItems = regionItems;
}
public String getEmployeeNo() {
return employeeNo;
}

View File

@ -0,0 +1,50 @@
package com.unis.crm.dto.expansion;
public class CrmExpansionContactDTO {
private Long id;
private Long crmExpansionId;
private String name;
private String mobile;
private String title;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getCrmExpansionId() {
return crmExpansionId;
}
public void setCrmExpansionId(Long crmExpansionId) {
this.crmExpansionId = crmExpansionId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
}

View File

@ -0,0 +1,32 @@
package com.unis.crm.dto.expansion;
public class CrmExpansionContactRequest {
private String name;
private String mobile;
private String title;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
}

View File

@ -0,0 +1,287 @@
package com.unis.crm.dto.expansion;
import java.util.ArrayList;
import java.util.List;
public class CrmExpansionItemDTO {
private Long id;
private Long ownerUserId;
private String owner;
private String type;
private String endUser;
private String officeName;
private String industryAttr;
private String industryAttrCode;
private String extensionType;
private String purchaseDate;
private String purchaseDateText;
private String warrantyExpiry;
private String warrantyExpiryText;
private String onlineStatus;
private String contactName;
private String contactPhone;
private String contactTitle;
private List<CrmExpansionContactDTO> contacts = new ArrayList<>();
private Long supplierId;
private String supplierName;
private Long h3cContactId;
private String h3cContactName;
private String hasExpansionOpportunity;
private Integer softwarePoints;
private String expansionTime;
private String expansionTimeText;
private String expansionScale;
private String hasMaintenanceOpportunity;
private String createdAt;
private String updatedAt;
private List<ExpansionFollowUpDTO> followUps = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getEndUser() {
return endUser;
}
public void setEndUser(String endUser) {
this.endUser = endUser;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public String getIndustryAttrCode() {
return industryAttrCode;
}
public void setIndustryAttrCode(String industryAttrCode) {
this.industryAttrCode = industryAttrCode;
}
public String getIndustryAttr() {
return industryAttr;
}
public void setIndustryAttr(String industryAttr) {
this.industryAttr = industryAttr;
}
public String getExtensionType() {
return extensionType;
}
public void setExtensionType(String extensionType) {
this.extensionType = extensionType;
}
public String getPurchaseDate() {
return purchaseDate;
}
public void setPurchaseDate(String purchaseDate) {
this.purchaseDate = purchaseDate;
}
public String getPurchaseDateText() {
return purchaseDateText;
}
public void setPurchaseDateText(String purchaseDateText) {
this.purchaseDateText = purchaseDateText;
}
public String getWarrantyExpiry() {
return warrantyExpiry;
}
public void setWarrantyExpiry(String warrantyExpiry) {
this.warrantyExpiry = warrantyExpiry;
}
public String getWarrantyExpiryText() {
return warrantyExpiryText;
}
public void setWarrantyExpiryText(String warrantyExpiryText) {
this.warrantyExpiryText = warrantyExpiryText;
}
public String getOnlineStatus() {
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus) {
this.onlineStatus = onlineStatus;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getContactTitle() {
return contactTitle;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
}
public List<CrmExpansionContactDTO> getContacts() {
return contacts;
}
public void setContacts(List<CrmExpansionContactDTO> contacts) {
this.contacts = contacts;
}
public Long getSupplierId() {
return supplierId;
}
public void setSupplierId(Long supplierId) {
this.supplierId = supplierId;
}
public String getSupplierName() {
return supplierName;
}
public void setSupplierName(String supplierName) {
this.supplierName = supplierName;
}
public Long getH3cContactId() {
return h3cContactId;
}
public void setH3cContactId(Long h3cContactId) {
this.h3cContactId = h3cContactId;
}
public String getH3cContactName() {
return h3cContactName;
}
public void setH3cContactName(String h3cContactName) {
this.h3cContactName = h3cContactName;
}
public String getHasExpansionOpportunity() {
return hasExpansionOpportunity;
}
public void setHasExpansionOpportunity(String hasExpansionOpportunity) {
this.hasExpansionOpportunity = hasExpansionOpportunity;
}
public Integer getSoftwarePoints() {
return softwarePoints;
}
public void setSoftwarePoints(Integer softwarePoints) {
this.softwarePoints = softwarePoints;
}
public String getExpansionTime() {
return expansionTime;
}
public void setExpansionTime(String expansionTime) {
this.expansionTime = expansionTime;
}
public String getExpansionTimeText() {
return expansionTimeText;
}
public void setExpansionTimeText(String expansionTimeText) {
this.expansionTimeText = expansionTimeText;
}
public String getExpansionScale() {
return expansionScale;
}
public void setExpansionScale(String expansionScale) {
this.expansionScale = expansionScale;
}
public String getHasMaintenanceOpportunity() {
return hasMaintenanceOpportunity;
}
public void setHasMaintenanceOpportunity(String hasMaintenanceOpportunity) {
this.hasMaintenanceOpportunity = hasMaintenanceOpportunity;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
public List<ExpansionFollowUpDTO> getFollowUps() {
return followUps;
}
public void setFollowUps(List<ExpansionFollowUpDTO> followUps) {
this.followUps = followUps;
}
}

View File

@ -0,0 +1,154 @@
package com.unis.crm.dto.expansion;
import java.time.LocalDate;
/**
* CRM
*/
public class CrmExpansionMoveSourceDTO {
private Long id;
private Long ownerUserId;
private String endUser;
private String officeName;
private String industryAttr;
private String extensionType;
private LocalDate purchaseDate;
private LocalDate warrantyExpiry;
private String onlineStatus;
private String contactName;
private String contactPhone;
private String contactTitle;
private Long supplierId;
private Long h3cContactId;
private String hasExpansionOpportunity;
private String remark;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getEndUser() {
return endUser;
}
public void setEndUser(String endUser) {
this.endUser = endUser;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public String getIndustryAttr() {
return industryAttr;
}
public void setIndustryAttr(String industryAttr) {
this.industryAttr = industryAttr;
}
public String getExtensionType() {
return extensionType;
}
public void setExtensionType(String extensionType) {
this.extensionType = extensionType;
}
public LocalDate getPurchaseDate() {
return purchaseDate;
}
public void setPurchaseDate(LocalDate purchaseDate) {
this.purchaseDate = purchaseDate;
}
public LocalDate getWarrantyExpiry() {
return warrantyExpiry;
}
public void setWarrantyExpiry(LocalDate warrantyExpiry) {
this.warrantyExpiry = warrantyExpiry;
}
public String getOnlineStatus() {
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus) {
this.onlineStatus = onlineStatus;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getContactTitle() {
return contactTitle;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
}
public Long getSupplierId() {
return supplierId;
}
public void setSupplierId(Long supplierId) {
this.supplierId = supplierId;
}
public Long getH3cContactId() {
return h3cContactId;
}
public void setH3cContactId(Long h3cContactId) {
this.h3cContactId = h3cContactId;
}
public String getHasExpansionOpportunity() {
return hasExpansionOpportunity;
}
public void setHasExpansionOpportunity(String hasExpansionOpportunity) {
this.hasExpansionOpportunity = hasExpansionOpportunity;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}

View File

@ -11,6 +11,9 @@ public class ExpansionMetaDTO {
private List<DictOptionDTO> channelAttributeOptions;
private List<DictOptionDTO> internalAttributeOptions;
private String nextChannelCode;
private List<DictOptionDTO> extensionTypeOptions;
private List<DictOptionDTO> onlineStatusOptions;
private List<DictOptionDTO> isOptions;
public ExpansionMetaDTO() {
}
@ -32,6 +35,29 @@ public class ExpansionMetaDTO {
this.nextChannelCode = nextChannelCode;
}
public ExpansionMetaDTO(
List<DictOptionDTO> officeOptions,
List<DictOptionDTO> industryOptions,
List<DictOptionDTO> provinceOptions,
List<DictOptionDTO> certificationLevelOptions,
List<DictOptionDTO> channelAttributeOptions,
List<DictOptionDTO> internalAttributeOptions,
String nextChannelCode,
List<DictOptionDTO> extensionTypeOptions,
List<DictOptionDTO> onlineStatusOptions,
List<DictOptionDTO> isOptions) {
this.officeOptions = officeOptions;
this.industryOptions = industryOptions;
this.provinceOptions = provinceOptions;
this.certificationLevelOptions = certificationLevelOptions;
this.channelAttributeOptions = channelAttributeOptions;
this.internalAttributeOptions = internalAttributeOptions;
this.nextChannelCode = nextChannelCode;
this.extensionTypeOptions = extensionTypeOptions;
this.onlineStatusOptions = onlineStatusOptions;
this.isOptions = isOptions;
}
public List<DictOptionDTO> getOfficeOptions() {
return officeOptions;
}
@ -87,4 +113,28 @@ public class ExpansionMetaDTO {
public void setNextChannelCode(String nextChannelCode) {
this.nextChannelCode = nextChannelCode;
}
public List<DictOptionDTO> getExtensionTypeOptions() {
return extensionTypeOptions;
}
public void setExtensionTypeOptions(List<DictOptionDTO> extensionTypeOptions) {
this.extensionTypeOptions = extensionTypeOptions;
}
public List<DictOptionDTO> getOnlineStatusOptions() {
return onlineStatusOptions;
}
public void setOnlineStatusOptions(List<DictOptionDTO> onlineStatusOptions) {
this.onlineStatusOptions = onlineStatusOptions;
}
public List<DictOptionDTO> getIsOptions() {
return isOptions;
}
public void setIsOptions(List<DictOptionDTO> isOptions) {
this.isOptions = isOptions;
}
}

View File

@ -6,6 +6,7 @@ public class ExpansionOverviewDTO {
private List<SalesExpansionItemDTO> salesItems;
private List<ChannelExpansionItemDTO> channelItems;
private List<CrmExpansionItemDTO> crmItems;
public ExpansionOverviewDTO() {
}
@ -15,6 +16,10 @@ public class ExpansionOverviewDTO {
this.channelItems = channelItems;
}
public ExpansionOverviewDTO(List<CrmExpansionItemDTO> crmItems) {
this.crmItems = crmItems;
}
public List<SalesExpansionItemDTO> getSalesItems() {
return salesItems;
}
@ -30,4 +35,12 @@ public class ExpansionOverviewDTO {
public void setChannelItems(List<ChannelExpansionItemDTO> channelItems) {
this.channelItems = channelItems;
}
public List<CrmExpansionItemDTO> getCrmItems() {
return crmItems;
}
public void setCrmItems(List<CrmExpansionItemDTO> crmItems) {
this.crmItems = crmItems;
}
}

View File

@ -0,0 +1,178 @@
package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.List;
/**
* CRM CRM
*/
public class MoveChannelToCrmRequest {
/** 代表处tz_bsc 字典码);若未填写,后端尝试按省份自动匹配,匹配失败则报错 */
private String officeName;
@NotBlank(message = "类型不能为空")
private String extensionType;
@NotNull(message = "采购时间不能为空")
private LocalDate purchaseDate;
@NotNull(message = "过保时间不能为空")
private LocalDate warrantyExpiry;
@NotBlank(message = "在线情况不能为空")
private String onlineStatus;
/** 进货商:下拉选中时存 id手动输入时为空名称存 supplierName */
private Long supplierId;
/** 进货商名称:下拉选中冗余存名,手动输入时存文字 */
private String supplierName;
/** 新华三对接人:下拉选中时存 id手动输入时为空名称存 h3cContactName */
private Long h3cContactId;
/** 新华三对接人名称:下拉选中冗余存名,手动输入时存文字 */
private String h3cContactName;
@NotBlank(message = "是否有扩容机会不能为空")
private String hasExpansionOpportunity;
/** 软件点数(整数,可选) */
private Integer softwarePoints;
/** 扩容时间(可选) */
private LocalDate expansionTime;
/** 扩容规模(可选) */
private String expansionScale;
/** 是否有维保项目机会sys_is1是 / 0否 */
@NotBlank(message = "是否有维保项目机会不能为空")
private String hasMaintenanceOpportunity;
/** 联系人(迁移弹窗补录);为空时后端回退使用源渠道联系人 */
private List<CrmExpansionContactRequest> contacts;
public List<CrmExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<CrmExpansionContactRequest> contacts) {
this.contacts = contacts;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public String getExtensionType() {
return extensionType;
}
public void setExtensionType(String extensionType) {
this.extensionType = extensionType;
}
public LocalDate getPurchaseDate() {
return purchaseDate;
}
public void setPurchaseDate(LocalDate purchaseDate) {
this.purchaseDate = purchaseDate;
}
public LocalDate getWarrantyExpiry() {
return warrantyExpiry;
}
public void setWarrantyExpiry(LocalDate warrantyExpiry) {
this.warrantyExpiry = warrantyExpiry;
}
public String getOnlineStatus() {
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus) {
this.onlineStatus = onlineStatus;
}
public Long getSupplierId() {
return supplierId;
}
public void setSupplierId(Long supplierId) {
this.supplierId = supplierId;
}
public String getSupplierName() {
return supplierName;
}
public void setSupplierName(String supplierName) {
this.supplierName = supplierName;
}
public Long getH3cContactId() {
return h3cContactId;
}
public void setH3cContactId(Long h3cContactId) {
this.h3cContactId = h3cContactId;
}
public String getH3cContactName() {
return h3cContactName;
}
public void setH3cContactName(String h3cContactName) {
this.h3cContactName = h3cContactName;
}
public String getHasExpansionOpportunity() {
return hasExpansionOpportunity;
}
public void setHasExpansionOpportunity(String hasExpansionOpportunity) {
this.hasExpansionOpportunity = hasExpansionOpportunity;
}
public Integer getSoftwarePoints() {
return softwarePoints;
}
public void setSoftwarePoints(Integer softwarePoints) {
this.softwarePoints = softwarePoints;
}
public LocalDate getExpansionTime() {
return expansionTime;
}
public void setExpansionTime(LocalDate expansionTime) {
this.expansionTime = expansionTime;
}
public String getExpansionScale() {
return expansionScale;
}
public void setExpansionScale(String expansionScale) {
this.expansionScale = expansionScale;
}
public String getHasMaintenanceOpportunity() {
return hasMaintenanceOpportunity;
}
public void setHasMaintenanceOpportunity(String hasMaintenanceOpportunity) {
this.hasMaintenanceOpportunity = hasMaintenanceOpportunity;
}
}

View File

@ -0,0 +1,201 @@
package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* CRM
*/
public class MoveCrmToChannelRequest {
/** 省份(迁移弹窗省市区补填);为空时后端回退用代表处(office_name)反查字典 label */
private String province;
@NotBlank(message = "请选择市")
private String city;
@NotBlank(message = "请填写办公地址")
private String officeAddress;
@NotBlank(message = "请选择汇智内部认证级别")
private String certificationLevel;
@NotNull(message = "请填写年度营业额(万元)")
private BigDecimal annualRevenue;
@NotNull(message = "请填写人员规模")
private Integer staffSize;
@NotNull(message = "请填写注册资金(万元)")
private BigDecimal registeredCapital;
@NotBlank(message = "请选择渠道属性")
private String channelAttribute;
@NotBlank(message = "请选择新华三内部属性")
private String internalAttribute;
@NotBlank(message = "请选择覆盖省份")
private String coverageProvince;
@NotBlank(message = "请选择覆盖市/区/县")
private String coverageCity;
private List<CoverageItemDTO> coverageItems;
private String intentLevel;
private Boolean hasDesktopExp;
private String stage;
private Boolean landedFlag;
private LocalDate expectedSignDate;
/** 联系人迁移弹窗补录为空时后端回退使用源CRM联系人 */
private List<ChannelExpansionContactRequest> contacts;
public List<ChannelExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<ChannelExpansionContactRequest> contacts) {
this.contacts = contacts;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getProvince() {
return province;
}
public void setProvince(String province) {
this.province = province;
}
public String getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(String officeAddress) {
this.officeAddress = officeAddress;
}
public String getCertificationLevel() {
return certificationLevel;
}
public void setCertificationLevel(String certificationLevel) {
this.certificationLevel = certificationLevel;
}
public BigDecimal getAnnualRevenue() {
return annualRevenue;
}
public void setAnnualRevenue(BigDecimal annualRevenue) {
this.annualRevenue = annualRevenue;
}
public Integer getStaffSize() {
return staffSize;
}
public void setStaffSize(Integer staffSize) {
this.staffSize = staffSize;
}
public BigDecimal getRegisteredCapital() {
return registeredCapital;
}
public void setRegisteredCapital(BigDecimal registeredCapital) {
this.registeredCapital = registeredCapital;
}
public String getChannelAttribute() {
return channelAttribute;
}
public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute;
}
public String getInternalAttribute() {
return internalAttribute;
}
public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute;
}
public String getCoverageProvince() {
return coverageProvince;
}
public void setCoverageProvince(String coverageProvince) {
this.coverageProvince = coverageProvince;
}
public String getCoverageCity() {
return coverageCity;
}
public void setCoverageCity(String coverageCity) {
this.coverageCity = coverageCity;
}
public List<CoverageItemDTO> getCoverageItems() {
return coverageItems;
}
public void setCoverageItems(List<CoverageItemDTO> coverageItems) {
this.coverageItems = coverageItems;
}
public String getIntentLevel() {
return intentLevel;
}
public void setIntentLevel(String intentLevel) {
this.intentLevel = intentLevel;
}
public Boolean getHasDesktopExp() {
return hasDesktopExp;
}
public void setHasDesktopExp(Boolean hasDesktopExp) {
this.hasDesktopExp = hasDesktopExp;
}
public String getStage() {
return stage;
}
public void setStage(String stage) {
this.stage = stage;
}
public Boolean getLandedFlag() {
return landedFlag;
}
public void setLandedFlag(Boolean landedFlag) {
this.landedFlag = landedFlag;
}
public LocalDate getExpectedSignDate() {
return expectedSignDate;
}
public void setExpectedSignDate(LocalDate expectedSignDate) {
this.expectedSignDate = expectedSignDate;
}
}

View File

@ -32,6 +32,10 @@ public class SalesExpansionItemDTO {
private String createdAt;
private String updatedAt;
private String notes;
/** 所属区域(省-市配对明细,值与"覆盖地市"同结构city 为空表示全省) */
private String regionProvince;
private String regionCity;
private String regionItems;
private java.util.List<RelatedProjectSummaryDTO> relatedProjects = new java.util.ArrayList<>();
private List<ExpansionFollowUpDTO> followUps = new ArrayList<>();
@ -251,6 +255,30 @@ public class SalesExpansionItemDTO {
this.notes = notes;
}
public String getRegionProvince() {
return regionProvince;
}
public void setRegionProvince(String regionProvince) {
this.regionProvince = regionProvince;
}
public String getRegionCity() {
return regionCity;
}
public void setRegionCity(String regionCity) {
this.regionCity = regionCity;
}
public String getRegionItems() {
return regionItems;
}
public void setRegionItems(String regionItems) {
this.regionItems = regionItems;
}
public java.util.List<RelatedProjectSummaryDTO> getRelatedProjects() {
return relatedProjects;
}

View File

@ -15,6 +15,14 @@ public class UpdateChannelExpansionRequest {
private String city;
private String certificationLevel;
@Size(max = 500, message = "覆盖省份不能超过500字符")
private String coverageProvince;
@Size(max = 2000, message = "覆盖市/区/县不能超过2000字符")
private String coverageCity;
private List<CoverageItemDTO> coverageItems;
@NotBlank(message = "渠道名称不能为空")
@Size(max = 200, message = "渠道名称不能超过200字符")
private String channelName;
@ -22,6 +30,7 @@ public class UpdateChannelExpansionRequest {
private String province;
private BigDecimal annualRevenue;
private Integer staffSize;
private BigDecimal registeredCapital;
private LocalDate contactEstablishedDate;
private String intentLevel;
private Boolean hasDesktopExp;
@ -65,6 +74,30 @@ public class UpdateChannelExpansionRequest {
this.city = city;
}
public String getCoverageProvince() {
return coverageProvince;
}
public void setCoverageProvince(String coverageProvince) {
this.coverageProvince = coverageProvince;
}
public String getCoverageCity() {
return coverageCity;
}
public void setCoverageCity(String coverageCity) {
this.coverageCity = coverageCity;
}
public List<CoverageItemDTO> getCoverageItems() {
return coverageItems;
}
public void setCoverageItems(List<CoverageItemDTO> coverageItems) {
this.coverageItems = coverageItems;
}
public String getCertificationLevel() {
return certificationLevel;
}
@ -105,6 +138,14 @@ public class UpdateChannelExpansionRequest {
this.staffSize = staffSize;
}
public BigDecimal getRegisteredCapital() {
return registeredCapital;
}
public void setRegisteredCapital(BigDecimal registeredCapital) {
this.registeredCapital = registeredCapital;
}
public LocalDate getContactEstablishedDate() {
return contactEstablishedDate;
}

View File

@ -0,0 +1,229 @@
package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class UpdateCrmExpansionRequest {
@NotBlank(message = "最终用户不能为空")
private String endUser;
@NotBlank(message = "代表处不能为空")
private String officeName;
@NotBlank(message = "行业属性不能为空")
private String industryAttr;
@NotBlank(message = "类型不能为空")
private String extensionType;
@NotNull(message = "采购时间不能为空")
private LocalDate purchaseDate;
@NotNull(message = "过保时间不能为空")
private LocalDate warrantyExpiry;
@NotBlank(message = "在线情况不能为空")
private String onlineStatus;
@NotEmpty(message = "请至少填写一位联系人")
private List<CrmExpansionContactRequest> contacts = new ArrayList<>();
/** 冗余字段:由 Service 从 contacts 第一个联系人派生,用于主表搜索/展示 */
private String contactName;
private String contactPhone;
private String contactTitle;
/** 进货商:下拉选中时存 id手动输入时为空名称存 supplierName */
private Long supplierId;
/** 进货商名称:下拉选中冗余存名,手动输入时存文字 */
private String supplierName;
/** 新华三对接人:下拉选中时存 id手动输入时为空名称存 h3cContactName */
private Long h3cContactId;
/** 新华三对接人名称:下拉选中冗余存名,手动输入时存文字 */
private String h3cContactName;
@NotBlank(message = "是否有扩容机会不能为空")
private String hasExpansionOpportunity;
/** 软件点数(整数,必填) */
@NotNull(message = "软件点数不能为空")
private Integer softwarePoints;
/** 扩容时间(可选) */
private LocalDate expansionTime;
/** 扩容规模(可选) */
private String expansionScale;
/** 是否有维保项目机会sys_is1是 / 0否 */
@NotBlank(message = "是否有维保项目机会不能为空")
private String hasMaintenanceOpportunity;
public String getEndUser() {
return endUser;
}
public void setEndUser(String endUser) {
this.endUser = endUser;
}
public String getOfficeName() {
return officeName;
}
public void setOfficeName(String officeName) {
this.officeName = officeName;
}
public String getIndustryAttr() {
return industryAttr;
}
public void setIndustryAttr(String industryAttr) {
this.industryAttr = industryAttr;
}
public String getExtensionType() {
return extensionType;
}
public void setExtensionType(String extensionType) {
this.extensionType = extensionType;
}
public LocalDate getPurchaseDate() {
return purchaseDate;
}
public void setPurchaseDate(LocalDate purchaseDate) {
this.purchaseDate = purchaseDate;
}
public LocalDate getWarrantyExpiry() {
return warrantyExpiry;
}
public void setWarrantyExpiry(LocalDate warrantyExpiry) {
this.warrantyExpiry = warrantyExpiry;
}
public String getOnlineStatus() {
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus) {
this.onlineStatus = onlineStatus;
}
public List<CrmExpansionContactRequest> getContacts() {
return contacts;
}
public void setContacts(List<CrmExpansionContactRequest> contacts) {
this.contacts = contacts;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getContactTitle() {
return contactTitle;
}
public void setContactTitle(String contactTitle) {
this.contactTitle = contactTitle;
}
public Long getSupplierId() {
return supplierId;
}
public void setSupplierId(Long supplierId) {
this.supplierId = supplierId;
}
public String getSupplierName() {
return supplierName;
}
public void setSupplierName(String supplierName) {
this.supplierName = supplierName;
}
public Long getH3cContactId() {
return h3cContactId;
}
public void setH3cContactId(Long h3cContactId) {
this.h3cContactId = h3cContactId;
}
public String getH3cContactName() {
return h3cContactName;
}
public void setH3cContactName(String h3cContactName) {
this.h3cContactName = h3cContactName;
}
public String getHasExpansionOpportunity() {
return hasExpansionOpportunity;
}
public void setHasExpansionOpportunity(String hasExpansionOpportunity) {
this.hasExpansionOpportunity = hasExpansionOpportunity;
}
public Integer getSoftwarePoints() {
return softwarePoints;
}
public void setSoftwarePoints(Integer softwarePoints) {
this.softwarePoints = softwarePoints;
}
public LocalDate getExpansionTime() {
return expansionTime;
}
public void setExpansionTime(LocalDate expansionTime) {
this.expansionTime = expansionTime;
}
public String getExpansionScale() {
return expansionScale;
}
public void setExpansionScale(String expansionScale) {
this.expansionScale = expansionScale;
}
public String getHasMaintenanceOpportunity() {
return hasMaintenanceOpportunity;
}
public void setHasMaintenanceOpportunity(String hasMaintenanceOpportunity) {
this.hasMaintenanceOpportunity = hasMaintenanceOpportunity;
}
}

View File

@ -3,13 +3,25 @@ package com.unis.crm.dto.expansion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
public class UpdateSalesExpansionRequest {
/** 所属区域(省-市配对明细多选city 为空表示全省) */
private List<CoverageItemDTO> regionItems;
@NotBlank(message = "工号不能为空")
@Size(max = 50, message = "工号不能超过50字符")
private String employeeNo;
public List<CoverageItemDTO> getRegionItems() {
return regionItems;
}
public void setRegionItems(List<CoverageItemDTO> regionItems) {
this.regionItems = regionItems;
}
@NotBlank(message = "候选人姓名不能为空")
@Size(max = 50, message = "候选人姓名不能超过50字符")
private String candidateName;

View File

@ -0,0 +1,82 @@
package com.unis.crm.dto.opportunity;
import java.util.List;
public class OpportunityDuplicateCheckDTO {
private boolean duplicate;
private List<Item> items;
public OpportunityDuplicateCheckDTO() {
}
public OpportunityDuplicateCheckDTO(boolean duplicate, List<Item> items) {
this.duplicate = duplicate;
this.items = items;
}
public boolean isDuplicate() {
return duplicate;
}
public void setDuplicate(boolean duplicate) {
this.duplicate = duplicate;
}
public List<Item> getItems() {
return items;
}
public void setItems(List<Item> items) {
this.items = items;
}
public static class Item {
private Long opportunityId;
private String opportunityCode;
private String opportunityName;
private String customerName;
private String stage;
public Long getOpportunityId() {
return opportunityId;
}
public void setOpportunityId(Long opportunityId) {
this.opportunityId = opportunityId;
}
public String getOpportunityCode() {
return opportunityCode;
}
public void setOpportunityCode(String opportunityCode) {
this.opportunityCode = opportunityCode;
}
public String getOpportunityName() {
return opportunityName;
}
public void setOpportunityName(String opportunityName) {
this.opportunityName = opportunityName;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
public String getStage() {
return stage;
}
public void setStage(String stage) {
this.stage = stage;
}
}
}

View File

@ -5,6 +5,7 @@ import java.util.List;
public class OpportunityMetaDTO {
private List<OpportunityDictOptionDTO> stageOptions;
private List<OpportunityDictOptionDTO> allStageOptions;
private List<OpportunityDictOptionDTO> operatorOptions;
private List<OpportunityDictOptionDTO> projectLocationOptions;
private List<OpportunityDictOptionDTO> projectOwnershipLocationOptions;
@ -40,6 +41,14 @@ public class OpportunityMetaDTO {
this.stageOptions = stageOptions;
}
public List<OpportunityDictOptionDTO> getAllStageOptions() {
return allStageOptions;
}
public void setAllStageOptions(List<OpportunityDictOptionDTO> allStageOptions) {
this.allStageOptions = allStageOptions;
}
public List<OpportunityDictOptionDTO> getOperatorOptions() {
return operatorOptions;
}

View File

@ -0,0 +1,68 @@
package com.unis.crm.mapper;
import com.unis.crm.dto.expansion.CreateCrmExpansionRequest;
import com.unis.crm.dto.expansion.CrmExpansionContactDTO;
import com.unis.crm.dto.expansion.CrmExpansionContactRequest;
import com.unis.crm.dto.expansion.CrmExpansionItemDTO;
import com.unis.crm.dto.expansion.CrmExpansionMoveSourceDTO;
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
import com.unis.crm.dto.expansion.UpdateCrmExpansionRequest;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.unisbase.annotation.DataScope;
@Mapper
public interface CrmExpansionMapper {
@DataScope(tableAlias = "crm", ownerColumn = "owner_user_id")
List<CrmExpansionItemDTO> selectCrmExpansions(
@Param("userId") Long userId,
@Param("keyword") String keyword,
@Param("limit") Integer limit);
List<CrmExpansionItemDTO> selectCrmExpansionsByOwnerUserIds(
@Param("ownerUserIds") List<Long> ownerUserIds,
@Param("keyword") String keyword,
@Param("limit") Integer limit);
int insertCrmExpansion(
@Param("userId") Long userId,
@Param("request") CreateCrmExpansionRequest request);
@DataScope(tableAlias = "crm", ownerColumn = "owner_user_id")
int updateCrmExpansion(
@Param("userId") Long userId,
@Param("id") Long id,
@Param("request") UpdateCrmExpansionRequest request);
@DataScope(tableAlias = "crm", ownerColumn = "owner_user_id")
int countOwnedCrmExpansion(
@Param("userId") Long userId,
@Param("id") Long id);
int countCrmExpansionByEndUser(@Param("endUser") String endUser);
int countCrmExpansionByEndUserExcludingId(@Param("endUser") String endUser, @Param("excludeId") Long excludeId);
List<ExpansionFollowUpDTO> selectCrmExpansionFollowUps(
@Param("userId") Long userId,
@Param("bizIds") List<Long> bizIds);
List<CrmExpansionContactDTO> selectCrmExpansionContacts(
@Param("userId") Long userId,
@Param("crmExpansionIds") List<Long> crmExpansionIds);
int insertCrmExpansionContact(
@Param("crmExpansionId") Long crmExpansionId,
@Param("sortOrder") int sortOrder,
@Param("contact") CrmExpansionContactRequest contact);
int deleteCrmExpansionContacts(@Param("crmExpansionId") Long crmExpansionId);
CrmExpansionMoveSourceDTO selectCrmExpansionForMove(@Param("id") Long id);
List<CrmExpansionContactDTO> selectCrmExpansionContactsForMove(@Param("crmExpansionId") Long crmExpansionId);
int deleteCrmExpansion(@Param("id") Long id);
}

View File

@ -0,0 +1,24 @@
package com.unis.crm.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CrmOmsDictMappingMapper {
/**
* CRMOMS
* @param dictType sj_xmjd
* @param crmValue CRM
* @return OMSnull
*/
String selectOmsValueByCrmValue(@Param("dictType") String dictType, @Param("crmValue") String crmValue);
/**
* OMSCRM
* @param dictType sj_xmjd
* @param omsValue OMS
* @return CRMnull
*/
String selectCrmValueByOmsValue(@Param("dictType") String dictType, @Param("omsValue") String omsValue);
}

View File

@ -3,7 +3,9 @@ package com.unis.crm.mapper;
import com.unis.crm.dto.expansion.ChannelExpansionItemDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.ChannelExpansionMoveSourceDTO;
import com.unis.crm.dto.expansion.ChannelRelatedProjectSummaryDTO;
import com.unis.crm.dto.expansion.CoverageItemDTO;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
@ -94,6 +96,14 @@ public interface ExpansionMapper {
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
int deleteChannelCoverage(@Param("channelId") Long channelId);
int insertChannelCoverage(@Param("channelId") Long channelId, @Param("items") List<CoverageItemDTO> items);
int deleteSalesRegion(@Param("salesExpansionId") Long salesExpansionId);
int insertSalesRegion(@Param("salesExpansionId") Long salesExpansionId, @Param("items") List<CoverageItemDTO> items);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
int countOwnedSalesExpansion(@Param("userId") Long userId, @Param("id") Long id);
@ -105,4 +115,34 @@ public interface ExpansionMapper {
@Param("bizId") Long bizId,
@Param("userId") Long userId,
@Param("request") CreateExpansionFollowUpRequest request);
ChannelExpansionMoveSourceDTO selectChannelExpansionForMove(@Param("id") Long id);
List<ChannelExpansionContactDTO> selectChannelContactsForMove(@Param("channelExpansionId") Long channelExpansionId);
int updateFollowUpBiz(
@Param("oldBizType") String oldBizType,
@Param("oldBizId") Long oldBizId,
@Param("newBizType") String newBizType,
@Param("newBizId") Long newBizId);
int updateCheckinBiz(
@Param("oldBizType") String oldBizType,
@Param("oldBizId") Long oldBizId,
@Param("newBizType") String newBizType,
@Param("newBizId") Long newBizId,
@Param("newBizName") String newBizName);
int updateReportMessageBiz(
@Param("oldBizType") String oldBizType,
@Param("oldBizId") Long oldBizId,
@Param("newBizType") String newBizType,
@Param("newBizId") Long newBizId,
@Param("newBizName") String newBizName);
int clearOpportunityChannelExpansion(@Param("channelId") Long channelId);
int clearCrmSupplierRefs(@Param("channelId") Long channelId);
int deleteChannelExpansion(@Param("id") Long id);
}

View File

@ -5,6 +5,7 @@ import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
@ -21,6 +22,8 @@ public interface OpportunityMapper {
List<OpportunityDictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
List<OpportunityDictOptionDTO> selectAllDictItems(@Param("typeCode") String typeCode);
List<OpportunityDictOptionDTO> selectProvinceAreaOptions();
List<OpportunityDictOptionDTO> selectProjectOwnershipLocationOptions();
@ -31,6 +34,13 @@ public interface OpportunityMapper {
@Param("typeCode") String typeCode,
@Param("itemValue") String itemValue);
/**
*
*/
String selectDictLabelIgnoreStatus(
@Param("typeCode") String typeCode,
@Param("itemValue") String itemValue);
String selectDictValueByLabel(
@Param("typeCode") String typeCode,
@Param("itemLabel") String itemLabel);
@ -129,6 +139,11 @@ public interface OpportunityMapper {
OpportunityIntegrationTargetDTO selectOpportunityIntegrationTarget(
@Param("opportunityCode") String opportunityCode);
List<OpportunityDuplicateCheckDTO.Item> selectDuplicateOpportunities(
@Param("name") String name,
@Param("excludeId") Long excludeId,
@Param("excludeStageCodes") List<String> excludeStageCodes);
int updateOpportunityByIntegration(
@Param("opportunityId") Long opportunityId,
@Param("request") UpdateOpportunityIntegrationRequest request);

View File

@ -0,0 +1,79 @@
package com.unis.crm.service;
import com.unis.crm.common.BusinessException;
import com.unis.crm.mapper.CrmOmsDictMappingMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class CrmOmsDictMappingService {
private static final Logger log = LoggerFactory.getLogger(CrmOmsDictMappingService.class);
private final CrmOmsDictMappingMapper mappingMapper;
public CrmOmsDictMappingService(CrmOmsDictMappingMapper mappingMapper) {
this.mappingMapper = mappingMapper;
}
/**
* CRMOMS
* @param dictType
* @param crmValue CRM
* @return OMSnull
*/
public String getOmsValue(String dictType, String crmValue) {
if (dictType == null || crmValue == null) {
return null;
}
return mappingMapper.selectOmsValueByCrmValue(dictType, crmValue);
}
/**
* OMSCRM
* @param dictType
* @param omsValue OMS
* @return CRMnull
*/
public String getCrmValue(String dictType, String omsValue) {
if (dictType == null || omsValue == null) {
return null;
}
return mappingMapper.selectCrmValueByOmsValue(dictType, omsValue);
}
/**
* (sj_xmjd)CRMOMS
* @param crmStageValue CRM (e.g., S3)
* @return OMS (e.g., S4A)
*/
public String mapStageToOms(String crmStageValue) {
if (crmStageValue == null) {
return null;
}
String omsValue = getOmsValue("sj_xmjd", crmStageValue);
if (omsValue == null) {
// 降级处理:未配置映射时按原值透传并告警,避免阻断新增商机/推送
log.warn("项目阶段【{}】未在 crm_oms_dict_mapping 映射表中配置,已按原值透传推送", crmStageValue);
return crmStageValue;
}
return omsValue;
}
/**
* (sj_xmjd)OMSCRM
* @param omsStageValue OMS (e.g., S4A)
* @return CRM (e.g., S3)
*/
public String mapStageToCrm(String omsStageValue) {
if (omsStageValue == null) {
return null;
}
String crmValue = getCrmValue("sj_xmjd", omsStageValue);
if (crmValue == null) {
throw new BusinessException("OMS 反写的项目阶段【" + omsStageValue + "】未在 crm_oms_dict_mapping 映射表中配置,请先在映射表中维护后再反写");
}
return crmValue;
}
}

View File

@ -205,7 +205,97 @@ public class DashboardAnalyticsConfigService {
if (tenantId == null || tenantId <= 0 || currentUserId == null || currentUserId <= 0) {
return disabledPanel();
}
return buildPanel(tenantId, currentUserId, false);
DashboardAnalyticsConfigDTO config = loadConfig(tenantId);
DashboardAnalyticsPanelDTO panel = new DashboardAnalyticsPanelDTO();
panel.setEnabled(config.getEnabled());
panel.setTitle(config.getTitle());
panel.setSubtitle(config.getSubtitle());
panel.setEmptyStateText(config.getEmptyStateText());
panel.setCards(Boolean.TRUE.equals(config.getEnabled())
? buildPanelStructure(config)
: List.of());
return panel;
}
public List<DashboardAnalyticsCardDTO> getDashboardCardData(Long currentUserId, List<String> cardKeys) {
Long tenantId = tenantProvider.getCurrentTenantId();
if (tenantId == null || tenantId <= 0 || currentUserId == null || currentUserId <= 0) {
return List.of();
}
if (cardKeys == null || cardKeys.isEmpty()) {
return List.of();
}
Set<String> requestedKeys = new LinkedHashSet<>(cardKeys);
DashboardAnalyticsConfigDTO config = loadConfig(tenantId);
List<DashboardAnalyticsCardDTO> cards = new ArrayList<>();
for (DashboardAnalyticsCardConfigDTO cardConfig : config.getCards()) {
if (!requestedKeys.contains(cardConfig.getCardKey())) {
continue;
}
if (!Boolean.TRUE.equals(cardConfig.getEnabled()) || !isSupportedStoredCard(cardConfig)) {
continue;
}
try {
cards.add(executeCard(tenantId, currentUserId, cardConfig, true, null));
} catch (Exception exception) {
log.warn("Failed to execute dashboard analytics card {} for tenant {}", cardConfig.getCardKey(), tenantId, exception);
cards.add(buildErrorCard(cardConfig, exception));
}
}
return cards;
}
private List<DashboardAnalyticsCardDTO> buildPanelStructure(DashboardAnalyticsConfigDTO config) {
List<DashboardAnalyticsCardDTO> cards = new ArrayList<>();
for (DashboardAnalyticsCardConfigDTO cardConfig : config.getCards()) {
if (!Boolean.TRUE.equals(cardConfig.getEnabled()) || !isSupportedStoredCard(cardConfig)) {
continue;
}
DashboardAnalyticsCardDTO dto = new DashboardAnalyticsCardDTO();
dto.setId(cardConfig.getId());
dto.setCardKey(cardConfig.getCardKey());
dto.setGroupName(cardConfig.getGroupName());
dto.setTitle(cardConfig.getTitle());
dto.setSubtitle(cardConfig.getSubtitle());
dto.setRenderType(normalizeRenderType(cardConfig.getRenderType()));
dto.setValueType(cardConfig.getValueType());
dto.setUnit(cardConfig.getUnit());
dto.setDisplayTextConfig(cardConfig.getDisplayTextConfig());
dto.setLinkPath(cardConfig.getLinkPath());
dto.setLayoutType(cardConfig.getLayoutType());
dto.setFullRow(cardConfig.getFullRow());
dto.setSortOrder(cardConfig.getSortOrder());
dto.setDataLoaded(false);
dto.setHasMore(false);
dto.setTotalCount(0);
dto.setChartData(List.of());
cards.add(dto);
}
return cards;
}
private DashboardAnalyticsCardDTO buildErrorCard(DashboardAnalyticsCardConfigDTO cardConfig, Exception exception) {
DashboardAnalyticsCardDTO failed = new DashboardAnalyticsCardDTO();
failed.setId(cardConfig.getId());
failed.setCardKey(cardConfig.getCardKey());
failed.setTitle(cardConfig.getTitle());
failed.setSubtitle(cardConfig.getSubtitle());
failed.setRenderType(normalizeRenderType(cardConfig.getRenderType()));
failed.setValue("0");
failed.setValueText("查询失败");
failed.setValueType(cardConfig.getValueType());
failed.setUnit(cardConfig.getUnit());
failed.setDisplayTextConfig(cardConfig.getDisplayTextConfig());
failed.setLinkPath(cardConfig.getLinkPath());
failed.setLayoutType(cardConfig.getLayoutType());
failed.setFullRow(cardConfig.getFullRow());
failed.setSortOrder(cardConfig.getSortOrder());
failed.setDataLoaded(true);
failed.setErrorMessage(exception.getMessage());
failed.setHasMore(false);
failed.setTotalCount(0);
failed.setChartData(List.of());
return failed;
}
public DashboardAnalyticsCardDTO previewCard(Long tenantId, String cardKey, String dimension) {
@ -322,6 +412,7 @@ public class DashboardAnalyticsConfigService {
dto.setLayoutType(config.getLayoutType());
dto.setFullRow(config.getFullRow());
dto.setSortOrder(config.getSortOrder());
dto.setDataLoaded(true);
if ("metric".equals(renderType)) {
Object valueObject = readValue(row, config.getValueField(), "value");
Object descriptionObject = readValue(row, config.getDescriptionField(), "description");

View File

@ -2,6 +2,7 @@ package com.unis.crm.service;
import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO;
import com.unis.crm.dto.dashboard.DashboardHomeDTO;
import java.util.List;
public interface DashboardService {
@ -12,4 +13,6 @@ public interface DashboardService {
void readMessage(Long userId, Long messageId);
DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey, String dimension);
List<DashboardAnalyticsCardDTO> getAnalyticsCardData(Long userId, List<String> cardKeys);
}

View File

@ -1,13 +1,17 @@
package com.unis.crm.service;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateCrmExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.MoveChannelToCrmRequest;
import com.unis.crm.dto.expansion.MoveCrmToChannelRequest;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateCrmExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import java.util.List;
@ -29,6 +33,8 @@ public interface ExpansionService {
ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId);
ExpansionDuplicateCheckDTO checkCrmEndUserDuplicate(Long userId, String endUser, Long excludeId);
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request);
@ -38,4 +44,14 @@ public interface ExpansionService {
void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request);
Long createFollowUp(Long userId, String bizType, Long bizId, CreateExpansionFollowUpRequest request);
ExpansionOverviewDTO getCrmOverview(Long userId, String keyword, boolean includeDetails, Integer limit);
Long createCrmExpansion(Long userId, CreateCrmExpansionRequest request);
void updateCrmExpansion(Long userId, Long id, UpdateCrmExpansionRequest request);
Long moveChannelToCrm(Long userId, Long channelId, MoveChannelToCrmRequest request);
Long moveCrmToChannel(Long userId, Long crmId, MoveCrmToChannelRequest request);
}

View File

@ -42,10 +42,12 @@ public class OmsClient {
private final OmsProperties omsProperties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final CrmOmsDictMappingService dictMappingService;
public OmsClient(OmsProperties omsProperties, ObjectMapper objectMapper) {
public OmsClient(OmsProperties omsProperties, ObjectMapper objectMapper, CrmOmsDictMappingService dictMappingService) {
this.omsProperties = omsProperties;
this.objectMapper = objectMapper;
this.dictMappingService = dictMappingService;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(Math.max(1, omsProperties.getConnectTimeoutSeconds())))
.build();
@ -113,7 +115,9 @@ public class OmsClient {
payload.put("estimatedAmount", decimalText(opportunity.getAmount()));
payload.put("estimatedOrderTime", opportunity.getExpectedCloseDate());
payload.put("projectGraspDegree", mapProjectGraspDegree(opportunity.getConfidencePct()));
payload.put("projectStage", opportunity.getStage());
// 转换项目阶段为OMS码值
String omsStage = dictMappingService.mapStageToOms(opportunity.getStage());
payload.put("projectStage", omsStage);
payload.put("competitorList", splitMultiValue(opportunity.getCompetitorName()));
payload.put("hzSupportUser", longText(opportunity.getPreSalesId()));
payload.put("createBy", defaultText(createBy, ""));

View File

@ -3,6 +3,7 @@ package com.unis.crm.service;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
@ -26,6 +27,8 @@ public interface OpportunityService {
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
OpportunityDuplicateCheckDTO checkDuplicateOpportunity(Long userId, String name, Long excludeId, List<String> excludeStageCodes);
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
Long createOpportunity(Long userId, CreateOpportunityRequest request);

View File

@ -146,13 +146,9 @@ public class DashboardServiceImpl implements DashboardService {
try {
return dashboardAnalyticsConfigService.getDashboardCardDetail(userId, cardKey, dimension);
} catch (Exception exception) {
DashboardAnalyticsPanelDTO panel = dashboardAnalyticsConfigService.getDashboardPanel(userId);
DashboardAnalyticsCardDTO fallbackCard = panel.getCards() == null
? null
: panel.getCards().stream()
.filter(item -> item != null && cardKey != null && cardKey.equals(item.getCardKey()))
.findFirst()
.orElse(null);
List<DashboardAnalyticsCardDTO> fallbackCards = dashboardAnalyticsConfigService.getDashboardCardData(
userId, cardKey == null ? List.of() : List.of(cardKey));
DashboardAnalyticsCardDTO fallbackCard = fallbackCards.isEmpty() ? null : fallbackCards.get(0);
if (fallbackCard != null) {
return fallbackCard;
}
@ -160,6 +156,18 @@ public class DashboardServiceImpl implements DashboardService {
}
}
@Override
public List<DashboardAnalyticsCardDTO> getAnalyticsCardData(Long userId, List<String> cardKeys) {
if (userId == null) {
throw new UnauthorizedException("未获取到当前登录用户,禁止查询他人数据");
}
Set<String> permissionCodes = loadPermissionCodes(userId);
if (!permissionCodes.contains(DASHBOARD_ANALYTICS_CARD_VIEW_PERMISSION)) {
throw new UnauthorizedException("无权查看经营分析卡片详情");
}
return dashboardAnalyticsConfigService.getDashboardCardData(userId, cardKeys);
}
private Set<String> loadPermissionCodes(Long userId) {
try {
Long tenantId = tenantProvider.getCurrentTenantId();

View File

@ -3,20 +3,32 @@ package com.unis.crm.service.impl;
import com.unis.crm.common.BusinessException;
import com.unis.crm.common.UnauthorizedException;
import com.unis.crm.dto.expansion.ChannelExpansionItemDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactDTO;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.ChannelExpansionMoveSourceDTO;
import com.unis.crm.dto.expansion.ChannelRelatedProjectSummaryDTO;
import com.unis.crm.dto.expansion.CoverageItemDTO;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateCrmExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.CrmExpansionContactDTO;
import com.unis.crm.dto.expansion.CrmExpansionContactRequest;
import com.unis.crm.dto.expansion.CrmExpansionItemDTO;
import com.unis.crm.dto.expansion.CrmExpansionMoveSourceDTO;
import com.unis.crm.dto.expansion.DictOptionDTO;
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.MoveChannelToCrmRequest;
import com.unis.crm.dto.expansion.MoveCrmToChannelRequest;
import com.unis.crm.dto.expansion.RelatedProjectSummaryDTO;
import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateCrmExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.CrmExpansionMapper;
import com.unis.crm.mapper.ExpansionMapper;
import com.unis.crm.service.CrmDataVisibilityService;
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
@ -37,6 +49,7 @@ import java.util.ArrayList;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -46,27 +59,34 @@ public class ExpansionServiceImpl implements ExpansionService {
private static final int OPPORTUNITY_FORM_DEFAULT_LIMIT = 20;
private static final String SALES_DUPLICATE_MESSAGE = "工号重复,请确认该人员是否已存在!";
private static final String CHANNEL_DUPLICATE_MESSAGE = "渠道重复,请确认该渠道是否已存在!";
private static final String CRM_DUPLICATE_MESSAGE = "最终用户重复,请确认该客户是否已存在!";
private static final String OFFICE_TYPE_CODE = "tz_bsc";
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
private static final String CRM_EXTENSION_TYPE_CODE = "crm_extension_type";
private static final String ONLINE_STATUS_TYPE_CODE = "crm_online_status";
private static final String IS_TYPE_CODE = "sys_is";
private static final String CREATE_PERMISSION = "expansion:create";
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
private static final DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
private final ExpansionMapper expansionMapper;
private final CrmExpansionMapper crmExpansionMapper;
private final SpringSecurityTenantProvider tenantProvider;
private final PermissionService permissionService;
private final CrmDataVisibilityService crmDataVisibilityService;
public ExpansionServiceImpl(
ExpansionMapper expansionMapper,
CrmExpansionMapper crmExpansionMapper,
SpringSecurityTenantProvider tenantProvider,
PermissionService permissionService,
CrmDataVisibilityService crmDataVisibilityService) {
this.expansionMapper = expansionMapper;
this.crmExpansionMapper = crmExpansionMapper;
this.tenantProvider = tenantProvider;
this.permissionService = permissionService;
this.crmDataVisibilityService = crmDataVisibilityService;
@ -82,7 +102,10 @@ public class ExpansionServiceImpl implements ExpansionService {
loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE),
loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE),
loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE),
expansionMapper.selectNextChannelCode());
expansionMapper.selectNextChannelCode(),
loadDictOptions(CRM_EXTENSION_TYPE_CODE),
loadDictOptions(ONLINE_STATUS_TYPE_CODE),
loadDictOptions(IS_TYPE_CODE));
} catch (Exception ex) {
log.warn("Failed to load expansion dict options, fallback to empty list", ex);
return new ExpansionMetaDTO(
@ -92,7 +115,10 @@ public class ExpansionServiceImpl implements ExpansionService {
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
null);
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
}
}
@ -210,11 +236,21 @@ public class ExpansionServiceImpl implements ExpansionService {
if (userId == null || userId <= 0) {
throw new BusinessException("登录用户不存在");
}
Long tenantId = requireTenantId();
String normalizedKeyword = normalizeKeyword(keyword);
int normalizedLimit = normalizeOpportunityFormLimit(limit);
List<SalesExpansionItemDTO> salesItems = expansionMapper.selectSalesExpansionsForTenant(tenantId, normalizedKeyword, normalizedLimit);
List<ChannelExpansionItemDTO> channelItems = expansionMapper.selectChannelExpansionsForTenant(tenantId, normalizedKeyword, normalizedLimit);
List<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
List<SalesExpansionItemDTO> salesItems = mergeSalesExpansionItems(
expansionMapper.selectSalesExpansions(userId, normalizedKeyword, normalizedLimit),
extraVisibleOwnerUserIds.isEmpty()
? List.of()
: expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit));
List<ChannelExpansionItemDTO> channelItems = mergeChannelExpansionItems(
expansionMapper.selectChannelExpansions(userId, normalizedKeyword, normalizedLimit),
extraVisibleOwnerUserIds.isEmpty()
? List.of()
: expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit));
salesItems = new ArrayList<>(salesItems.subList(0, Math.min(normalizedLimit, salesItems.size())));
channelItems = new ArrayList<>(channelItems.subList(0, Math.min(normalizedLimit, channelItems.size())));
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@ -245,14 +281,33 @@ public class ExpansionServiceImpl implements ExpansionService {
}
@Override
public ExpansionDuplicateCheckDTO checkCrmEndUserDuplicate(Long userId, String endUser, Long excludeId) {
String normalizedEndUser = trimToNull(endUser);
if (normalizedEndUser == null) {
return new ExpansionDuplicateCheckDTO(false, null);
}
int count = excludeId == null
? crmExpansionMapper.countCrmExpansionByEndUser(normalizedEndUser)
: crmExpansionMapper.countCrmExpansionByEndUserExcludingId(normalizedEndUser, excludeId);
return new ExpansionDuplicateCheckDTO(count > 0, count > 0 ? CRM_DUPLICATE_MESSAGE : null);
}
@Override
@Transactional
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
requirePermission(CREATE_PERMISSION, "无权新增拓展");
fillSalesDefaults(request);
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), null);
expansionMapper.insertSalesExpansion(userId, request);
try {
expansionMapper.insertSalesExpansion(userId, request);
} catch (DuplicateKeyException e) {
throw new BusinessException(SALES_DUPLICATE_MESSAGE);
}
if (request.getId() == null) {
throw new BusinessException("销售拓展新增失败");
}
replaceSalesRegion(request.getId(), request.getRegionItems());
return request.getId();
}
@ -262,32 +317,204 @@ public class ExpansionServiceImpl implements ExpansionService {
requirePermission(CREATE_PERMISSION, "无权新增拓展");
fillChannelDefaults(request);
ensureUniqueChannelName(userId, request.getChannelName(), null);
expansionMapper.insertChannelExpansion(userId, request);
try {
expansionMapper.insertChannelExpansion(userId, request);
} catch (DuplicateKeyException e) {
throw new BusinessException(CHANNEL_DUPLICATE_MESSAGE);
}
if (request.getId() == null) {
throw new BusinessException("渠道拓展新增失败");
}
replaceChannelContacts(request.getId(), request.getContacts());
replaceChannelCoverage(request.getId(), request.getCoverageItems());
return request.getId();
}
@Override
@Transactional
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
fillSalesDefaults(request);
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), id);
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的销售拓展记录");
}
replaceSalesRegion(id, request.getRegionItems());
}
@Override
@Transactional
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
fillChannelDefaults(request);
ensureUniqueChannelName(userId, request.getChannelName(), id);
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的渠道拓展记录");
}
replaceChannelContacts(id, request.getContacts());
replaceChannelCoverage(id, request.getCoverageItems());
}
@Override
public ExpansionOverviewDTO getCrmOverview(Long userId, String keyword, boolean includeDetails, Integer limit) {
String normalizedKeyword = normalizeKeyword(keyword);
Integer normalizedLimit = limit != null && limit > 0 ? limit : null;
List<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
List<CrmExpansionItemDTO> items = mergeCrmExpansionItems(
crmExpansionMapper.selectCrmExpansions(userId, normalizedKeyword, normalizedLimit),
extraVisibleOwnerUserIds.isEmpty()
? List.of()
: crmExpansionMapper.selectCrmExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit));
if (normalizedLimit != null) {
items = new ArrayList<>(items.subList(0, Math.min(normalizedLimit, items.size())));
}
if (includeDetails) {
attachCrmExpansionFollowUps(userId, items);
}
attachCrmExpansionContacts(userId, items);
fillCrmDisplayFields(items);
return new ExpansionOverviewDTO(items);
}
@Override
@Transactional
public Long createCrmExpansion(Long userId, CreateCrmExpansionRequest request) {
requirePermission(CREATE_PERMISSION, "无权新增拓展");
fillCrmDefaults(request);
ensureUniqueEndUser(userId, request.getEndUser(), null);
try {
crmExpansionMapper.insertCrmExpansion(userId, request);
} catch (DuplicateKeyException e) {
throw new BusinessException(CRM_DUPLICATE_MESSAGE);
}
if (request.getId() == null) {
throw new BusinessException("CRM拓展新增失败");
}
replaceCrmExpansionContacts(request.getId(), request.getContacts());
return request.getId();
}
@Override
@Transactional
public void updateCrmExpansion(Long userId, Long id, UpdateCrmExpansionRequest request) {
fillCrmDefaults(request);
ensureUniqueEndUser(userId, request.getEndUser(), id);
int updated = crmExpansionMapper.updateCrmExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的CRM拓展记录");
}
replaceCrmExpansionContacts(id, request.getContacts());
}
@Override
@Transactional
public Long moveChannelToCrm(Long userId, Long channelId, MoveChannelToCrmRequest request) {
requirePermission(CREATE_PERMISSION, "无权新增拓展");
ChannelExpansionMoveSourceDTO source = expansionMapper.selectChannelExpansionForMove(channelId);
if (source == null) {
throw new BusinessException("记录不存在或已被迁移");
}
if (!Objects.equals(source.getOwnerUserId(), userId)) {
throw new BusinessException("无权操作该拓展记录");
}
if (request.getSupplierId() != null && Objects.equals(request.getSupplierId(), channelId)) {
throw new BusinessException("进货商不能选择被迁移的渠道本身");
}
CreateCrmExpansionRequest target = new CreateCrmExpansionRequest();
target.setEndUser(source.getName());
target.setOfficeName(resolveCrmOfficeName(request.getOfficeName(), source.getProvince()));
target.setIndustryAttr(source.getChannelIndustry());
target.setExtensionType(request.getExtensionType());
target.setPurchaseDate(request.getPurchaseDate());
target.setWarrantyExpiry(request.getWarrantyExpiry());
target.setOnlineStatus(request.getOnlineStatus());
target.setSupplierId(request.getSupplierId());
target.setSupplierName(request.getSupplierName());
target.setH3cContactId(request.getH3cContactId());
target.setH3cContactName(request.getH3cContactName());
target.setHasExpansionOpportunity(request.getHasExpansionOpportunity());
target.setSoftwarePoints(request.getSoftwarePoints());
target.setExpansionTime(request.getExpansionTime());
target.setExpansionScale(request.getExpansionScale());
target.setHasMaintenanceOpportunity(request.getHasMaintenanceOpportunity());
target.setRemark(source.getRemark());
List<CrmExpansionContactRequest> requestedContacts = request.getContacts();
List<CrmExpansionContactRequest> sourceContacts = toCrmContactRequests(expansionMapper.selectChannelContactsForMove(channelId));
target.setContacts((requestedContacts != null && !requestedContacts.isEmpty()) ? requestedContacts : sourceContacts);
fillCrmDefaults(target);
ensureUniqueEndUser(userId, target.getEndUser(), null);
crmExpansionMapper.insertCrmExpansion(userId, target);
if (target.getId() == null) {
throw new BusinessException("CRM拓展新增失败");
}
replaceCrmExpansionContacts(target.getId(), target.getContacts());
Long newId = target.getId();
String newName = target.getEndUser();
expansionMapper.updateFollowUpBiz("channel", channelId, "crm", newId);
expansionMapper.updateCheckinBiz("channel", channelId, "crm", newId, newName);
expansionMapper.updateReportMessageBiz("channel", channelId, "crm", newId, newName);
expansionMapper.clearOpportunityChannelExpansion(channelId);
expansionMapper.clearCrmSupplierRefs(channelId);
expansionMapper.deleteChannelExpansion(channelId);
return newId;
}
@Override
@Transactional
public Long moveCrmToChannel(Long userId, Long crmId, MoveCrmToChannelRequest request) {
requirePermission(CREATE_PERMISSION, "无权新增拓展");
CrmExpansionMoveSourceDTO source = crmExpansionMapper.selectCrmExpansionForMove(crmId);
if (source == null) {
throw new BusinessException("记录不存在或已被迁移");
}
if (!Objects.equals(source.getOwnerUserId(), userId)) {
throw new BusinessException("无权操作该拓展记录");
}
CreateChannelExpansionRequest target = new CreateChannelExpansionRequest();
target.setChannelName(source.getEndUser());
// 省份:优先取迁移弹窗用户选择的省份(可手改);为空时由代表处反查字典 label 兜底
String targetProvince = trimToNull(request.getProvince());
target.setProvince(targetProvince != null ? targetProvince : resolveChannelProvince(source.getOfficeName()));
target.setCity(request.getCity());
target.setOfficeAddress(request.getOfficeAddress());
target.setChannelIndustry(source.getIndustryAttr());
target.setCertificationLevel(request.getCertificationLevel());
target.setAnnualRevenue(request.getAnnualRevenue());
target.setStaffSize(request.getStaffSize());
target.setRegisteredCapital(request.getRegisteredCapital());
target.setContactEstablishedDate(source.getPurchaseDate());
target.setChannelAttribute(request.getChannelAttribute());
target.setInternalAttribute(request.getInternalAttribute());
target.setCoverageProvince(request.getCoverageProvince());
target.setCoverageCity(request.getCoverageCity());
target.setIntentLevel(request.getIntentLevel());
target.setHasDesktopExp(request.getHasDesktopExp());
target.setStage(request.getStage());
target.setLandedFlag(request.getLandedFlag());
target.setExpectedSignDate(request.getExpectedSignDate());
target.setRemark(source.getRemark());
List<ChannelExpansionContactRequest> requestedContacts = request.getContacts();
List<ChannelExpansionContactRequest> sourceContacts = toChannelContactRequests(crmExpansionMapper.selectCrmExpansionContactsForMove(crmId));
target.setContacts((requestedContacts != null && !requestedContacts.isEmpty()) ? requestedContacts : sourceContacts);
fillChannelDefaults(target);
ensureUniqueChannelName(userId, target.getChannelName(), null);
expansionMapper.insertChannelExpansion(userId, target);
if (target.getId() == null) {
throw new BusinessException("渠道拓展新增失败");
}
replaceChannelContacts(target.getId(), target.getContacts());
replaceChannelCoverage(target.getId(), request.getCoverageItems());
Long newId = target.getId();
String newName = target.getChannelName();
expansionMapper.updateFollowUpBiz("crm", crmId, "channel", newId);
expansionMapper.updateCheckinBiz("crm", crmId, "channel", newId, newName);
expansionMapper.updateReportMessageBiz("crm", crmId, "channel", newId, newName);
crmExpansionMapper.deleteCrmExpansionContacts(crmId);
crmExpansionMapper.deleteCrmExpansion(crmId);
return newId;
}
@Override
@ -453,6 +680,17 @@ public class ExpansionServiceImpl implements ExpansionService {
}
}
private void fillCrmDisplayFields(List<CrmExpansionItemDTO> crmItems) {
if (crmItems == null || crmItems.isEmpty()) {
return;
}
Map<String, String> industryLabels = toDictLabelMap(loadDictOptions(INDUSTRY_TYPE_CODE));
for (CrmExpansionItemDTO item : crmItems) {
item.setIndustryAttr(formatMultiValueDisplay(item.getIndustryAttrCode(), industryLabels));
}
}
private Map<String, String> toDictLabelMap(List<DictOptionDTO> options) {
Map<String, String> labelMap = new LinkedHashMap<>();
for (DictOptionDTO option : options) {
@ -560,6 +798,15 @@ public class ExpansionServiceImpl implements ExpansionService {
}
}
private void ensureUniqueEndUser(Long userId, String endUser, Long excludeId) {
int count = excludeId == null
? crmExpansionMapper.countCrmExpansionByEndUser(endUser)
: crmExpansionMapper.countCrmExpansionByEndUserExcludingId(endUser, excludeId);
if (count > 0) {
throw new BusinessException(CRM_DUPLICATE_MESSAGE);
}
}
private void fillSalesDefaults(CreateSalesExpansionRequest request) {
request.setEmployeeNo(normalizeRequiredText(request.getEmployeeNo(), "工号不能为空"));
request.setCandidateName(normalizeRequiredText(request.getCandidateName(), "候选人姓名不能为空"));
@ -671,7 +918,7 @@ public class ExpansionServiceImpl implements ExpansionService {
private List<ChannelExpansionContactRequest> normalizeRequiredContacts(List<ChannelExpansionContactRequest> contacts) {
if (contacts == null || contacts.isEmpty()) {
throw new BusinessException("请至少填写一位渠道联系人");
throw new BusinessException("请至少完整填写一行渠道联系人");
}
List<ChannelExpansionContactRequest> normalized = new ArrayList<>();
@ -679,23 +926,31 @@ public class ExpansionServiceImpl implements ExpansionService {
if (contact == null) {
continue;
}
contact.setDuty(trimToNull(contact.getDuty()));
String name = trimToNull(contact.getName());
String mobile = trimToNull(contact.getMobile());
String title = trimToNull(contact.getTitle());
if (name == null && mobile == null && title == null) {
String wecomAdded = trimToNull(contact.getWecomAdded());
String specialNote = trimToNull(contact.getSpecialNote());
// 生日选填;整行均为空时跳过(联系人固定 6 行,空行允许)
if (name == null && mobile == null && title == null
&& contact.getBirthday() == null && wecomAdded == null && specialNote == null) {
continue;
}
if (name == null || mobile == null || title == null) {
throw new BusinessException("请完整填写渠道联系人的姓名、联系电话和职位");
// 行内任一字段有值时,除生日外均必填
if (name == null || mobile == null || title == null || wecomAdded == null || specialNote == null) {
throw new BusinessException("请完整填写渠道联系人的姓名、职务、联系电话、是否加企业微信和特别说明(生日选填)");
}
contact.setName(name);
contact.setMobile(mobile);
contact.setTitle(title);
contact.setWecomAdded(wecomAdded);
contact.setSpecialNote(specialNote);
normalized.add(contact);
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少填写一位渠道联系人");
throw new BusinessException("请至少完整填写一行渠道联系人");
}
return normalized;
}
@ -727,6 +982,22 @@ public class ExpansionServiceImpl implements ExpansionService {
}
}
private void replaceChannelCoverage(Long channelExpansionId, List<CoverageItemDTO> items) {
expansionMapper.deleteChannelCoverage(channelExpansionId);
if (items == null || items.isEmpty()) {
return;
}
expansionMapper.insertChannelCoverage(channelExpansionId, items);
}
private void replaceSalesRegion(Long salesExpansionId, List<CoverageItemDTO> items) {
expansionMapper.deleteSalesRegion(salesExpansionId);
if (items == null || items.isEmpty()) {
return;
}
expansionMapper.insertSalesRegion(salesExpansionId, items);
}
private String trimToNull(String value) {
if (value == null) {
return null;
@ -760,6 +1031,9 @@ public class ExpansionServiceImpl implements ExpansionService {
if ("channel".equalsIgnoreCase(bizType)) {
return "channel";
}
if ("crm".equalsIgnoreCase(bizType)) {
return "crm";
}
throw new BusinessException("不支持的拓展类型");
}
@ -768,14 +1042,290 @@ public class ExpansionServiceImpl implements ExpansionService {
throw new BusinessException("拓展记录不存在");
}
int count = "sales".equals(bizType)
? expansionMapper.countOwnedSalesExpansion(userId, bizId)
: expansionMapper.countOwnedChannelExpansion(userId, bizId);
int count;
switch (bizType) {
case "sales":
count = expansionMapper.countOwnedSalesExpansion(userId, bizId);
break;
case "channel":
count = expansionMapper.countOwnedChannelExpansion(userId, bizId);
break;
case "crm":
count = crmExpansionMapper.countOwnedCrmExpansion(userId, bizId);
break;
default:
throw new BusinessException("不支持的拓展类型");
}
if (count <= 0) {
throw new BusinessException("无权操作该拓展记录");
}
}
private void fillCrmDefaults(CreateCrmExpansionRequest request) {
request.setEndUser(normalizeRequiredText(request.getEndUser(), "最终用户不能为空"));
request.setOfficeName(normalizeRequiredText(request.getOfficeName(), "代表处不能为空"));
request.setIndustryAttr(normalizeRequiredText(request.getIndustryAttr(), "行业属性不能为空"));
request.setExtensionType(normalizeRequiredText(request.getExtensionType(), "类型不能为空"));
if (request.getPurchaseDate() == null) {
throw new BusinessException("采购时间不能为空");
}
if (request.getWarrantyExpiry() == null) {
throw new BusinessException("过保时间不能为空");
}
request.setOnlineStatus(normalizeRequiredText(request.getOnlineStatus(), "在线情况不能为空"));
List<CrmExpansionContactRequest> normalizedContacts = normalizeRequiredCrmContacts(request.getContacts());
request.setContacts(normalizedContacts);
fillCrmContactRedundantFields(request, normalizedContacts);
normalizeCrmRefIds(request);
normalizeRequiredRef(request.getSupplierId(), request.getSupplierName(), "进货商不能为空");
normalizeRequiredRef(request.getH3cContactId(), request.getH3cContactName(), "新华三对接人不能为空");
request.setHasExpansionOpportunity(normalizeRequiredText(request.getHasExpansionOpportunity(), "是否有扩容机会不能为空"));
if (request.getSoftwarePoints() == null || request.getSoftwarePoints() < 0) {
throw new BusinessException("软件点数不能为空");
}
if ("1".equals(request.getHasExpansionOpportunity())) {
if (request.getExpansionTime() == null) {
throw new BusinessException("扩容时间不能为空");
}
if (isBlank(request.getExpansionScale())) {
throw new BusinessException("扩容规模不能为空");
}
}
request.setHasMaintenanceOpportunity(normalizeRequiredText(request.getHasMaintenanceOpportunity(), "是否有维保项目机会不能为空"));
}
private void fillCrmDefaults(UpdateCrmExpansionRequest request) {
request.setEndUser(normalizeRequiredText(request.getEndUser(), "最终用户不能为空"));
request.setOfficeName(normalizeRequiredText(request.getOfficeName(), "代表处不能为空"));
request.setIndustryAttr(normalizeRequiredText(request.getIndustryAttr(), "行业属性不能为空"));
request.setExtensionType(normalizeRequiredText(request.getExtensionType(), "类型不能为空"));
if (request.getPurchaseDate() == null) {
throw new BusinessException("采购时间不能为空");
}
if (request.getWarrantyExpiry() == null) {
throw new BusinessException("过保时间不能为空");
}
request.setOnlineStatus(normalizeRequiredText(request.getOnlineStatus(), "在线情况不能为空"));
List<CrmExpansionContactRequest> normalizedContacts = normalizeRequiredCrmContacts(request.getContacts());
request.setContacts(normalizedContacts);
fillCrmContactRedundantFields(request, normalizedContacts);
normalizeCrmRefIds(request);
normalizeRequiredRef(request.getSupplierId(), request.getSupplierName(), "进货商不能为空");
normalizeRequiredRef(request.getH3cContactId(), request.getH3cContactName(), "新华三对接人不能为空");
request.setHasExpansionOpportunity(normalizeRequiredText(request.getHasExpansionOpportunity(), "是否有扩容机会不能为空"));
if (request.getSoftwarePoints() == null || request.getSoftwarePoints() < 0) {
throw new BusinessException("软件点数不能为空");
}
if ("1".equals(request.getHasExpansionOpportunity())) {
if (request.getExpansionTime() == null) {
throw new BusinessException("扩容时间不能为空");
}
if (isBlank(request.getExpansionScale())) {
throw new BusinessException("扩容规模不能为空");
}
}
request.setHasMaintenanceOpportunity(normalizeRequiredText(request.getHasMaintenanceOpportunity(), "是否有维保项目机会不能为空"));
}
private void normalizeCrmRefIds(CreateCrmExpansionRequest request) {
if (request.getSupplierId() != null && request.getSupplierId() <= 0) {
request.setSupplierId(null);
}
if (request.getH3cContactId() != null && request.getH3cContactId() <= 0) {
request.setH3cContactId(null);
}
}
private void normalizeCrmRefIds(UpdateCrmExpansionRequest request) {
if (request.getSupplierId() != null && request.getSupplierId() <= 0) {
request.setSupplierId(null);
}
if (request.getH3cContactId() != null && request.getH3cContactId() <= 0) {
request.setH3cContactId(null);
}
}
/** 校验进货商/对接人id 或名称至少填其一 */
private void normalizeRequiredRef(Long id, String name, String message) {
if ((id == null || id <= 0) && isBlank(name)) {
throw new BusinessException(message);
}
}
private List<CrmExpansionItemDTO> mergeCrmExpansionItems(
List<CrmExpansionItemDTO> scopedItems,
List<CrmExpansionItemDTO> extraItems) {
Map<Long, CrmExpansionItemDTO> byId = new LinkedHashMap<>();
for (CrmExpansionItemDTO item : scopedItems) {
if (item != null && item.getId() != null) {
byId.putIfAbsent(item.getId(), item);
}
}
for (CrmExpansionItemDTO item : extraItems) {
if (item != null && item.getId() != null) {
byId.putIfAbsent(item.getId(), item);
}
}
return new ArrayList<>(byId.values());
}
private void attachCrmExpansionFollowUps(Long userId, List<CrmExpansionItemDTO> items) {
List<Long> bizIds = items.stream()
.map(CrmExpansionItemDTO::getId)
.filter(Objects::nonNull)
.toList();
if (bizIds.isEmpty()) {
return;
}
Map<Long, List<ExpansionFollowUpDTO>> grouped = crmExpansionMapper.selectCrmExpansionFollowUps(userId, bizIds).stream()
.peek(this::fillFollowUpDisplayFields)
.collect(Collectors.groupingBy(ExpansionFollowUpDTO::getBizId));
for (CrmExpansionItemDTO item : items) {
item.setFollowUps(grouped.getOrDefault(item.getId(), Collections.emptyList()));
}
}
private void attachCrmExpansionContacts(Long userId, List<CrmExpansionItemDTO> items) {
List<Long> crmExpansionIds = items.stream()
.map(CrmExpansionItemDTO::getId)
.filter(Objects::nonNull)
.toList();
if (crmExpansionIds.isEmpty()) {
return;
}
Map<Long, List<CrmExpansionContactDTO>> grouped = crmExpansionMapper
.selectCrmExpansionContacts(userId, crmExpansionIds)
.stream()
.collect(Collectors.groupingBy(CrmExpansionContactDTO::getCrmExpansionId));
for (CrmExpansionItemDTO item : items) {
item.setContacts(grouped.getOrDefault(item.getId(), Collections.emptyList()));
}
}
private void replaceCrmExpansionContacts(Long crmExpansionId, List<CrmExpansionContactRequest> contacts) {
crmExpansionMapper.deleteCrmExpansionContacts(crmExpansionId);
if (contacts == null || contacts.isEmpty()) {
return;
}
for (int index = 0; index < contacts.size(); index++) {
crmExpansionMapper.insertCrmExpansionContact(crmExpansionId, index + 1, contacts.get(index));
}
}
private List<CrmExpansionContactRequest> normalizeRequiredCrmContacts(List<CrmExpansionContactRequest> contacts) {
if (contacts == null || contacts.isEmpty()) {
throw new BusinessException("请至少填写一位联系人");
}
List<CrmExpansionContactRequest> normalized = new ArrayList<>();
for (CrmExpansionContactRequest contact : contacts) {
if (contact == null) {
continue;
}
String name = trimToNull(contact.getName());
String mobile = trimToNull(contact.getMobile());
String title = trimToNull(contact.getTitle());
if (name == null) {
throw new BusinessException("请完整填写每位联系人的姓名");
}
if (mobile == null) {
throw new BusinessException("请完整填写每位联系人的联系电话");
}
if (title == null) {
throw new BusinessException("请完整填写每位联系人的职位");
}
contact.setName(name);
contact.setMobile(mobile);
contact.setTitle(title);
normalized.add(contact);
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少填写一位联系人");
}
return normalized;
}
private void fillCrmContactRedundantFields(CreateCrmExpansionRequest request, List<CrmExpansionContactRequest> contacts) {
CrmExpansionContactRequest first = contacts.get(0);
request.setContactName(first.getName());
request.setContactPhone(first.getMobile());
request.setContactTitle(first.getTitle());
}
private void fillCrmContactRedundantFields(UpdateCrmExpansionRequest request, List<CrmExpansionContactRequest> contacts) {
CrmExpansionContactRequest first = contacts.get(0);
request.setContactName(first.getName());
request.setContactPhone(first.getMobile());
request.setContactTitle(first.getTitle());
}
private List<CrmExpansionContactRequest> toCrmContactRequests(List<ChannelExpansionContactDTO> contacts) {
List<CrmExpansionContactRequest> result = new ArrayList<>();
if (contacts == null) {
return result;
}
for (ChannelExpansionContactDTO contact : contacts) {
if (contact == null) {
continue;
}
CrmExpansionContactRequest converted = new CrmExpansionContactRequest();
converted.setName(contact.getName());
converted.setMobile(contact.getMobile());
converted.setTitle(contact.getTitle());
result.add(converted);
}
return result;
}
private List<ChannelExpansionContactRequest> toChannelContactRequests(List<CrmExpansionContactDTO> contacts) {
List<ChannelExpansionContactRequest> result = new ArrayList<>();
if (contacts == null) {
return result;
}
for (CrmExpansionContactDTO contact : contacts) {
if (contact == null) {
continue;
}
ChannelExpansionContactRequest converted = new ChannelExpansionContactRequest();
converted.setName(contact.getName());
converted.setMobile(contact.getMobile());
converted.setTitle(contact.getTitle());
result.add(converted);
}
return result;
}
/** 代表处:优先取请求值;否则按省份中文名匹配 tz_bsc 字典取 item_value失败返回空由必填校验兜底 */
private String resolveCrmOfficeName(String requested, String province) {
if (!isBlank(requested)) {
return requested.trim();
}
if (!isBlank(province)) {
for (DictOptionDTO option : loadDictOptions(OFFICE_TYPE_CODE)) {
if (province.equals(option.getLabel())) {
return option.getValue();
}
}
}
return null;
}
/** 省份:由代表处字典码反查 tz_bsc 中文标签;非字典码保留原值 */
private String resolveChannelProvince(String officeName) {
if (isBlank(officeName)) {
return null;
}
for (DictOptionDTO option : loadDictOptions(OFFICE_TYPE_CODE)) {
if (officeName.equals(option.getValue())) {
return option.getLabel();
}
}
return officeName;
}
private void requirePermission(String perm, String message) {
if (!permissionService.hasPermi(perm)) {
throw new UnauthorizedException(message);

View File

@ -11,6 +11,7 @@ import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
@ -23,6 +24,7 @@ import com.unis.crm.dto.work.WorkReportAttachmentDTO;
import com.unis.crm.mapper.OpportunityMapper;
import com.unis.crm.service.CrmDataVisibilityService;
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
import com.unis.crm.service.CrmOmsDictMappingService;
import com.unis.crm.service.OmsClient;
import com.unis.crm.service.OpportunityService;
import com.unisbase.security.PermissionService;
@ -60,12 +62,16 @@ 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 Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
// 代表“已签约”的状态码和阶段码,用于判断是否允许使用禁用阶段
private static final Set<String> WON_STAGE_CODES = Set.of("won", "S6");
private final OpportunityMapper opportunityMapper;
private final OmsClient omsClient;
private final ObjectMapper objectMapper;
private final CrmDataVisibilityService crmDataVisibilityService;
private final UnisBaseTenantProvider tenantProvider;
private final PermissionService permissionService;
private final CrmOmsDictMappingService dictMappingService;
public OpportunityServiceImpl(
OpportunityMapper opportunityMapper,
@ -73,20 +79,22 @@ public class OpportunityServiceImpl implements OpportunityService {
ObjectMapper objectMapper,
CrmDataVisibilityService crmDataVisibilityService,
UnisBaseTenantProvider tenantProvider,
PermissionService permissionService) {
PermissionService permissionService,
CrmOmsDictMappingService dictMappingService) {
this.opportunityMapper = opportunityMapper;
this.omsClient = omsClient;
this.objectMapper = objectMapper;
this.crmDataVisibilityService = crmDataVisibilityService;
this.tenantProvider = tenantProvider;
this.permissionService = permissionService;
this.dictMappingService = dictMappingService;
}
@Override
public OpportunityMetaDTO getMeta() {
List<OpportunityDictOptionDTO> opportunityTypeOptions = opportunityMapper.selectDictItems(OPPORTUNITY_TYPE_CODE);
List<OpportunityDictOptionDTO> confidenceOptions = opportunityMapper.selectDictItems(CONFIDENCE_TYPE_CODE);
return new OpportunityMetaDTO(
OpportunityMetaDTO meta = new OpportunityMetaDTO(
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
opportunityMapper.selectProvinceAreaOptions(),
@ -94,6 +102,9 @@ public class OpportunityServiceImpl implements OpportunityService {
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions,
buildSysIsOptions(opportunityMapper.selectDictItems(SYS_IS_TYPE_CODE)));
// 全量阶段(含已禁用项),用于已签单 tab 导出弹窗展示
meta.setAllStageOptions(opportunityMapper.selectAllDictItems(STAGE_TYPE_CODE));
return meta;
}
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
@ -193,6 +204,16 @@ public class OpportunityServiceImpl implements OpportunityService {
return item;
}
@Override
public OpportunityDuplicateCheckDTO checkDuplicateOpportunity(Long userId, String name, Long excludeId, List<String> excludeStageCodes) {
if (isBlank(name)) {
return new OpportunityDuplicateCheckDTO(false, Collections.emptyList());
}
List<OpportunityDuplicateCheckDTO.Item> items = opportunityMapper.selectDuplicateOpportunities(name.trim(), excludeId, excludeStageCodes);
return new OpportunityDuplicateCheckDTO(!items.isEmpty(), items);
}
@Override
public List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId) {
if (userId == null || userId <= 0) {
@ -213,7 +234,8 @@ public class OpportunityServiceImpl implements OpportunityService {
throw new BusinessException("商机新增失败");
}
syncCreatedOpportunityCodeStrict(userId, request.getId());
// OMS 同步采用 best-effortOMS 异常不阻断新增商机(商机已创建成功,可稍后手动推送)
syncCreatedOpportunityCodeBestEffort(userId, request.getId());
return request.getId();
}
@ -676,24 +698,6 @@ public class OpportunityServiceImpl implements OpportunityService {
}
}
private void syncCreatedOpportunityCodeStrict(Long userId, Long opportunityId) {
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
if (pushData == null) {
throw new BusinessException("未找到商机同步数据");
}
validatePushBaseData(pushData);
CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId);
OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists(
currentUser.getUsername().trim(),
currentUser.getDisplayName().trim());
String opportunityCode = omsClient.createProject(pushData, resolveOmsCreateBy(currentOmsUser));
int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, opportunityCode);
if (codeUpdated <= 0) {
throw new BusinessException("保存商机编号失败");
}
}
private void syncUpdatedOpportunityCodeBestEffort(Long userId, Long opportunityId) {
try {
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
@ -782,10 +786,13 @@ public class OpportunityServiceImpl implements OpportunityService {
private String resolveOpportunityCodeForUpdate(String existingOpportunityCode, String returnedOpportunityCode) {
String normalizedExisting = normalizeOpportunityCode(existingOpportunityCode);
if (normalizedExisting != null && !normalizedExisting.toUpperCase().startsWith("OPP-")) {
return normalizedExisting;
String normalizedReturned = normalizeOpportunityCode(returnedOpportunityCode);
// 只要 OMS 返回了有效编号,一律以 OMS 返回值为准更新(即使与现有编号不一致)
if (normalizedReturned != null) {
return normalizedReturned;
}
return normalizeOpportunityCode(returnedOpportunityCode);
// OMS 未返回编号时保留现有编号,避免覆盖为空
return normalizedExisting;
}
private String normalizeConfidenceGrade(String value, String blankMessage) {
@ -901,7 +908,10 @@ public class OpportunityServiceImpl implements OpportunityService {
if (request.getStage() != null && trimToNull(request.getStage()) == null) {
request.setStage(null);
} else if (request.getStage() != null) {
request.setStage(normalizeStageValue(request.getStage()));
// 先将 OMS 码值转换为 CRM 码值
String crmStage = dictMappingService.mapStageToCrm(request.getStage());
// OMS 是外部权威系统,反写过来的阶段码必须被无条件接受
request.setStage(normalizeStageValue(crmStage, true));
}
if (request.getOpportunityType() != null) {
request.setOpportunityType(trimToEmpty(request.getOpportunityType()));
@ -923,6 +933,10 @@ public class OpportunityServiceImpl implements OpportunityService {
}
normalizeArchivedTime(request);
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
// OMS 反写“已签约”(归档)时,将项目阶段同步为 S5-已签约
if (Boolean.TRUE.equals(request.getArchived())) {
request.setStage("S5");
}
autoFillOmsPushTime(request);
}
@ -998,12 +1012,18 @@ public class OpportunityServiceImpl implements OpportunityService {
}
private String normalizeStageValue(String value) {
return normalizeStageValue(value, false);
}
private String normalizeStageValue(String value, boolean ignoreDisabled) {
String trimmed = value == null ? null : value.trim();
if (trimmed == null || trimmed.isEmpty()) {
return "initial_contact";
}
String dictLabel = opportunityMapper.selectDictLabel(STAGE_TYPE_CODE, trimmed);
String dictLabel = ignoreDisabled
? opportunityMapper.selectDictLabelIgnoreStatus(STAGE_TYPE_CODE, trimmed)
: opportunityMapper.selectDictLabel(STAGE_TYPE_CODE, trimmed);
if (!isBlank(dictLabel)) {
return trimmed;
}
@ -1013,6 +1033,12 @@ public class OpportunityServiceImpl implements OpportunityService {
return directCode;
}
// 如果忽略禁用,说明是集成场景,找不到字典项时直接返回原值,不再抛异常
if (ignoreDisabled) {
log.warn("OMS 反写的项目阶段 '{}' 在 CRM 字典中未找到,已直接接受", trimmed);
return trimmed;
}
throw new BusinessException("项目阶段无效: " + trimmed);
}

View File

@ -322,6 +322,7 @@ public class WorkServiceImpl implements WorkService {
}
@Override
@Transactional
public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) {
requireUser(userId);
ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录");
@ -1835,6 +1836,7 @@ public class WorkServiceImpl implements WorkService {
case "sales" -> "销售拓展";
case "channel" -> "渠道拓展";
case "opportunity" -> "商机";
case "crm" -> "CRM拓展";
default -> "业务对象";
};
String attachmentSummary = buildAttachmentSummary(item.getAttachments());
@ -1870,6 +1872,19 @@ public class WorkServiceImpl implements WorkService {
item.setCommunicationContent(null);
return;
}
if ("crm".equals(item.getBizType())) {
String crmBody = stripCrmEditorBody(item.getEditorText(), item.getContent());
item.setEditorText(buildCrmEditorText(objectName, crmBody, item.getToUsers()));
item.setContent(crmBody);
item.setVisitStartTime(null);
item.setEvaluationContent(null);
item.setNextPlan(null);
item.setLatestProgress(null);
item.setStage(null);
item.setCommunicationTime(null);
item.setCommunicationContent(null);
return;
}
if (editorText == null) {
editorText = buildEditorText(item.getBizType(), objectName, extractLineFieldValues(item), item.getToUsers());
}
@ -2018,7 +2033,42 @@ public class WorkServiceImpl implements WorkService {
return String.join("\n", lines);
}
/** CRM 拓展日报行采用纯文本,无“+ 沟通内容:”等字段行 */
private String buildCrmEditorText(String bizName, String body, List<WorkReportToUserDTO> toUsers) {
List<String> lines = new ArrayList<>();
lines.add("#" + getBizTypeLabel("crm") + " " + firstNonBlank(bizName, "未选择对象"));
if (body != null && !body.isEmpty()) {
lines.add(body);
}
String toLine = buildReportToLine(toUsers);
if (toLine != null) {
lines.add(toLine);
}
return String.join("\n", lines);
}
/** 提取 CRM 日报行正文:剔除 # 提及行、@ 通知行、+ 字段行与空行 */
private String stripCrmEditorBody(String editorText, String fallback) {
if (editorText != null) {
List<String> bodyLines = new ArrayList<>();
for (String rawLine : editorText.replace("\r", "").split("\n")) {
String line = rawLine == null ? "" : rawLine.trim();
if (line.isEmpty() || line.startsWith("#") || line.startsWith("@") || isReportToLine(line)) {
continue;
}
bodyLines.add(line);
}
if (!bodyLines.isEmpty()) {
return String.join("\n", bodyLines);
}
}
return normalizeOptionalText(fallback);
}
private List<String> getEditorFieldLabels(String bizType) {
if ("crm".equals(bizType)) {
return List.of();
}
if ("opportunity".equals(bizType)) {
return List.of("项目最新进展", OPPORTUNITY_NEXT_PLAN_LABEL);
}
@ -2032,6 +2082,9 @@ public class WorkServiceImpl implements WorkService {
if ("channel".equals(bizType)) {
return "渠道拓展";
}
if ("crm".equals(bizType)) {
return "CRM拓展";
}
return "商机";
}
@ -2080,8 +2133,6 @@ public class WorkServiceImpl implements WorkService {
CreateWorkDailyReportRequest request,
WorkDailyReportDTO currentReport,
boolean syncOpportunitySnapshot) {
LocalDate reportDate = resolveReportDate(currentReport);
for (WorkReportLineItemDTO item : previousLineItems) {
if (item == null || item.getBizId() == null || item.getWorkDate() == null) {
continue;
@ -2103,7 +2154,9 @@ public class WorkServiceImpl implements WorkService {
WORK_REPORT_FOLLOW_UP_TYPE);
}
OffsetDateTime followUpTime = resolveReportSubmitTime(currentReport);
LocalDate followUpDate = resolveReportDate(currentReport);
OffsetDateTime followUpTime = followUpDate.atTime(LocalTime.of(9, 0))
.atZone(BUSINESS_ZONE_ID).toOffsetDateTime();
for (WorkReportLineItemRequest item : request.getLineItems()) {
if (!hasLinkedReportTarget(item)) {
continue;
@ -2112,7 +2165,7 @@ public class WorkServiceImpl implements WorkService {
workMapper.deleteDailyReportOpportunityFollowUps(
item.getBizId(),
userId,
reportDate,
followUpDate,
WORK_REPORT_FOLLOW_UP_TYPE);
workMapper.insertLegacyOpportunityFollowUp(
item.getBizId(),
@ -2135,7 +2188,7 @@ public class WorkServiceImpl implements WorkService {
item.getBizType(),
item.getBizId(),
userId,
reportDate,
followUpDate,
WORK_REPORT_FOLLOW_UP_TYPE);
workMapper.insertLegacyExpansionFollowUp(
item.getBizType(),
@ -2224,7 +2277,7 @@ public class WorkServiceImpl implements WorkService {
try {
if (normalized.length() == 16) {
return LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"))
.atZone(ZoneId.systemDefault())
.atZone(BUSINESS_ZONE_ID)
.toOffsetDateTime();
}
return OffsetDateTime.parse(normalized);
@ -2235,22 +2288,12 @@ public class WorkServiceImpl implements WorkService {
private OffsetDateTime resolveFollowUpTime(String workDate) {
LocalDate date = LocalDate.parse(workDate);
return date.atTime(LocalTime.of(9, 0)).atZone(ZoneId.systemDefault()).toOffsetDateTime();
}
private OffsetDateTime resolveReportSubmitTime(WorkDailyReportDTO currentReport) {
String submitTime = currentReport == null ? null : normalizeOptionalText(currentReport.getSubmitTime());
if (submitTime != null) {
return LocalDateTime.parse(submitTime, DATE_TIME_FORMATTER)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime();
}
return OffsetDateTime.now(ZoneId.systemDefault());
return date.atTime(LocalTime.of(9, 0)).atZone(BUSINESS_ZONE_ID).toOffsetDateTime();
}
private LocalDate resolveReportDate(WorkDailyReportDTO currentReport) {
String reportDate = currentReport == null ? null : normalizeOptionalText(currentReport.getDate());
return reportDate == null ? LocalDate.now() : LocalDate.parse(reportDate);
return reportDate == null ? LocalDate.now(BUSINESS_ZONE_ID) : LocalDate.parse(reportDate);
}
private String resolveFileExtension(String contentType, String originalFileName) {
@ -2907,7 +2950,7 @@ public class WorkServiceImpl implements WorkService {
return null;
}
return switch (normalized) {
case "sales", "channel", "opportunity" -> normalized;
case "sales", "channel", "opportunity", "crm" -> normalized;
default -> throw new BusinessException("不支持的跟进对象类型");
};
}

View File

@ -84,7 +84,7 @@ unisbase:
access-token-safety-seconds: 120
oms:
enabled: ${OMS_ENABLED:true}
base-url: ${OMS_BASE_URL:http://192.168.4.78:28080}
base-url: ${OMS_BASE_URL:http://192.168.2.158:28080}
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
api-key-header: ${OMS_API_KEY_HEADER:apiKey}
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.CrmOmsDictMappingMapper">
<select id="selectOmsValueByCrmValue" resultType="java.lang.String">
select oms_value
from crm_oms_dict_mapping
where dict_type = #{dictType}
and crm_value = #{crmValue}
and is_default = true
and status = 1
limit 1
</select>
<select id="selectCrmValueByOmsValue" resultType="java.lang.String">
select crm_value
from crm_oms_dict_mapping
where dict_type = #{dictType}
and oms_value = #{omsValue}
and status = 1
limit 1
</select>
</mapper>

View File

@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.CrmExpansionMapper">
<sql id="crmExpansionSelectColumns">
select
crm.id,
crm.owner_user_id as ownerUserId,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
'crm' as type,
crm.end_user as endUser,
crm.office_name as officeName,
crm.industry_attr as industryAttr,
crm.industry_attr as industryAttrCode,
crm.extension_type as extensionType,
crm.purchase_date as purchaseDate,
coalesce(to_char(crm.purchase_date, 'YYYY-MM-DD'), '') as purchaseDateText,
crm.warranty_expiry as warrantyExpiry,
coalesce(to_char(crm.warranty_expiry, 'YYYY-MM-DD'), '') as warrantyExpiryText,
crm.online_status as onlineStatus,
crm.contact_name as contactName,
crm.contact_phone as contactPhone,
crm.contact_title as contactTitle,
crm.supplier_id as supplierId,
coalesce(nullif(crm.supplier_name, ''), ch.channel_name, '') as supplierName,
crm.h3c_contact_id as h3cContactId,
coalesce(nullif(crm.h3c_contact_name, ''), s.candidate_name, '') as h3cContactName,
crm.has_expansion_opportunity as hasExpansionOpportunity,
crm.software_points as softwarePoints,
crm.expansion_time as expansionTime,
coalesce(to_char(crm.expansion_time, 'YYYY-MM-DD'), '') as expansionTimeText,
crm.expansion_scale as expansionScale,
crm.has_maintenance_opportunity as hasMaintenanceOpportunity,
coalesce(to_char(crm.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
coalesce(to_char(crm.updated_at, 'YYYY-MM-DD HH24:MI'), '无') as updatedAt
</sql>
<sql id="crmExpansionJoins">
from crm_crm_expansion crm
left join sys_user u on u.user_id = crm.owner_user_id
left join crm_channel_expansion ch on ch.id = crm.supplier_id
left join crm_sales_expansion s on s.id = crm.h3c_contact_id
</sql>
<select id="selectCrmExpansions" resultType="com.unis.crm.dto.expansion.CrmExpansionItemDTO">
<include refid="crmExpansionSelectColumns"/>
<include refid="crmExpansionJoins"/>
<where>
<if test="keyword != null and keyword != ''">
and (crm.end_user like '%'||#{keyword}||'%'
or crm.contact_name like '%'||#{keyword}||'%'
or crm.office_name like '%'||#{keyword}||'%'
or exists (
select 1 from sys_dict_item d
where d.type_code = 'tz_bsc'
and d.item_value = crm.office_name
and d.status = 1
and coalesce(d.is_deleted, 0) = 0
and d.item_label like '%'||#{keyword}||'%'
))
</if>
</where>
order by crm.updated_at desc, crm.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<select id="selectCrmExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.CrmExpansionItemDTO">
<include refid="crmExpansionSelectColumns"/>
<include refid="crmExpansionJoins"/>
<where>
crm.owner_user_id in
<foreach collection="ownerUserIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
<if test="keyword != null and keyword != ''">
and (crm.end_user like '%'||#{keyword}||'%'
or crm.contact_name like '%'||#{keyword}||'%'
or crm.office_name like '%'||#{keyword}||'%'
or exists (
select 1 from sys_dict_item d
where d.type_code = 'tz_bsc'
and d.item_value = crm.office_name
and d.status = 1
and coalesce(d.is_deleted, 0) = 0
and d.item_label like '%'||#{keyword}||'%'
))
</if>
</where>
order by crm.updated_at desc, crm.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<insert id="insertCrmExpansion" useGeneratedKeys="true" keyProperty="request.id" keyColumn="id">
insert into crm_crm_expansion (
end_user, office_name, industry_attr, extension_type,
purchase_date, warranty_expiry, online_status,
contact_name, contact_phone, contact_title,
supplier_id, h3c_contact_id, has_expansion_opportunity,
supplier_name, h3c_contact_name,
software_points, expansion_time, expansion_scale,
has_maintenance_opportunity,
owner_user_id, remark
) values (
#{request.endUser}, #{request.officeName}, #{request.industryAttr}, #{request.extensionType},
#{request.purchaseDate}, #{request.warrantyExpiry}, #{request.onlineStatus},
#{request.contactName}, #{request.contactPhone}, #{request.contactTitle},
#{request.supplierId}, #{request.h3cContactId}, #{request.hasExpansionOpportunity},
#{request.supplierName}, #{request.h3cContactName},
#{request.softwarePoints}, #{request.expansionTime}, #{request.expansionScale},
#{request.hasMaintenanceOpportunity},
#{userId}, #{request.remark}
)
</insert>
<update id="updateCrmExpansion">
update crm_crm_expansion
set
end_user = #{request.endUser},
office_name = #{request.officeName},
industry_attr = #{request.industryAttr},
extension_type = #{request.extensionType},
purchase_date = #{request.purchaseDate},
warranty_expiry = #{request.warrantyExpiry},
online_status = #{request.onlineStatus},
contact_name = #{request.contactName},
contact_phone = #{request.contactPhone},
contact_title = #{request.contactTitle},
supplier_id = #{request.supplierId},
h3c_contact_id = #{request.h3cContactId},
supplier_name = #{request.supplierName},
h3c_contact_name = #{request.h3cContactName},
has_expansion_opportunity = #{request.hasExpansionOpportunity},
software_points = #{request.softwarePoints},
expansion_time = #{request.expansionTime},
expansion_scale = #{request.expansionScale},
has_maintenance_opportunity = #{request.hasMaintenanceOpportunity},
updated_at = now()
where id = #{id}
</update>
<select id="countOwnedCrmExpansion" resultType="int">
select count(1)
from crm_crm_expansion crm
where crm.id = #{id} and crm.owner_user_id = #{userId}
</select>
<select id="countCrmExpansionByEndUser" resultType="int">
select count(1)
from crm_crm_expansion
where end_user = #{endUser}
</select>
<select id="countCrmExpansionByEndUserExcludingId" resultType="int">
select count(1)
from crm_crm_expansion
where end_user = #{endUser}
and id &lt;&gt; #{excludeId}
</select>
<select id="selectCrmExpansionFollowUps" resultType="com.unis.crm.dto.expansion.ExpansionFollowUpDTO">
select
f.id,
f.biz_id as bizId,
f.biz_type as bizType,
f.followup_time as followUpTime,
case
when (f.followup_type = '工作日报' or f.source_type = 'work_report')
and (f.followup_time at time zone 'Asia/Shanghai')::time &lt; time '10:00'
then ((f.followup_time at time zone 'Asia/Shanghai')::date - 1)::text
when (f.followup_type = '工作日报' or f.source_type = 'work_report')
then (f.followup_time at time zone 'Asia/Shanghai')::date::text
else to_char(f.followup_time, 'YYYY-MM-DD HH24:MI')
end as date,
f.followup_type as type,
coalesce(f.content, '无') as content,
coalesce(u.display_name, '无') as user,
coalesce(to_char(f.visit_start_time, 'YYYY-MM-DD HH24:MI'), '无') as visitStartTime,
coalesce(f.evaluation_content, '无') as evaluationContent,
coalesce(f.next_plan, '无') as nextPlan
from crm_expansion_followup f
join crm_crm_expansion crm on crm.id = f.biz_id and f.biz_type = 'crm'
left join sys_user u on u.user_id = f.followup_user_id
where f.biz_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by f.followup_time desc, f.id desc
</select>
<select id="selectCrmExpansionContacts" resultType="com.unis.crm.dto.expansion.CrmExpansionContactDTO">
select
cc.id,
cc.crm_expansion_id as crmExpansionId,
coalesce(cc.contact_name, '无') as name,
coalesce(cc.contact_mobile, '无') as mobile,
coalesce(cc.contact_title, '无') as title
from crm_crm_expansion_contact cc
join crm_crm_expansion crm on crm.id = cc.crm_expansion_id
where cc.crm_expansion_id in
<foreach collection="crmExpansionIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by cc.sort_order asc nulls last, cc.id asc
</select>
<insert id="insertCrmExpansionContact">
insert into crm_crm_expansion_contact (
crm_expansion_id,
contact_name,
contact_mobile,
contact_title,
sort_order,
created_at,
updated_at
) values (
#{crmExpansionId},
#{contact.name},
#{contact.mobile},
#{contact.title},
#{sortOrder},
now(),
now()
)
</insert>
<delete id="deleteCrmExpansionContacts">
delete from crm_crm_expansion_contact
where crm_expansion_id = #{crmExpansionId}
</delete>
<select id="selectCrmExpansionForMove"
resultType="com.unis.crm.dto.expansion.CrmExpansionMoveSourceDTO">
select
crm.id,
crm.owner_user_id as ownerUserId,
crm.end_user as endUser,
crm.office_name as officeName,
crm.industry_attr as industryAttr,
crm.extension_type as extensionType,
crm.purchase_date as purchaseDate,
crm.warranty_expiry as warrantyExpiry,
crm.online_status as onlineStatus,
crm.contact_name as contactName,
crm.contact_phone as contactPhone,
crm.contact_title as contactTitle,
crm.supplier_id as supplierId,
crm.h3c_contact_id as h3cContactId,
crm.has_expansion_opportunity as hasExpansionOpportunity,
crm.remark
from crm_crm_expansion crm
where crm.id = #{id}
for update
</select>
<select id="selectCrmExpansionContactsForMove"
resultType="com.unis.crm.dto.expansion.CrmExpansionContactDTO">
select
cc.contact_name as name,
cc.contact_mobile as mobile,
cc.contact_title as title
from crm_crm_expansion_contact cc
where cc.crm_expansion_id = #{crmExpansionId}
order by cc.sort_order asc nulls last, cc.id asc
</select>
<delete id="deleteCrmExpansion">
delete from crm_crm_expansion
where id = #{id}
</delete>
</mapper>

View File

@ -53,7 +53,21 @@
),
'无'
) as updatedAt,
coalesce(s.remark, '无') as notes
coalesce(s.remark, '无') as notes,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.province is not null
and cov.province != ''), '') as regionProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.city is not null
and cov.city != ''), '') as regionCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and (cov.province is not null or cov.city is not null)), '') as regionItems
</sql>
<sql id="salesExpansionJoins">
@ -103,6 +117,20 @@
coalesce(province_area.name, nullif(c.province, ''), '无') as province,
coalesce(c.city, '') as cityCode,
coalesce(city_area.name, nullif(c.city, ''), '无') as city,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.province is not null
and cov.province != ''), '') as coverageProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.city is not null
and cov.city != ''), '') as coverageCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and (cov.province is not null or cov.city is not null)), '') as coverageItems,
coalesce(c.office_address, '无') as officeAddress,
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
@ -113,6 +141,7 @@
else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元'
end as revenue,
coalesce(c.staff_size, 0) as size,
coalesce(trim(to_char(c.registered_capital, 'FM999999990.##')), '') as registeredCapital,
coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName,
coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle,
coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile,
@ -271,7 +300,7 @@
select p.area_code
from cnarea p
where p.level = 1
and p.name = #{provinceName}
and (p.name = #{provinceName} or p.short_name = #{provinceName})
order by p.id asc
limit 1
)
@ -337,7 +366,21 @@
),
'无'
) as updatedAt,
coalesce(s.remark, '无') as notes
coalesce(s.remark, '无') as notes,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.province is not null
and cov.province != ''), '') as regionProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.city is not null
and cov.city != ''), '') as regionCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and (cov.province is not null or cov.city is not null)), '') as regionItems
from crm_sales_expansion s
left join (
select
@ -400,6 +443,20 @@
coalesce(province_area.name, nullif(c.province, ''), '无') as province,
coalesce(c.city, '') as cityCode,
coalesce(city_area.name, nullif(c.city, ''), '无') as city,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.province is not null
and cov.province != ''), '') as coverageProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.city is not null
and cov.city != ''), '') as coverageCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and (cov.province is not null or cov.city is not null)), '') as coverageItems,
coalesce(c.office_address, '无') as officeAddress,
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
@ -410,6 +467,7 @@
else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元'
end as revenue,
coalesce(c.staff_size, 0) as size,
coalesce(trim(to_char(c.registered_capital, 'FM999999990.##')), '') as registeredCapital,
coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName,
coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle,
coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile,
@ -599,7 +657,21 @@
),
'无'
) as updatedAt,
coalesce(s.remark, '无') as notes
coalesce(s.remark, '无') as notes,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.province is not null
and cov.province != ''), '') as regionProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and cov.city is not null
and cov.city != ''), '') as regionCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_sales_expansion_coverage cov
where cov.sales_expansion_id = s.id
and (cov.province is not null or cov.city is not null)), '') as regionItems
from crm_sales_expansion s
left join (
select
@ -656,6 +728,20 @@
coalesce(province_area.name, nullif(c.province, ''), '无') as province,
coalesce(c.city, '') as cityCode,
coalesce(city_area.name, nullif(c.city, ''), '无') as city,
coalesce((select string_agg(cov.province, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.province is not null
and cov.province != ''), '') as coverageProvince,
coalesce((select string_agg(cov.city, ',' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and cov.city is not null
and cov.city != ''), '') as coverageCity,
coalesce((select string_agg(coalesce(cov.province, '') || '|' || coalesce(cov.city, ''), '' order by cov.id)
from crm_channel_expansion_coverage cov
where cov.channel_id = c.id
and (cov.province is not null or cov.city is not null)), '') as coverageItems,
coalesce(c.office_address, '无') as officeAddress,
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
@ -666,6 +752,7 @@
else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元'
end as revenue,
coalesce(c.staff_size, 0) as size,
coalesce(trim(to_char(c.registered_capital, 'FM999999990.##')), '') as registeredCapital,
coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName,
coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle,
coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile,
@ -902,9 +989,13 @@
select
cc.id,
cc.channel_expansion_id as channelExpansionId,
cc.duty,
coalesce(cc.contact_name, '无') as name,
coalesce(cc.contact_mobile, '无') as mobile,
coalesce(cc.contact_title, '无') as title
coalesce(cc.contact_title, '无') as title,
cc.birthday,
cc.wecom_added as wecomAdded,
cc.special_note as specialNote
from crm_channel_expansion_contact cc
join crm_channel_expansion c on c.id = cc.channel_expansion_id
where cc.channel_expansion_id in
@ -1004,6 +1095,7 @@
certification_level,
annual_revenue,
staff_size,
registered_capital,
contact_established_date,
intent_level,
has_desktop_exp,
@ -1027,6 +1119,7 @@
#{request.certificationLevel},
#{request.annualRevenue},
#{request.staffSize},
#{request.registeredCapital},
#{request.contactEstablishedDate},
#{request.intentLevel},
#{request.hasDesktopExp},
@ -1046,17 +1139,25 @@
<insert id="insertChannelContact">
insert into crm_channel_expansion_contact (
channel_expansion_id,
duty,
contact_name,
contact_mobile,
contact_title,
birthday,
wecom_added,
special_note,
sort_order,
created_at,
updated_at
) values (
#{channelExpansionId},
#{contact.duty},
#{contact.name},
#{contact.mobile},
#{contact.title},
#{contact.birthday},
#{contact.wecomAdded},
#{contact.specialNote},
#{sortOrder},
now(),
now()
@ -1124,6 +1225,7 @@
certification_level = #{request.certificationLevel},
annual_revenue = #{request.annualRevenue},
staff_size = #{request.staffSize},
registered_capital = #{request.registeredCapital},
contact_established_date = #{request.contactEstablishedDate},
intent_level = #{request.intentLevel},
has_desktop_exp = #{request.hasDesktopExp},
@ -1139,6 +1241,28 @@
where c.id = #{id}
</update>
<delete id="deleteChannelCoverage">
delete from crm_channel_expansion_coverage where channel_id = #{channelId}
</delete>
<insert id="insertChannelCoverage">
insert into crm_channel_expansion_coverage (channel_id, province, city) values
<foreach collection="items" item="item" separator=",">
(#{channelId}, #{item.province}, #{item.city})
</foreach>
</insert>
<delete id="deleteSalesRegion">
delete from crm_sales_expansion_coverage where sales_expansion_id = #{salesExpansionId}
</delete>
<insert id="insertSalesRegion">
insert into crm_sales_expansion_coverage (sales_expansion_id, province, city) values
<foreach collection="items" item="item" separator=",">
(#{salesExpansionId}, #{item.province}, #{item.city})
</foreach>
</insert>
<select id="countOwnedSalesExpansion" resultType="int">
select count(1)
from crm_sales_expansion s
@ -1176,4 +1300,87 @@
#{request.nextPlan}
)
</insert>
<select id="selectChannelExpansionForMove"
resultType="com.unis.crm.dto.expansion.ChannelExpansionMoveSourceDTO">
select
c.id,
c.owner_user_id as ownerUserId,
c.channel_name as name,
c.province,
c.city,
c.office_address as officeAddress,
coalesce(c.channel_industry, c.industry, '') as channelIndustry,
c.certification_level as certificationLevel,
c.annual_revenue as annualRevenue,
c.staff_size as staffSize,
c.registered_capital as registeredCapital,
c.contact_established_date as establishedDate,
c.intent_level as intentLevel,
c.has_desktop_exp as hasDesktopExp,
c.channel_attribute as channelAttribute,
c.internal_attribute as internalAttribute,
c.stage,
c.landed_flag as landedFlag,
c.expected_sign_date as expectedSignDate,
c.remark
from crm_channel_expansion c
where c.id = #{id}
for update
</select>
<update id="updateFollowUpBiz">
update crm_expansion_followup
set biz_type = #{newBizType},
biz_id = #{newBizId}
where biz_type = #{oldBizType}
and biz_id = #{oldBizId}
</update>
<update id="updateCheckinBiz">
update work_checkin
set biz_type = #{newBizType},
biz_id = #{newBizId},
biz_name = #{newBizName}
where biz_type = #{oldBizType}
and biz_id = #{oldBizId}
</update>
<update id="updateReportMessageBiz">
update work_report_message
set biz_type = #{newBizType},
biz_id = #{newBizId},
biz_name = #{newBizName}
where biz_type = #{oldBizType}
and biz_id = #{oldBizId}
</update>
<update id="clearOpportunityChannelExpansion">
update crm_opportunity
set channel_expansion_id = null
where channel_expansion_id = #{channelId}
</update>
<update id="clearCrmSupplierRefs">
update crm_crm_expansion
set supplier_id = null,
supplier_name = null
where supplier_id = #{channelId}
</update>
<select id="selectChannelContactsForMove"
resultType="com.unis.crm.dto.expansion.ChannelExpansionContactDTO">
select
cc.contact_name as name,
cc.contact_mobile as mobile,
cc.contact_title as title
from crm_channel_expansion_contact cc
where cc.channel_expansion_id = #{channelExpansionId}
order by cc.sort_order asc nulls last, cc.id asc
</select>
<delete id="deleteChannelExpansion">
delete from crm_channel_expansion
where id = #{id}
</delete>
</mapper>

View File

@ -53,6 +53,16 @@
order by sort_order asc nulls last, dict_item_id asc
</select>
<select id="selectAllDictItems" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
select
item_label as label,
item_value as value
from sys_dict_item
where type_code = #{typeCode}
and coalesce(is_deleted, 0) = 0
order by sort_order asc nulls last, dict_item_id asc
</select>
<select id="selectProvinceAreaOptions" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
select
name as label,
@ -104,6 +114,17 @@
limit 1
</select>
<!-- 忽略状态查询字典标签,用于已签约商机等需要保留历史数据的场景 -->
<select id="selectDictLabelIgnoreStatus" resultType="java.lang.String">
select item_label
from sys_dict_item
where type_code = #{typeCode}
and item_value = #{itemValue}
and coalesce(is_deleted, 0) = 0
order by case when status = 1 then 0 else 1 end, sort_order asc nulls last, dict_item_id asc
limit 1
</select>
<select id="selectDictValueByLabel" resultType="java.lang.String">
select item_value
from sys_dict_item
@ -152,7 +173,7 @@
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
when 'lost' then 'L-已丢单'
else coalesce(o.stage, '初步沟通')
end
) as stage,
@ -246,11 +267,14 @@
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
when 'lost' then 'L-已丢单'
else o.stage
end
)
and stage_dict.status = 1
and (
stage_dict.status = 1
or coalesce(o.archived, false) = true
)
and coalesce(stage_dict.is_deleted, 0) = 0
left join sys_dict_item operator_dict
on operator_dict.type_code = 'sj_yzf'
@ -336,7 +360,7 @@
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
when 'lost' then 'L-已丢单'
else coalesce(o.stage, '初步沟通')
end
) as stage,
@ -429,11 +453,14 @@
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
when 'lost' then 'L-已丢单'
else o.stage
end
)
and stage_dict.status = 1
and (
stage_dict.status = 1
or coalesce(o.archived, false) = true
)
and coalesce(stage_dict.is_deleted, 0) = 0
left join sys_dict_item operator_dict
on operator_dict.type_code = 'sj_yzf'
@ -501,6 +528,38 @@
limit 1
</select>
<select id="selectDuplicateOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityDuplicateCheckDTO$Item">
select
o.id as opportunityId,
o.opportunity_code as opportunityCode,
o.opportunity_name as opportunityName,
coalesce(c.customer_name, '') as customerName,
coalesce(stage_dict.item_label, o.stage, '') as stage
from crm_opportunity o
left join crm_customer c on c.id = o.customer_id
left join sys_dict_item stage_dict
on stage_dict.type_code = 'sj_xmjd'
and stage_dict.item_value = o.stage
and stage_dict.status = 1
and coalesce(stage_dict.is_deleted, 0) = 0
where coalesce(o.archived, false) = false
and o.opportunity_name &lt;&gt; ''
and replace(replace(upper(o.opportunity_name), ' ', ''), ' ', '')
= replace(replace(upper(#{name}), ' ', ''), ' ', '')
<if test="excludeId != null">
and o.id != #{excludeId}
</if>
<if test="excludeStageCodes != null and excludeStageCodes.size() > 0">
and coalesce(o.stage, '') not in (
<foreach item="code" collection="excludeStageCodes" open="(" separator="," close=")">
#{code}
</foreach>
)
</if>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
limit 20
</select>
<insert id="insertCustomer">
insert into crm_customer (
id,

View File

@ -724,7 +724,6 @@
<insert id="insertCheckIn">
insert into work_checkin (
id,
user_id,
checkin_date,
checkin_time,
@ -741,7 +740,6 @@
created_at,
updated_at
) values (
(select coalesce(max(id), 0) + 1 from work_checkin),
#{userId},
current_date,
now(),

View File

@ -99,15 +99,16 @@ class ExpansionServiceImplTest {
}
@Test
void updateSalesExpansion_shouldSkipDuplicateEmployeeNoCheck() {
void updateSalesExpansion_shouldCheckDuplicateEmployeeNoExcludingSelf() {
UpdateSalesExpansionRequest request = buildUpdateSalesRequest();
when(expansionMapper.countSalesExpansionByEmployeeNoExcludingId("EMP001", 10L)).thenReturn(0);
when(expansionMapper.updateSalesExpansion(1L, 10L, request)).thenReturn(1);
expansionService.updateSalesExpansion(1L, 10L, request);
verify(expansionMapper).updateSalesExpansion(1L, 10L, request);
verify(expansionMapper).countSalesExpansionByEmployeeNoExcludingId("EMP001", 10L);
verify(expansionMapper, never()).countSalesExpansionByEmployeeNo(any());
verify(expansionMapper, never()).countSalesExpansionByEmployeeNoExcludingId(any(), any());
verify(expansionMapper).updateSalesExpansion(1L, 10L, request);
}
@Test
@ -138,48 +139,55 @@ class ExpansionServiceImplTest {
}
@Test
void updateChannelExpansion_shouldSkipDuplicateChannelNameCheck() {
void updateChannelExpansion_shouldCheckDuplicateChannelNameExcludingSelf() {
UpdateChannelExpansionRequest request = buildUpdateChannelRequest();
when(expansionMapper.countChannelExpansionByChannelNameExcludingId("渠道A", 20L)).thenReturn(0);
when(expansionMapper.updateChannelExpansion(1L, 20L, request)).thenReturn(1);
expansionService.updateChannelExpansion(1L, 20L, request);
verify(expansionMapper).countChannelExpansionByChannelNameExcludingId("渠道A", 20L);
verify(expansionMapper, never()).countChannelExpansionByChannelName(any());
verify(expansionMapper).updateChannelExpansion(1L, 20L, request);
verify(expansionMapper).deleteChannelContacts(20L);
verify(expansionMapper).insertChannelContact(eq(20L), eq(1), any(ChannelExpansionContactRequest.class));
verify(expansionMapper, never()).countChannelExpansionByChannelName(any());
verify(expansionMapper, never()).countChannelExpansionByChannelNameExcludingId(any(), any());
}
@Test
void getOpportunityFormOptions_shouldReturnTenantScopedInitialOptions() {
void getOpportunityFormOptions_shouldReturnDataScopedOptions() {
SalesExpansionItemDTO salesItem = new SalesExpansionItemDTO();
salesItem.setId(11L);
salesItem.setName("张三");
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(expansionMapper.selectSalesExpansionsForTenant(100L, null, 20)).thenReturn(List.of(salesItem));
when(expansionMapper.selectChannelExpansionsForTenant(100L, null, 20)).thenReturn(List.of());
when(crmDataVisibilityService.resolveVisibility(1L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
.thenReturn(new DataVisibility(true, List.of(), List.of()));
when(expansionMapper.selectSalesExpansions(1L, null, 20)).thenReturn(List.of(salesItem));
when(expansionMapper.selectChannelExpansions(1L, null, 20)).thenReturn(List.of());
ExpansionOverviewDTO result = expansionService.getOpportunityFormOptions(1L, null, null);
assertEquals(1, result.getSalesItems().size());
assertEquals(0, result.getChannelItems().size());
verify(expansionMapper).selectSalesExpansionsForTenant(100L, null, 20);
verify(expansionMapper).selectChannelExpansionsForTenant(100L, null, 20);
verify(expansionMapper, never()).selectSalesExpansions(any(), any(), any());
verify(expansionMapper, never()).selectChannelExpansions(any(), any(), any());
verify(expansionMapper).selectSalesExpansions(1L, null, 20);
verify(expansionMapper).selectChannelExpansions(1L, null, 20);
verify(expansionMapper, never()).selectSalesExpansionsForTenant(any(), any(), any());
verify(expansionMapper, never()).selectChannelExpansionsForTenant(any(), any(), any());
}
@Test
void getOpportunityFormOptions_shouldPassKeywordAndLimit() {
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(expansionMapper.selectSalesExpansionsForTenant(100L, "张", 15)).thenReturn(List.of());
when(expansionMapper.selectChannelExpansionsForTenant(100L, "张", 15)).thenReturn(List.of());
when(crmDataVisibilityService.resolveVisibility(1L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
.thenReturn(new DataVisibility(true, List.of(), List.of()));
when(expansionMapper.selectSalesExpansions(1L, "张", 15)).thenReturn(List.of());
when(expansionMapper.selectChannelExpansions(1L, "张", 15)).thenReturn(List.of());
expansionService.getOpportunityFormOptions(1L, " 张 ", 15);
verify(expansionMapper).selectSalesExpansionsForTenant(100L, "张", 15);
verify(expansionMapper).selectChannelExpansionsForTenant(100L, "张", 15);
verify(expansionMapper).selectSalesExpansions(1L, "张", 15);
verify(expansionMapper).selectChannelExpansions(1L, "张", 15);
verify(expansionMapper, never()).selectSalesExpansionsForTenant(any(), any(), any());
verify(expansionMapper, never()).selectChannelExpansionsForTenant(any(), any(), any());
}
private CreateSalesExpansionRequest buildCreateSalesRequest() {
@ -253,6 +261,8 @@ class ExpansionServiceImplTest {
contact.setName("李四");
contact.setMobile("13800138000");
contact.setTitle("负责人");
contact.setWecomAdded("1");
contact.setSpecialNote("无");
return contact;
}
}

View File

@ -207,6 +207,26 @@ class OpportunityServiceImplTest {
&& Long.valueOf(0L).equals(normalized.getPreSalesId())));
}
@Test
void updateOpportunityByIntegration_shouldForceStageS5WhenArchived() {
OpportunityIntegrationTargetDTO target = new OpportunityIntegrationTargetDTO();
target.setId(10L);
target.setOpportunityCode("V001");
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(target);
when(opportunityMapper.updateOpportunityByIntegration(eq(10L), any(UpdateOpportunityIntegrationRequest.class))).thenReturn(1);
UpdateOpportunityIntegrationRequest request = new UpdateOpportunityIntegrationRequest();
request.setOpportunityCode("V001");
request.setArchived(Boolean.TRUE);
Long result = opportunityService.updateOpportunityByIntegration(request);
assertEquals(10L, result);
verify(opportunityMapper).updateOpportunityByIntegration(eq(10L), argThat(normalized ->
"S5".equals(normalized.getStage())
&& Boolean.TRUE.equals(normalized.getArchived())));
}
@Test
void createOpportunity_shouldPersistEditableSnapshotFields() {
when(permissionService.hasPermi("opportunity:create")).thenReturn(true);

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/crm-favicon.svg" />
<title>紫光汇智CRM</title>
<title>Sales-Channel-Customer</title>
</head>
<body>
<div id="root"></div>

View File

@ -2,102 +2,156 @@
"hash": "540407b6",
"configHash": "4d48f89c",
"lockfileHash": "dddd8ddf",
"browserHash": "11edbc54",
"browserHash": "7051e2af",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "f1e46176",
"fileHash": "c8453beb",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "efb2a8e2",
"fileHash": "83a8035a",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "d5b77aa1",
"fileHash": "d679482b",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "ad778c8f",
"fileHash": "c64a82f0",
"needsInterop": true
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "cde11afc",
"fileHash": "6aeec3e9",
"needsInterop": false
},
"date-fns": {
"src": "../../date-fns/index.js",
"file": "date-fns.js",
"fileHash": "b6351193",
"fileHash": "c6024d92",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "8c7d4e04",
"fileHash": "3cfd294b",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "92d599f0",
"fileHash": "dc6ddd02",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "02796eff",
"fileHash": "b90ab706",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "1f5aeac8",
"fileHash": "2d681b0e",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "5ab4e882",
"fileHash": "35b43695",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../date-fns/locale.js",
"file": "date-fns_locale.js",
"fileHash": "46729450",
"fileHash": "a3802832",
"needsInterop": false
},
"exceljs": {
"src": "../../exceljs/dist/exceljs.min.js",
"file": "exceljs.js",
"fileHash": "1b4cd078",
"fileHash": "a7091ae1",
"needsInterop": true
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "f145b69c",
"fileHash": "83123172",
"needsInterop": false
},
"echarts": {
"src": "../../echarts/index.js",
"file": "echarts.js",
"fileHash": "5cad0870",
"fileHash": "b3662c14",
"needsInterop": false
},
"echarts/core": {
"src": "../../echarts/core.js",
"file": "echarts_core.js",
"fileHash": "fe44db4f",
"needsInterop": false
},
"echarts/charts": {
"src": "../../echarts/charts.js",
"file": "echarts_charts.js",
"fileHash": "6663f90e",
"needsInterop": false
},
"echarts/components": {
"src": "../../echarts/components.js",
"file": "echarts_components.js",
"fileHash": "105e6a53",
"needsInterop": false
},
"echarts/renderers": {
"src": "../../echarts/renderers.js",
"file": "echarts_renderers.js",
"fileHash": "175febe0",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "36e398a9",
"needsInterop": false
}
},
"chunks": {
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
"chunk-XJ4TCYC4": {
"file": "chunk-XJ4TCYC4.js"
},
"chunk-UQYG3D35": {
"file": "chunk-UQYG3D35.js"
},
"chunk-CPZR7XF2": {
"file": "chunk-CPZR7XF2.js"
},
"chunk-4GEPX7DH": {
"file": "chunk-4GEPX7DH.js"
},
"chunk-JUJSOZUN": {
"file": "chunk-JUJSOZUN.js"
},
"chunk-JOZHVH7B": {
"file": "chunk-JOZHVH7B.js"
},
"chunk-VTW7MBX2": {
"file": "chunk-VTW7MBX2.js"
},
"chunk-JQQT7BUO": {
"file": "chunk-JQQT7BUO.js"
},
"chunk-IS7GUWH2": {
"file": "chunk-IS7GUWH2.js"
},
"chunk-BCIG5HOZ": {
"file": "chunk-BCIG5HOZ.js"
@ -105,6 +159,9 @@
"chunk-ZFXKT4LN": {
"file": "chunk-ZFXKT4LN.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-5MXL5BYH": {
"file": "chunk-5MXL5BYH.js"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -3,20 +3,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, type ReactNode } from "react";
import { lazy, Suspense, useEffect, type ReactNode } from "react";
import { BrowserRouter, Navigate, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Expansion from "./pages/Expansion";
import Opportunities from "./pages/Opportunities";
import Work from "./pages/Work";
import Profile from "./pages/Profile";
import OwnerTransfer from "./pages/OwnerTransfer";
import { ThemeProvider } from "./components/ThemeProvider";
import LoginPage from "./pages/Login";
import WecomLoginCallbackPage from "./pages/WecomLoginCallback";
import { isAuthed, startAuthSessionMonitor } from "./lib/auth";
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Expansion = lazy(() => import("./pages/Expansion"));
const Opportunities = lazy(() => import("./pages/Opportunities"));
const Work = lazy(() => import("./pages/Work"));
const Profile = lazy(() => import("./pages/Profile"));
const OwnerTransfer = lazy(() => import("./pages/OwnerTransfer"));
const LoginPage = lazy(() => import("./pages/Login"));
const WecomLoginCallbackPage = lazy(() => import("./pages/WecomLoginCallback"));
function PageLoadingFallback() {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
</div>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
if (!isAuthed()) {
return <Navigate to="/login" replace />;
@ -31,25 +40,27 @@ export default function App() {
return (
<ThemeProvider defaultTheme="light" storageKey="crm-theme">
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login/wecom" element={<WecomLoginCallbackPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="expansion" element={<Expansion />} />
<Route path="opportunities" element={<Opportunities />} />
<Route path="work/*" element={<Work />} />
<Route path="profile" element={<Profile />} />
<Route path="owner-transfer" element={<OwnerTransfer />} />
</Route>
</Routes>
<Suspense fallback={<PageLoadingFallback />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login/wecom" element={<WecomLoginCallbackPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="expansion" element={<Expansion />} />
<Route path="opportunities" element={<Opportunities />} />
<Route path="work/*" element={<Work />} />
<Route path="profile" element={<Profile />} />
<Route path="owner-transfer" element={<OwnerTransfer />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</ThemeProvider>
);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, X } from "lucide-react";
import { createPortal } from "react-dom";
@ -26,6 +26,8 @@ type AdaptiveSelectBaseProps = {
className?: string;
searchable?: boolean;
searchPlaceholder?: string;
/** 有选中值时在框内显示 x 重置图标,点击清空所选 */
clearable?: boolean;
};
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
@ -79,6 +81,7 @@ export function AdaptiveSelect({
className,
searchable = false,
searchPlaceholder = "请输入关键字搜索",
clearable = false,
value,
multiple = false,
onChange,
@ -90,7 +93,7 @@ export function AdaptiveSelect({
const isMobile = useIsMobileViewport();
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(288);
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top: number } | null>(null);
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top?: number; bottom?: number } | null>(null);
const selectedValues = multiple
? Array.isArray(value) ? value : []
: typeof value === "string" && value ? [value] : [];
@ -131,9 +134,8 @@ export function AdaptiveSelect({
setDesktopDropdownStyle({
left: rect.left,
width: rect.width,
top: shouldOpenUpward
? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight))
: rect.bottom + 8,
top: shouldOpenUpward ? undefined : rect.bottom + 8,
bottom: shouldOpenUpward ? window.innerHeight - rect.top + 8 : undefined,
});
}, [isMobile, multiple, open, searchable]);
@ -213,6 +215,16 @@ export function AdaptiveSelect({
setOpen(false);
};
const handleClear = (event: ReactMouseEvent<HTMLSpanElement>) => {
event.stopPropagation();
event.preventDefault();
if (multiple) {
(onChange as (value: string[]) => void)([]);
} else {
(onChange as (value: string) => void)("");
}
};
const renderOption = (option: AdaptiveSelectOption) => {
const isSelected = multiple
? selectedValues.includes(option.value)
@ -256,7 +268,20 @@ export function AdaptiveSelect({
<span className={selectedValues.length > 0 ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
{selectedLabel}
</span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
{clearable && !disabled && selectedValues.length > 0 ? (
<span
role="button"
aria-label="重置"
title="重置"
tabIndex={-1}
onClick={handleClear}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
>
<X className="h-4 w-4" />
</span>
) : (
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
)}
</button>
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
@ -269,6 +294,7 @@ export function AdaptiveSelect({
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
top: desktopDropdownStyle.top,
bottom: desktopDropdownStyle.bottom,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>

View File

@ -26,7 +26,7 @@ function BrandLockup({ mobile = false }: { mobile?: boolean }) {
<div className="flex min-w-0 items-center gap-2.5">
<img
src="/crm-favicon.svg"
alt="紫光汇智CRM 图标"
alt="销售易 图标"
className={cn(
"shrink-0 rounded-2xl shadow-sm ring-1 ring-violet-200/60",
mobile ? "h-7 w-7 rounded-xl" : "h-9 w-9"
@ -38,7 +38,7 @@ function BrandLockup({ mobile = false }: { mobile?: boolean }) {
mobile ? "text-sm uppercase tracking-[0.12em]" : "text-lg"
)}
>
CRM
SCC
</span>
</div>
);

View File

@ -0,0 +1,350 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronDown, Search, X } from "lucide-react";
import type { CrmId } from "@/lib/auth";
import { cn } from "@/lib/utils";
export type SearchOrInputOption = {
value: CrmId;
label: string;
keywords?: string[];
};
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
export function SearchOrInputSelect({
valueId,
valueText,
options,
placeholder,
searchPlaceholder,
emptyText,
loading = false,
className,
onChange,
onQueryChange,
}: {
valueId?: CrmId;
valueText?: string;
options: SearchOrInputOption[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
loading?: boolean;
className?: string;
onChange: (id: CrmId, text: string) => void;
onQueryChange?: (query: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const isMobile = useIsMobileViewport();
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
const normalizedOptions = useMemo(() => {
const seen = new Set<SearchOrInputOption["value"]>();
return options.filter((option) => {
if (seen.has(option.value)) {
return false;
}
seen.add(option.value);
return option.label.trim() !== "";
});
}, [options]);
const displayValue = typeof valueText === "string" && valueText.trim() !== "" ? valueText : null;
const normalizedQuery = query.trim().toLowerCase();
const filteredOptions = useMemo(() => {
if (!normalizedQuery) {
return normalizedOptions;
}
return normalizedOptions.filter((item) => {
const haystacks = [item.label, ...(item.keywords ?? [])]
.filter(Boolean)
.map((entry) => entry.toLowerCase());
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
}, [normalizedOptions, normalizedQuery]);
const hasExactOption = normalizedQuery
? normalizedOptions.some((item) => item.label.trim().toLowerCase() === normalizedQuery)
: false;
const showCustomAction = Boolean(normalizedQuery);
const resetQuery = () => {
setQuery("");
onQueryChange?.("");
};
const selectOption = (item: SearchOrInputOption) => {
onChange(item.value, item.label);
setOpen(false);
resetQuery();
};
const selectCustom = () => {
const text = query.trim();
if (text) {
onChange(0, text);
}
setOpen(false);
resetQuery();
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) {
resetQuery();
}
};
useEffect(() => {
if (!open || isMobile) {
setDesktopDropdownStyle(null);
return;
}
}, [isMobile, open]);
useEffect(() => {
if (!open) {
return;
}
const t = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}, [open]);
useEffect(() => {
if (!open || isMobile) {
return;
}
const updateDesktopDropdownLayout = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || typeof window === "undefined") {
return;
}
const viewportHeight = window.innerHeight;
const safePadding = 24;
const panelPadding = 96;
const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding);
const availableAbove = Math.max(160, rect.top - safePadding - panelPadding);
const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow;
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow));
setDesktopDropdownStyle({
top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8,
left: rect.left,
width: rect.width,
});
};
updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
setOpen(false);
resetQuery();
}
};
const handleViewportChange = () => updateDesktopDropdownLayout();
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
const renderSearchBody = () => (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
ref={inputRef}
value={query}
onChange={(event) => {
const nextQuery = event.target.value;
setQuery(nextQuery);
onQueryChange?.(nextQuery);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
selectCustom();
}
}}
placeholder={searchPlaceholder}
className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white"
/>
</div>
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
{showCustomAction ? (
<button
type="button"
onClick={selectCustom}
className="w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-violet-600 transition-colors hover:bg-violet-50 dark:text-violet-400 dark:hover:bg-violet-500/10"
>
使{query.trim()}
</button>
) : null}
{filteredOptions.length > 0 ? (
filteredOptions.map((item) => (
<button
type="button"
key={item.value}
onClick={() => selectOption(item)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors ${
valueId !== undefined && valueId !== null && valueId !== "" && String(valueId) === String(item.value)
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{item.label}</span>
{valueId !== undefined && valueId !== null && valueId !== "" && String(valueId) === String(item.value) ? (
<Check className="h-4 w-4 shrink-0" />
) : null}
</button>
))
) : loading ? (
<div className="crm-empty-state px-3 py-6">
<p>...</p>
</div>
) : (
<div className="crm-empty-state px-3 py-6">
<p>{showCustomAction ? "未找到匹配项,可直接使用上方输入的文字" : emptyText}</p>
</div>
)}
</div>
</>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => handleOpenChange(!open)}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={displayValue ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{displayValue || placeholder}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[220]">
<div
ref={desktopDropdownRef}
style={{
position: "absolute",
top: desktopDropdownPlacement === "top"
? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
: desktopDropdownStyle.top,
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div style={{ maxHeight: `${desktopDropdownMaxHeight}px` }} className="overflow-y-auto overscroll-contain pr-1">
{renderSearchBody()}
</div>
</div>
</div>,
document.body,
)
: null}
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => {
setOpen(false);
setQuery("");
}}
/>
<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-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{placeholder}</p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => {
setOpen(false);
setQuery("");
}}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">{renderSearchBody()}</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronDown, Search, X } from "lucide-react";
import type { CrmId } from "@/lib/auth";
import { cn } from "@/lib/utils";
export type SearchableOption = {
value: CrmId;
label: string;
keywords?: string[];
};
export function getSearchableOptionLabel(option: SearchableOption) {
const normalizedLabel = typeof option.label === "string" ? option.label.trim() : "";
if (normalizedLabel) {
return normalizedLabel;
}
return String(option.value ?? "");
}
export function dedupeSearchableOptions(options: SearchableOption[]) {
const seenValues = new Set<SearchableOption["value"]>();
return options.filter((option) => {
if (seenValues.has(option.value)) {
return false;
}
seenValues.add(option.value);
return true;
});
}
export function appendSearchableOptionIfMissing(options: SearchableOption[], fallbackOption?: SearchableOption | null) {
if (!fallbackOption) {
return options;
}
return dedupeSearchableOptions([...options, fallbackOption]);
}
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
export function SearchableSelect({
value,
options,
placeholder,
searchPlaceholder,
emptyText,
loading = false,
createActionLabel,
className,
onChange,
onCreate,
onQueryChange,
}: {
value?: CrmId;
options: SearchableOption[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
loading?: boolean;
createActionLabel?: string;
className?: string;
onChange: (value?: CrmId) => void;
onCreate?: (query: string) => void;
onQueryChange?: (query: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
// 记录最近一次选中的 value->label用于当前 options 不含选中项时仍能正确回显(如搜索后清空关键字重拉列表)
const [cachedSelection, setCachedSelection] = useState<{ value: SearchableOption["value"]; label: string } | null>(null);
const normalizedOptions = dedupeSearchableOptions(options);
const selectedOption = normalizedOptions.find((item) => item.value === value);
const hasValue = value !== undefined && value !== null && value !== "" && Number(value) !== 0;
const selectedLabel = hasValue
? selectedOption
? getSearchableOptionLabel(selectedOption)
: cachedSelection && cachedSelection.value === value
? cachedSelection.label
: null
: null;
const normalizedQuery = query.trim().toLowerCase();
const filteredOptions = normalizedOptions.filter((item) => {
if (!normalizedQuery) {
return true;
}
const haystacks = [getSearchableOptionLabel(item), ...(item.keywords ?? [])]
.filter(Boolean)
.map((entry) => entry.toLowerCase());
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
const resetQuery = () => {
setQuery("");
onQueryChange?.("");
};
useEffect(() => {
if (!open || isMobile) {
setDesktopDropdownStyle(null);
return;
}
const updateDesktopDropdownLayout = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || typeof window === "undefined") {
return;
}
const viewportHeight = window.innerHeight;
const safePadding = 24;
const panelPadding = 96;
const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding);
const availableAbove = Math.max(160, rect.top - safePadding - panelPadding);
const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow;
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow));
setDesktopDropdownStyle({
top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8,
left: rect.left,
width: rect.width,
});
};
updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
setOpen(false);
resetQuery();
}
};
const handleViewportChange = () => {
updateDesktopDropdownLayout();
};
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
const renderSearchBody = () => (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
autoFocus
value={query}
onChange={(event) => {
const nextQuery = event.target.value;
setQuery(nextQuery);
onQueryChange?.(nextQuery);
}}
placeholder={searchPlaceholder}
className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white"
/>
</div>
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
<button
type="button"
onClick={() => {
onChange(undefined);
setCachedSelection(null);
setOpen(false);
resetQuery();
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
</button>
{filteredOptions.length > 0 ? (
filteredOptions.map((item) => (
<button
type="button"
key={item.value}
onClick={() => {
setCachedSelection({ value: item.value, label: getSearchableOptionLabel(item) });
onChange(item.value);
setOpen(false);
resetQuery();
}}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors ${
item.value === value
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{getSearchableOptionLabel(item)}</span>
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))
) : loading ? (
<div className="crm-empty-state px-3 py-6">
<p>...</p>
</div>
) : (
<div className="crm-empty-state px-3 py-6">
<p>{emptyText}</p>
{onCreate ? (
<button
type="button"
onClick={() => {
onCreate(query);
setOpen(false);
resetQuery();
}}
className="mt-3 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
>
{createActionLabel || "新增并选中"}
</button>
) : null}
</div>
)}
</div>
</>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => {
setOpen((current) => {
const next = !current;
if (!next) {
resetQuery();
}
return next;
});
}}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={selectedLabel ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{selectedLabel || placeholder}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[220]">
<div
ref={desktopDropdownRef}
style={{
position: "absolute",
top: desktopDropdownPlacement === "top"
? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
: desktopDropdownStyle.top,
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
className="overflow-y-auto overscroll-contain pr-1"
>
{renderSearchBody()}
</div>
</div>
</div>,
document.body,
)
: null}
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => {
setOpen(false);
setQuery("");
}}
/>
<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-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{placeholder}</p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => {
setOpen(false);
setQuery("");
}}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{renderSearchBody()}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}

View File

@ -1,9 +1,24 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as echarts from "echarts";
import * as echarts from "echarts/core";
import { BarChart, FunnelChart, LineChart, PieChart } from "echarts/charts";
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsOption } from "echarts";
import type { DashboardAnalyticsCard } from "@/lib/auth";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
echarts.use([
LineChart,
BarChart,
PieChart,
FunnelChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
CanvasRenderer,
]);
type ChartPoint = NonNullable<DashboardAnalyticsCard["chartData"]>[number];
type RenderType = NonNullable<DashboardAnalyticsCard["renderType"]>;
type ValueType = DashboardAnalyticsCard["valueType"];

View File

@ -28,6 +28,7 @@ export type ChannelField =
| "certificationLevel"
| "annualRevenue"
| "staffSize"
| "registeredCapital"
| "contactEstablishedDate"
| "intentLevel"
| "channelAttribute"
@ -35,6 +36,121 @@ export type ChannelField =
| "internalAttribute"
| "contacts";
/** 渠道联系人固定 6 行,工作职责不可编辑 */
export const CHANNEL_CONTACT_DUTY_OPTIONS = [
"销售负责人",
"技术负责人",
"商务负责人",
"宣传负责人",
"订单通知接单人",
"汇智日常联络人",
] as const;
/** 是否加企业微信下拉:使用系统「是否」字典(是/否),兜底值 */
const DEFAULT_WECOM_OPTIONS: ExpansionDictOption[] = [
{ value: "1", label: "是" },
{ value: "0", label: "否" },
];
/** 渠道联系人常驻提示文案:固定显示在联系人表格下方,不作为校验错误提示 */
export const CHANNEL_CONTACT_HINT_MESSAGE =
"请至少完整填写一行联系人(姓名、职务、联系电话、是否加企业微信、特别说明为必填,生日选填),空行可留空";
function normalizeContactText(value?: string | null) {
const trimmed = value?.trim();
return !trimmed || trimmed === "无" ? "" : trimmed;
}
export function isEmptyChannelContact(contact?: ChannelExpansionContact) {
return (
!normalizeContactText(contact?.name) &&
!normalizeContactText(contact?.mobile) &&
!normalizeContactText(contact?.title) &&
!normalizeContactText(contact?.birthday) &&
!normalizeContactText(contact?.wecomAdded) &&
!normalizeContactText(contact?.specialNote)
);
}
function isCompleteChannelContact(contact?: ChannelExpansionContact) {
return Boolean(
normalizeContactText(contact?.name) &&
normalizeContactText(contact?.mobile) &&
normalizeContactText(contact?.title) &&
normalizeContactText(contact?.wecomAdded) &&
normalizeContactText(contact?.specialNote),
);
}
/** 生成固定 6 行联系人(每行带固定工作职责) */
export function createDefaultChannelContacts(): ChannelExpansionContact[] {
return CHANNEL_CONTACT_DUTY_OPTIONS.map((duty) => ({
duty,
name: "",
mobile: "",
title: "",
birthday: "",
wecomAdded: "",
specialNote: "",
}));
}
/**
* 6
* /
*/
export function buildChannelContactRows(contacts?: ChannelExpansionContact[]): ChannelExpansionContact[] {
const rows = createDefaultChannelContacts();
const saved = (contacts ?? []).filter((contact) => !isEmptyChannelContact(contact));
const assignToRow = (row: ChannelExpansionContact, contact: ChannelExpansionContact) => {
row.name = normalizeContactText(contact.name);
row.mobile = normalizeContactText(contact.mobile);
row.title = normalizeContactText(contact.title);
row.birthday = normalizeContactText(contact.birthday);
row.wecomAdded = normalizeContactText(contact.wecomAdded);
row.specialNote = normalizeContactText(contact.specialNote);
};
saved.forEach((contact) => {
const matched = rows.find((row) => row.duty === normalizeContactText(contact.duty));
if (matched && isEmptyChannelContact(matched)) {
assignToRow(matched, contact);
}
});
saved.forEach((contact) => {
const matched = rows.find((row) => row.duty === normalizeContactText(contact.duty));
if (matched && !isEmptyChannelContact(matched)) {
return;
}
const fallback = rows.find((row) => isEmptyChannelContact(row));
if (fallback) {
assignToRow(fallback, contact);
}
});
return rows;
}
/** 校验联系人固定行:至少一行完整,已填写的行需补全必填项(生日选填) */
export function validateChannelContactRows(contacts?: ChannelExpansionContact[]) {
const invalidContactRows: number[] = [];
let hasCompleteRow = false;
(contacts ?? []).forEach((contact, index) => {
if (isEmptyChannelContact(contact)) {
return;
}
if (isCompleteChannelContact(contact)) {
hasCompleteRow = true;
} else {
invalidContactRows.push(index);
}
});
const error = hasCompleteRow && invalidContactRows.length <= 0
? ""
: invalidContactRows.length > 0
? "请补全已填写联系人行的必填项(姓名、职务、联系电话、是否加企业微信、特别说明),生日选填"
: "请至少完整填写一行联系人(姓名、职务、联系电话、是否加企业微信、特别说明为必填)";
return { error, invalidContactRows, hasCompleteRow };
}
export const defaultQuickSalesForm: CreateSalesExpansionPayload = {
employeeNo: "",
candidateName: "",
@ -47,12 +163,6 @@ export const defaultQuickSalesForm: CreateSalesExpansionPayload = {
employmentStatus: "active",
};
export const createEmptyChannelContact = (): ChannelExpansionContact => ({
name: "",
mobile: "",
title: "",
});
export const defaultQuickChannelForm: CreateChannelExpansionPayload = {
channelCode: "",
channelName: "",
@ -69,7 +179,7 @@ export const defaultQuickChannelForm: CreateChannelExpansionPayload = {
internalAttribute: [],
stage: "initial_contact",
remark: "",
contacts: [createEmptyChannelContact()],
contacts: createDefaultChannelContacts(),
};
export function normalizeOptionalText(value?: string) {
@ -125,7 +235,6 @@ export function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
export function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) {
const errors: Partial<Record<ChannelField, string>> = {};
const invalidContactRows: number[] = [];
if (!form.channelName?.trim()) {
errors.channelName = "请填写渠道名称";
@ -151,6 +260,9 @@ export function validateChannelForm(form: CreateChannelExpansionPayload, channel
if (!form.staffSize || form.staffSize <= 0) {
errors.staffSize = "请填写人员规模";
}
if (!form.registeredCapital || form.registeredCapital <= 0) {
errors.registeredCapital = "请填写注册资金(万元)";
}
if (!form.contactEstablishedDate?.trim()) {
errors.contactEstablishedDate = "请选择建立联系时间";
}
@ -167,25 +279,12 @@ export function validateChannelForm(form: CreateChannelExpansionPayload, channel
errors.internalAttribute = "请选择新华三内部属性";
}
const contacts = form.contacts ?? [];
if (contacts.length <= 0) {
errors.contacts = "请至少填写一位渠道联系人";
invalidContactRows.push(0);
} else {
contacts.forEach((contact, index) => {
const hasName = Boolean(contact.name?.trim());
const hasMobile = Boolean(contact.mobile?.trim());
const hasTitle = Boolean(contact.title?.trim());
if (!hasName || !hasMobile || !hasTitle) {
invalidContactRows.push(index);
}
});
if (invalidContactRows.length > 0) {
errors.contacts = "请完整填写每位渠道联系人的姓名、联系电话和职位";
}
const contactValidation = validateChannelContactRows(form.contacts);
if (contactValidation.error) {
errors.contacts = contactValidation.error;
}
return { errors, invalidContactRows };
return { errors, invalidContactRows: contactValidation.invalidContactRows };
}
export function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload {
@ -219,6 +318,7 @@ export function normalizeChannelPayload(payload: CreateChannelExpansionPayload):
certificationLevel: normalizeOptionalText(payload.certificationLevel),
annualRevenue: payload.annualRevenue || undefined,
staffSize: payload.staffSize || undefined,
registeredCapital: payload.registeredCapital || undefined,
contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate),
intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium",
hasDesktopExp: Boolean(payload.hasDesktopExp),
@ -229,11 +329,15 @@ export function normalizeChannelPayload(payload: CreateChannelExpansionPayload):
remark: normalizeOptionalText(payload.remark),
contacts: (payload.contacts ?? [])
.map((contact) => ({
duty: normalizeOptionalText(contact.duty),
name: normalizeOptionalText(contact.name),
mobile: normalizeOptionalText(contact.mobile),
title: normalizeOptionalText(contact.title),
birthday: normalizeOptionalText(contact.birthday),
wecomAdded: normalizeOptionalText(contact.wecomAdded),
specialNote: normalizeOptionalText(contact.specialNote),
}))
.filter((contact) => contact.name || contact.mobile || contact.title),
.filter((contact) => contact.name || contact.mobile || contact.title || contact.birthday || contact.wecomAdded || contact.specialNote),
};
}
@ -357,6 +461,84 @@ export function QuickSalesForm({
);
}
/** 渠道联系人固定 6 行表格:工作职责只读,其余列可编辑(生日选填) */
export function ChannelContactRows({
contacts,
invalidContactRows,
onContactChange,
wecomOptions = DEFAULT_WECOM_OPTIONS,
}: {
contacts: ChannelExpansionContact[];
invalidContactRows: number[];
onContactChange: (index: number, key: keyof ChannelExpansionContact, value: string) => void;
wecomOptions?: ExpansionDictOption[];
}) {
const gridClass = "grid grid-cols-[160px_minmax(100px,1fr)_minmax(100px,1fr)_150px_150px_minmax(130px,0.9fr)_minmax(260px,1.6fr)] items-center gap-3";
const cellInputClass = (hasError: boolean) =>
cn(
"w-full min-w-0 rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50",
hasError
? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10"
: "border-slate-200 dark:border-slate-800",
);
return (
<>
<div className="overflow-x-auto">
<div className="w-full min-w-[1140px] space-y-2.5">
<div className={cn(gridClass, "px-1 text-xs font-medium text-slate-500 dark:text-slate-400")}>
<span className="pr-1"></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span className="pr-1"></span>
<span className="pr-1"></span>
</div>
{contacts.map((contact, index) => (
<div
key={`${contact.duty || "duty"}-${index}`}
className={cn(
gridClass,
"rounded-xl border p-2.5",
invalidContactRows.includes(index)
? "border-rose-400 bg-rose-50/40 dark:border-rose-500/70 dark:bg-rose-500/10"
: "border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900/50",
)}
>
<input
readOnly
value={contact.duty || ""}
title={contact.duty || ""}
className="w-full min-w-0 cursor-default truncate rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600 outline-none dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-300"
/>
<input value={contact.name || ""} onChange={(e) => onContactChange(index, "name", e.target.value)} placeholder="姓名" className={cellInputClass(invalidContactRows.includes(index))} />
<input value={contact.title || ""} onChange={(e) => onContactChange(index, "title", e.target.value)} placeholder="职务" className={cellInputClass(invalidContactRows.includes(index))} />
<input value={contact.mobile || ""} onChange={(e) => onContactChange(index, "mobile", e.target.value)} placeholder="联系电话" className={cellInputClass(invalidContactRows.includes(index))} />
<input type="date" value={contact.birthday || ""} onChange={(e) => onContactChange(index, "birthday", e.target.value)} className={cellInputClass(false)} />
<AdaptiveSelect
value={contact.wecomAdded || ""}
placeholder="请选择"
sheetTitle="是否加企业微信"
options={[
{ value: "", label: "请选择" },
...wecomOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cellInputClass(invalidContactRows.includes(index))}
onChange={(value) => onContactChange(index, "wecomAdded", value)}
/>
<input value={contact.specialNote || ""} onChange={(e) => onContactChange(index, "specialNote", e.target.value)} placeholder="填写依法合规,不得填写客户隐私" className={cellInputClass(invalidContactRows.includes(index))} />
</div>
))}
</div>
</div>
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">{CHANNEL_CONTACT_HINT_MESSAGE}</p>
</>
);
}
export function QuickChannelForm({
form,
fieldErrors,
@ -367,13 +549,12 @@ export function QuickChannelForm({
certificationLevelOptions,
channelAttributeOptions,
internalAttributeOptions,
wecomOptions,
channelOtherOptionValue,
duplicateMessage,
requiredMark,
onChange,
onContactChange,
onAddContact,
onRemoveContact,
onProvinceChange,
}: {
form: CreateChannelExpansionPayload;
@ -385,13 +566,12 @@ export function QuickChannelForm({
certificationLevelOptions: ExpansionDictOption[];
channelAttributeOptions: ExpansionDictOption[];
internalAttributeOptions: ExpansionDictOption[];
wecomOptions?: ExpansionDictOption[];
channelOtherOptionValue?: string;
duplicateMessage: string;
requiredMark: ReactNode;
onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void;
onContactChange: (index: number, key: keyof ChannelExpansionContact, value: string) => void;
onAddContact: () => void;
onRemoveContact: (index: number) => void;
onProvinceChange: (value: string) => void;
}) {
const cityDisabled = !form.province?.trim();
@ -501,6 +681,11 @@ export function QuickChannelForm({
<input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors.staffSize))} />
{fieldErrors.staffSize ? <p className="text-xs text-rose-500">{fieldErrors.staffSize}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">(){requiredMark}</span>
<input type="number" min="0.01" step="0.01" placeholder="请输入万元" value={form.registeredCapital ?? ""} onChange={(e) => onChange("registeredCapital", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors.registeredCapital))} />
{fieldErrors.registeredCapital ? <p className="text-xs text-rose-500">{fieldErrors.registeredCapital}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{requiredMark}</span>
<input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.contactEstablishedDate))} />
@ -572,22 +757,13 @@ export function QuickChannelForm({
<div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">{requiredMark}</span>
<button type="button" onClick={onAddContact} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400">
</button>
</div>
<div className="crm-section-stack">
{(form.contacts ?? []).map((contact, index) => (
<div key={`quick-channel-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50">
<input value={contact.name || ""} onChange={(e) => onContactChange(index, "name", e.target.value)} placeholder="人员姓名" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<input value={contact.mobile || ""} onChange={(e) => onContactChange(index, "mobile", e.target.value)} placeholder="联系电话" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<input value={contact.title || ""} onChange={(e) => onContactChange(index, "title", e.target.value)} placeholder="职位" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<button type="button" onClick={() => onRemoveContact(index)} className="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
</button>
</div>
))}
</div>
<ChannelContactRows
contacts={form.contacts ?? []}
invalidContactRows={invalidContactRows}
onContactChange={onContactChange}
wecomOptions={wecomOptions}
/>
{fieldErrors.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
</div>
<label className="space-y-2 sm:col-span-2">

View File

@ -160,6 +160,7 @@ export interface DashboardAnalyticsCard {
errorMessage?: string;
totalCount?: number;
hasMore?: boolean;
dataLoaded?: boolean;
chartData?: Array<{
label?: string;
value?: string;
@ -287,7 +288,7 @@ export interface WorkCheckIn {
longitude?: number;
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizType?: "sales" | "channel" | "opportunity" | "crm";
bizId?: CrmId;
bizName?: string;
userName?: string;
@ -416,7 +417,7 @@ export interface CreateWorkCheckInPayload {
longitude?: number;
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizType?: "sales" | "channel" | "opportunity" | "crm";
bizId?: CrmId;
bizName?: string;
userName?: string;
@ -425,7 +426,7 @@ export interface CreateWorkCheckInPayload {
export interface WorkReportLineItem {
workDate: string;
bizType: "sales" | "channel" | "opportunity";
bizType: "sales" | "channel" | "opportunity" | "crm";
bizId: CrmId;
bizName?: string;
editorText?: string;
@ -522,8 +523,22 @@ export interface OpportunityDictOption {
value?: string;
}
export interface OpportunityDuplicateItem {
opportunityId: CrmId;
opportunityCode: string;
opportunityName: string;
customerName: string;
stage: string;
}
export interface OpportunityDuplicateCheck {
duplicate: boolean;
items: OpportunityDuplicateItem[];
}
export interface OpportunityMeta {
stageOptions?: OpportunityDictOption[];
allStageOptions?: OpportunityDictOption[];
operatorOptions?: OpportunityDictOption[];
projectLocationOptions?: OpportunityDictOption[];
projectOwnershipLocationOptions?: OpportunityDictOption[];
@ -613,6 +628,9 @@ export interface SalesExpansionItem {
expectedJoinDate?: string;
updatedAt?: string;
notes?: string;
regionProvince?: string;
regionCity?: string;
regionItems?: string;
relatedProjects?: RelatedProjectSummary[];
followUps?: ExpansionFollowUp[];
}
@ -638,6 +656,9 @@ export interface ChannelExpansionItem {
province?: string;
cityCode?: string;
city?: string;
coverageProvince?: string;
coverageCity?: string;
coverageItems?: string;
officeAddress?: string;
channelIndustryCode?: string;
channelIndustry?: string;
@ -645,6 +666,7 @@ export interface ChannelExpansionItem {
annualRevenue?: string;
revenue?: string;
size?: number;
registeredCapital?: string;
primaryContactName?: string;
primaryContactTitle?: string;
primaryContactMobile?: string;
@ -669,9 +691,55 @@ export interface ChannelExpansionItem {
export interface ChannelExpansionContact {
id?: CrmId;
duty?: string;
name?: string;
mobile?: string;
title?: string;
birthday?: string;
wecomAdded?: string;
specialNote?: string;
}
export interface CrmExpansionContact {
id?: CrmId;
crmExpansionId?: CrmId;
name?: string;
mobile?: string;
title?: string;
}
export interface CrmExpansionItem {
id: CrmId;
ownerUserId?: CrmId;
owner?: string;
type: "crm";
endUser?: string;
officeName?: string;
industryAttr?: string;
industryAttrCode?: string;
extensionType?: string;
purchaseDate?: string;
purchaseDateText?: string;
warrantyExpiry?: string;
warrantyExpiryText?: string;
onlineStatus?: string;
contactName?: string;
contactPhone?: string;
contactTitle?: string;
contacts?: CrmExpansionContact[];
supplierId?: CrmId;
supplierName?: string;
h3cContactId?: CrmId;
h3cContactName?: string;
hasExpansionOpportunity?: string;
softwarePoints?: number;
expansionTime?: string;
expansionTimeText?: string;
expansionScale?: string;
hasMaintenanceOpportunity?: string;
createdAt?: string;
updatedAt?: string;
followUps?: ExpansionFollowUp[];
}
export interface ChannelRelatedProjectSummary {
@ -686,6 +754,7 @@ export interface ChannelRelatedProjectSummary {
export interface ExpansionOverview {
salesItems?: SalesExpansionItem[];
channelItems?: ChannelExpansionItem[];
crmItems?: CrmExpansionItem[];
}
export interface ExpansionDictOption {
@ -701,6 +770,9 @@ export interface ExpansionMeta {
channelAttributeOptions?: ExpansionDictOption[];
internalAttributeOptions?: ExpansionDictOption[];
nextChannelCode?: string;
extensionTypeOptions?: ExpansionDictOption[];
onlineStatusOptions?: ExpansionDictOption[];
isOptions?: ExpansionDictOption[];
}
export interface ExpansionDuplicateCheck {
@ -724,6 +796,9 @@ export interface CreateSalesExpansionPayload {
employmentStatus?: string;
expectedJoinDate?: string;
remark?: string;
regionProvince?: string[];
regionCity?: string[];
regionItems?: { province?: string; city?: string }[];
}
export interface CreateChannelExpansionPayload {
@ -733,9 +808,13 @@ export interface CreateChannelExpansionPayload {
channelName: string;
province?: string;
city?: string;
coverageProvince?: string[];
coverageCity?: string[];
coverageItems?: { province?: string; city?: string }[];
certificationLevel?: string;
annualRevenue?: number;
staffSize?: number;
registeredCapital?: number;
contactEstablishedDate?: string;
intentLevel?: string;
hasDesktopExp?: boolean;
@ -751,6 +830,77 @@ export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {}
export interface CreateCrmExpansionPayload {
endUser: string;
officeName: string;
industryAttr: string[];
extensionType: string[];
purchaseDate: string;
warrantyExpiry: string;
onlineStatus: string;
contacts: CrmExpansionContact[];
supplierId: CrmId;
h3cContactId: CrmId;
/** 进货商名称:下拉选中冗余存名,手动输入时存文字 */
supplierName?: string;
/** 新华三对接人名称:下拉选中冗余存名,手动输入时存文字 */
h3cContactName?: string;
hasExpansionOpportunity: string;
softwarePoints?: number;
expansionTime?: string;
expansionScale?: string;
hasMaintenanceOpportunity: string;
}
export interface UpdateCrmExpansionPayload extends CreateCrmExpansionPayload {}
/** 渠道拓展 → CRM 拓展 迁移请求(迁移前弹窗补填的 CRM 必填字段) */
export interface MoveChannelToCrmPayload {
officeName?: string;
extensionType: string[];
purchaseDate: string;
warrantyExpiry: string;
onlineStatus: string;
supplierId: CrmId;
h3cContactId: CrmId;
/** 进货商名称:下拉选中冗余存名,手动输入时存文字 */
supplierName?: string;
/** 新华三对接人名称:下拉选中冗余存名,手动输入时存文字 */
h3cContactName?: string;
hasExpansionOpportunity: string;
softwarePoints?: number;
expansionTime?: string;
expansionScale?: string;
hasMaintenanceOpportunity: string;
/** 联系人(迁移弹窗补录);为空时后端回退使用源渠道联系人 */
contacts?: CrmExpansionContact[];
}
/** CRM 拓展 → 渠道拓展 迁移请求(迁移前弹窗补填的渠道必填字段) */
export interface MoveCrmToChannelPayload {
/** 省份(迁移弹窗省市区补填);为空时后端回退用代表处(office_name)反查字典 label */
province?: string;
city: string;
officeAddress: string;
certificationLevel: string;
annualRevenue: number;
staffSize: number;
registeredCapital: number;
channelAttribute: string[];
channelAttributeCustom?: string;
internalAttribute: string[];
coverageProvince?: string[];
coverageCity?: string[];
coverageItems?: { province?: string; city?: string }[];
intentLevel?: string;
hasDesktopExp?: boolean;
stage?: string;
landedFlag?: boolean;
expectedSignDate?: string;
/** 联系人迁移弹窗补录为空时后端回退使用源CRM联系人 */
contacts?: ChannelExpansionContact[];
}
const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
function normalizeExpansionMultiValues(values?: string[]) {
@ -798,12 +948,22 @@ function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload
...rest,
city: payload.city,
certificationLevel: payload.certificationLevel,
coverageProvince: encodeExpansionMultiValue(payload.coverageProvince),
coverageCity: encodeExpansionMultiValue(payload.coverageCity),
channelIndustry: encodeExpansionMultiValue(payload.channelIndustry),
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
};
}
function serializeCrmExpansionPayload(payload: CreateCrmExpansionPayload) {
return {
...payload,
industryAttr: encodeExpansionMultiValue(payload.industryAttr),
extensionType: encodeExpansionMultiValue(payload.extensionType),
};
}
export interface CreateExpansionFollowUpPayload {
followUpType: string;
content: string;
@ -1381,6 +1541,14 @@ export async function getDashboardAnalyticsCardDetail(cardKey: string, dimension
return request<DashboardAnalyticsCard>(`/api/dashboard/analytics-cards/${encodeURIComponent(cardKey)}${params}`, undefined, true);
}
export async function getDashboardAnalyticsCardsData(cardKeys: string[]) {
if (!cardKeys.length) {
return [] as DashboardAnalyticsCard[];
}
const params = `?cardKeys=${cardKeys.map((key) => encodeURIComponent(key)).join(",")}`;
return request<DashboardAnalyticsCard[]>(`/api/dashboard/analytics-cards/data${params}`, undefined, true);
}
export async function completeDashboardTodo(todoId: string) {
return request<void>(`/api/dashboard/todos/${todoId}/complete`, {
method: "POST",
@ -1664,6 +1832,14 @@ export async function createOpportunity(payload: CreateOpportunityPayload) {
}, true);
}
export async function checkOpportunityDuplicate(name: string, excludeStageCodes?: string[]) {
const params = new URLSearchParams({ name });
if (excludeStageCodes && excludeStageCodes.length > 0) {
excludeStageCodes.forEach((code) => params.append("excludeStageCodes", code));
}
return request<OpportunityDuplicateCheck>(`/api/opportunities/duplicate-check?${params.toString()}`, undefined, true);
}
export async function updateOpportunity(opportunityId: CrmId, payload: CreateOpportunityPayload) {
return request<number>(`/api/opportunities/${opportunityId}`, {
method: "PUT",
@ -1698,6 +1874,19 @@ export async function getExpansionOverview(keyword?: string, includeDetails = tr
return request<ExpansionOverview>(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getCrmExpansionOverview(keyword?: string, includeDetails = true, limit?: number) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
}
params.set("includeDetails", String(includeDetails));
if (limit != null) {
params.set("limit", String(limit));
}
const query = params.toString();
return request<ExpansionOverview>(`/api/expansion/crm/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getOpportunityExpansionOptions(params?: { keyword?: string; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.keyword && params.keyword.trim()) {
@ -1735,6 +1924,14 @@ export async function checkChannelExpansionDuplicate(channelName: string, exclud
return request<ExpansionDuplicateCheck>(`/api/expansion/channel/duplicate-check?${params.toString()}`, undefined, true);
}
export async function checkCrmExpansionDuplicate(endUser: string, excludeId?: CrmId) {
const params = new URLSearchParams({ endUser });
if (excludeId) {
params.set("excludeId", String(excludeId));
}
return request<ExpansionDuplicateCheck>(`/api/expansion/crm/duplicate-check?${params.toString()}`, undefined, true);
}
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
return request<CrmId>("/api/expansion/sales", {
method: "POST",
@ -1763,8 +1960,22 @@ export async function updateChannelExpansion(id: CrmId, payload: UpdateChannelEx
}, true);
}
export async function createCrmExpansion(payload: CreateCrmExpansionPayload) {
return request<CrmId>("/api/expansion/crm", {
method: "POST",
body: JSON.stringify(serializeCrmExpansionPayload(payload)),
}, true);
}
export async function updateCrmExpansion(id: CrmId, payload: UpdateCrmExpansionPayload) {
return request<void>(`/api/expansion/crm/${id}`, {
method: "PUT",
body: JSON.stringify(serializeCrmExpansionPayload(payload)),
}, true);
}
export async function createExpansionFollowUp(
bizType: "sales" | "channel",
bizType: "sales" | "channel" | "crm",
bizId: CrmId,
payload: CreateExpansionFollowUpPayload,
) {
@ -1774,6 +1985,34 @@ export async function createExpansionFollowUp(
}, true);
}
/** 渠道拓展移至 CRM 拓展,返回新 CRM 拓展 id */
export async function moveChannelToCrm(id: CrmId, payload: MoveChannelToCrmPayload) {
const body = {
...payload,
extensionType: encodeExpansionMultiValue(payload.extensionType),
};
return request<CrmId>(`/api/expansion/channel/${id}/move-to-crm`, {
method: "POST",
body: JSON.stringify(body),
}, true);
}
/** CRM 拓展移至渠道拓展,返回新渠道拓展 id */
export async function moveCrmToChannel(id: CrmId, payload: MoveCrmToChannelPayload) {
const { channelAttribute, channelAttributeCustom, internalAttribute, ...rest } = payload;
const body = {
...rest,
channelAttribute: encodeExpansionMultiValue(channelAttribute, channelAttributeCustom),
internalAttribute: encodeExpansionMultiValue(internalAttribute),
coverageProvince: encodeExpansionMultiValue(payload.coverageProvince),
coverageCity: encodeExpansionMultiValue(payload.coverageCity),
};
return request<CrmId>(`/api/expansion/crm/${id}/move-to-channel`, {
method: "POST",
body: JSON.stringify(body),
}, true);
}
function readCachedValue<T>(cacheKey: string) {
const memoryValue = memoryRequestCache.get(cacheKey);
if (memoryValue && memoryValue.expiresAt > Date.now()) {

View File

@ -38,6 +38,7 @@ import { useNavigate } from "react-router-dom";
import {
completeDashboardTodo,
getDashboardAnalyticsCardDetail,
getDashboardAnalyticsCardsData,
getDashboardHome,
getWorkReportHistoryItem,
readDashboardMessage,
@ -649,12 +650,10 @@ export default function Dashboard() {
const [analyticsDimensionLoadingKey, setAnalyticsDimensionLoadingKey] = useState<string>("");
const [openDimensionCardKey, setOpenDimensionCardKey] = useState<string | null>(null);
const dimensionMenuRef = useRef<HTMLDivElement | null>(null);
const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<string>(() => {
if (typeof window === "undefined") {
return ANALYTICS_ALL_TAB_KEY;
}
return window.localStorage.getItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY) || ANALYTICS_ALL_TAB_KEY;
});
const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<string>("");
const [analyticsCardData, setAnalyticsCardData] = useState<Record<string, DashboardAnalyticsCard>>({});
const [analyticsDataLoading, setAnalyticsDataLoading] = useState(false);
const [analyticsDataError, setAnalyticsDataError] = useState(false);
useEffect(() => {
let cancelled = false;
@ -727,12 +726,13 @@ export default function Dashboard() {
const activeWorkPanel = showTodoCard ? activeWorkTab : "message";
const analyticsCards = useMemo(
() => (home.analyticsPanel?.cards ?? [])
.filter((item) => !item.errorMessage)
.map((item) => {
const loaded = item.cardKey ? analyticsCardData[item.cardKey] : undefined;
const override = item.cardKey ? analyticsCardOverrides[item.cardKey] : undefined;
return override ? { ...item, ...override } : item;
}),
[analyticsCardOverrides, home.analyticsPanel?.cards],
return { ...item, ...(loaded ?? {}), ...(override ?? {}) };
})
.filter((item) => !item.errorMessage),
[analyticsCardData, analyticsCardOverrides, home.analyticsPanel?.cards],
);
const analyticsSections = useMemo(
() => groupDashboardCards(analyticsCards),
@ -744,32 +744,85 @@ export default function Dashboard() {
);
const visibleAnalyticsSections = useMemo(
() => (
activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY || !analyticsTabs.length
? analyticsSections
: analyticsSections.filter((section) => section.key === activeAnalyticsTab)
!activeAnalyticsTab
? []
: activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY || !analyticsTabs.length
? analyticsSections
: analyticsSections.filter((section) => section.key === activeAnalyticsTab)
),
[activeAnalyticsTab, analyticsSections, analyticsTabs.length],
);
useEffect(() => {
if (!analyticsTabs.length) {
if (activeAnalyticsTab !== ANALYTICS_ALL_TAB_KEY) {
setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY);
}
// 单分组/未分组:无 tab 时强制展示全部卡片,避免经营分析面板空白(回归修复)
setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY);
return;
}
if (!analyticsTabs.some((item) => item.key === activeAnalyticsTab)) {
setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY);
}
}, [activeAnalyticsTab, analyticsTabs]);
setActiveAnalyticsTab((current) => {
if (current && analyticsTabs.some((item) => item.key === current)) {
return current;
}
if (typeof window !== "undefined") {
const saved = window.localStorage.getItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY);
if (saved && analyticsTabs.some((item) => item.key === saved)) {
return saved;
}
}
return analyticsTabs.find((item) => item.key !== ANALYTICS_ALL_TAB_KEY)?.key ?? ANALYTICS_ALL_TAB_KEY;
});
}, [analyticsTabs]);
useEffect(() => {
if (typeof window === "undefined") {
if (typeof window === "undefined" || !activeAnalyticsTab) {
return;
}
window.localStorage.setItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY, activeAnalyticsTab);
}, [activeAnalyticsTab]);
useEffect(() => {
if (!showAnalyticsCard || !visibleAnalyticsSections.length) {
return;
}
const missingKeys = visibleAnalyticsSections
.flatMap((section) => section.cards.map((card) => card.cardKey))
.filter((key): key is string => Boolean(key) && analyticsCardData[key] === undefined);
if (!missingKeys.length) {
return;
}
let cancelled = false;
setAnalyticsDataLoading(true);
setAnalyticsDataError(false);
void getDashboardAnalyticsCardsData(missingKeys)
.then((cards) => {
if (cancelled) {
return;
}
setAnalyticsCardData((current) => {
const next = { ...current };
cards.forEach((card) => {
if (card.cardKey) {
next[card.cardKey] = card;
}
});
return next;
});
})
.catch(() => {
if (!cancelled) {
setAnalyticsDataError(true);
}
})
.finally(() => {
if (!cancelled) {
setAnalyticsDataLoading(false);
}
});
return () => {
cancelled = true;
};
}, [analyticsCardData, showAnalyticsCard, visibleAnalyticsSections]);
useEffect(() => {
if (!openDimensionCardKey) {
return undefined;
@ -1375,7 +1428,23 @@ export default function Dashboard() {
}`}
style={chartCard ? undefined : metricStyles.container}
>
{chartCard ? (
{card.dataLoaded === false ? (
<div className="flex h-full min-h-[96px] w-full flex-col">
<h3 className={`${compactMobileCard ? "text-sm" : "text-base"} truncate font-bold text-slate-800`}>
{card.title || "未命名卡片"}
</h3>
{card.subtitle ? (
<p className={`${compactMobileCard ? "text-[9px]" : "text-[10px]"} mt-1 font-medium text-slate-400`}>
{card.subtitle}
</p>
) : null}
<div
className={`${compactMobileCard ? "mt-4" : "mt-6"} flex-1 animate-pulse rounded-xl bg-slate-100/90 dark:bg-slate-800/90 ${
chartCard ? "min-h-[120px]" : "min-h-[56px]"
}`}
/>
</div>
) : chartCard ? (
<>
<div className={`${compactMobileCard ? "mb-4 gap-2" : "mb-6 gap-4"} flex items-start justify-between`}>
<div className="min-w-0">

File diff suppressed because it is too large Load Diff

View File

@ -96,7 +96,7 @@ export default function LoginPage() {
const [initializing, setInitializing] = useState(true);
const [error, setError] = useState("");
const appName = "紫光汇智CRM系统";
const appName = "Sales-Channel-Customer";
const systemDescription = platformConfig?.systemDescription || "聚焦客户拓展、商机推进与销售协同,让团队每天的工作节奏更清晰。";
const backgroundStyle = useMemo(
@ -354,7 +354,7 @@ export default function LoginPage() {
{error ? <div className="login-error">{error}</div> : null}
<button className="login-submit" type="submit" disabled={loading || initializing}>
{loading ? "登录中..." : "立即登录"}
{loading ? "登录中..." : "登录 SCC"}
</button>
</form>
</div>

View File

@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useLocation } from "react-router-dom";
import {
checkChannelExpansionDuplicate,
checkOpportunityDuplicate,
checkSalesExpansionDuplicate,
canUsePermission,
createChannelExpansion,
@ -30,6 +31,7 @@ import {
type ExpansionDictOption,
type OmsPreSalesOption,
type OpportunityDictOption,
type OpportunityDuplicateItem,
type OpportunityFollowUp,
type OpportunityItem,
type PushOpportunityToOmsPayload,
@ -37,13 +39,13 @@ import {
type WorkReportAttachment,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { SearchableSelect, appendSearchableOptionIfMissing, type SearchableOption } from "@/components/SearchableSelect";
import { AttachmentPreviewModal, formatAttachmentSize } from "@/components/AttachmentPreviewModal";
import {
QuickChannelForm as SharedQuickChannelForm,
QuickSalesForm as SharedQuickSalesForm,
type ChannelField,
type SalesCreateField,
createEmptyChannelContact as createSharedEmptyChannelContact,
defaultQuickChannelForm as sharedDefaultQuickChannelForm,
defaultQuickSalesForm as sharedDefaultQuickSalesForm,
isOtherOption as isSharedOtherOption,
@ -52,6 +54,7 @@ import {
validateChannelForm as validateSharedChannelForm,
validateSalesCreateForm as validateSharedSalesCreateForm,
} from "@/features/crmQuickCreate/shared";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
@ -977,6 +980,7 @@ function OpportunityExportFilterModal({
exportError,
archiveTab,
stageOptions,
allStageOptions,
confidenceOptions,
projectLocationOptions,
opportunityTypeOptions,
@ -989,6 +993,7 @@ function OpportunityExportFilterModal({
exportError: string;
archiveTab: OpportunityArchiveTab;
stageOptions: OpportunityDictOption[];
allStageOptions: OpportunityDictOption[];
confidenceOptions: OpportunityDictOption[];
projectLocationOptions: OpportunityDictOption[];
opportunityTypeOptions: OpportunityDictOption[];
@ -1000,7 +1005,8 @@ function OpportunityExportFilterModal({
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(normalizedInitialFilters);
const selectedFields = resolveSelectedOpportunityFields(draftFilters.selectedFields);
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions, archiveTab);
const exportStageOptions = stageOptions
// 已签单 tab阶段筛选展示全量阶段含已禁用字典项便于勾选导出其余 tab 仅展示启用阶段
const exportStageOptions = (archiveTab === "archived" && allStageOptions.length > 0 ? allStageOptions : stageOptions)
.filter((option) => archiveTab === "lost" || archiveTab === "archived" || !isLostOpportunityStageOption(option))
.map((option) => {
const value = getOpportunityDictOptionValue(option);
@ -1320,20 +1326,6 @@ function OpportunityExportFilterModal({
);
}
type SearchableOption = {
value: CrmId;
label: string;
keywords?: string[];
};
function getSearchableOptionLabel(option: SearchableOption) {
const normalizedLabel = typeof option.label === "string" ? option.label.trim() : "";
if (normalizedLabel) {
return normalizedLabel;
}
return String(option.value ?? "");
}
function formatSalesExpansionOptionLabel(item?: Pick<SalesExpansionItem, "id" | "name" | "phone"> | null) {
if (!item) {
return "";
@ -1343,345 +1335,6 @@ function formatSalesExpansionOptionLabel(item?: Pick<SalesExpansionItem, "id" |
return normalizedPhone ? `${normalizedName}${normalizedPhone}` : normalizedName;
}
function dedupeSearchableOptions(options: SearchableOption[]) {
const seenValues = new Set<SearchableOption["value"]>();
return options.filter((option) => {
if (seenValues.has(option.value)) {
return false;
}
seenValues.add(option.value);
return true;
});
}
function appendSearchableOptionIfMissing(options: SearchableOption[], fallbackOption?: SearchableOption | null) {
if (!fallbackOption) {
return options;
}
return dedupeSearchableOptions([...options, fallbackOption]);
}
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
function SearchableSelect({
value,
options,
placeholder,
searchPlaceholder,
emptyText,
loading = false,
createActionLabel,
className,
onChange,
onCreate,
onQueryChange,
}: {
value?: CrmId;
options: SearchableOption[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
loading?: boolean;
createActionLabel?: string;
className?: string;
onChange: (value?: CrmId) => void;
onCreate?: (query: string) => void;
onQueryChange?: (query: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
const normalizedOptions = dedupeSearchableOptions(options);
const selectedOption = normalizedOptions.find((item) => item.value === value);
const normalizedQuery = query.trim().toLowerCase();
const filteredOptions = normalizedOptions.filter((item) => {
if (!normalizedQuery) {
return true;
}
const haystacks = [getSearchableOptionLabel(item), ...(item.keywords ?? [])]
.filter(Boolean)
.map((entry) => entry.toLowerCase());
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
const resetQuery = () => {
setQuery("");
onQueryChange?.("");
};
useEffect(() => {
if (!open || isMobile) {
setDesktopDropdownStyle(null);
return;
}
const updateDesktopDropdownLayout = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || typeof window === "undefined") {
return;
}
const viewportHeight = window.innerHeight;
const safePadding = 24;
const panelPadding = 96;
const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding);
const availableAbove = Math.max(160, rect.top - safePadding - panelPadding);
const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow;
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow));
setDesktopDropdownStyle({
top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8,
left: rect.left,
width: rect.width,
});
};
updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
setOpen(false);
resetQuery();
}
};
const handleViewportChange = () => {
updateDesktopDropdownLayout();
};
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
const renderSearchBody = () => (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
autoFocus
value={query}
onChange={(event) => {
const nextQuery = event.target.value;
setQuery(nextQuery);
onQueryChange?.(nextQuery);
}}
placeholder={searchPlaceholder}
className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white"
/>
</div>
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
<button
type="button"
onClick={() => {
onChange(undefined);
setOpen(false);
resetQuery();
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
</button>
{filteredOptions.length > 0 ? (
filteredOptions.map((item) => (
<button
type="button"
key={item.value}
onClick={() => {
onChange(item.value);
setOpen(false);
resetQuery();
}}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors ${
item.value === value
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{getSearchableOptionLabel(item)}</span>
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))
) : loading ? (
<div className="crm-empty-state px-3 py-6">
<p>...</p>
</div>
) : (
<div className="crm-empty-state px-3 py-6">
<p>{emptyText}</p>
{onCreate ? (
<button
type="button"
onClick={() => {
onCreate(query);
setOpen(false);
resetQuery();
}}
className="mt-3 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
>
{createActionLabel || "新增并选中"}
</button>
) : null}
</div>
)}
</div>
</>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => {
setOpen((current) => {
const next = !current;
if (!next) {
resetQuery();
}
return next;
});
}}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{selectedOption ? getSearchableOptionLabel(selectedOption) : placeholder}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[220]">
<div
ref={desktopDropdownRef}
style={{
position: "absolute",
top: desktopDropdownPlacement === "top"
? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
: desktopDropdownStyle.top,
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
className="overflow-y-auto overscroll-contain pr-1"
>
{renderSearchBody()}
</div>
</div>
</div>,
document.body,
)
: null}
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => {
setOpen(false);
setQuery("");
}}
/>
<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-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{placeholder}</p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => {
setOpen(false);
setQuery("");
}}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{renderSearchBody()}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}
function CompetitorMultiSelect({
value,
options,
@ -1925,6 +1578,8 @@ export default function Opportunities() {
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
const [duplicateCheckOpen, setDuplicateCheckOpen] = useState(false);
const [duplicateCheckItems, setDuplicateCheckItems] = useState<OpportunityDuplicateItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [pushingOms, setPushingOms] = useState(false);
const [exporting, setExporting] = useState(false);
@ -1937,12 +1592,18 @@ export default function Opportunities() {
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadingMoreRef = useRef(false);
const loadedTabKeysRef = useRef(new Set<string>());
// 缓存按「archiveTab:keyword:filter:stageOptions长度」完整 key 独立存一份,
// 避免共享单个 per-tab 槽位被其它筛选覆盖后,回显到错误 key 的陈旧数据H1
const overviewCacheRef = useRef<Record<string, OpportunityItem[]>>({});
const prevArchiveTabRef = useRef<OpportunityArchiveTab>(archiveTab);
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
const [selectedSalesExpansionOption, setSelectedSalesExpansionOption] = useState<SearchableOption | null>(null);
const [selectedChannelExpansionOption, setSelectedChannelExpansionOption] = useState<SearchableOption | null>(null);
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
const [allStageOptions, setAllStageOptions] = useState<OpportunityDictOption[]>([]);
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
const [projectOwnershipLocationOptions, setProjectOwnershipLocationOptions] = useState<OpportunityDictOption[]>([]);
@ -1973,6 +1634,7 @@ export default function Opportunities() {
const [quickCertificationLevelOptions, setQuickCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
const [quickChannelAttributeOptions, setQuickChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [quickIsOptions, setQuickIsOptions] = useState<ExpansionDictOption[]>([]);
const [quickSalesDuplicateMessage, setQuickSalesDuplicateMessage] = useState("");
const [quickChannelDuplicateMessage, setQuickChannelDuplicateMessage] = useState("");
const [salesExpansionQuery, setSalesExpansionQuery] = useState("");
@ -1980,7 +1642,7 @@ export default function Opportunities() {
const [loadingSalesExpansionOptions, setLoadingSalesExpansionOptions] = useState(false);
const [loadingChannelExpansionOptions, setLoadingChannelExpansionOptions] = useState(false);
const hasLoadedOpportunityExpansionOptionsRef = useRef(false);
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen || quickCreateOpen;
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || duplicateCheckOpen || exportFilterOpen || quickCreateOpen;
const canCreateOpportunity = permissionCodes !== null && canUsePermission(OPPORTUNITY_CREATE_PERMISSION, permissionCodes);
const fetchOpportunityExpansionOptions = async (query?: string) => getOpportunityExpansionOptions({
@ -2017,8 +1679,17 @@ export default function Opportunities() {
useEffect(() => {
let cancelled = false;
const loadKey = `${archiveTab}:${keyword}:${filter}:${stageOptions.length}`;
const cachedItems = overviewCacheRef.current[loadKey];
async function load() {
if (loadedTabKeysRef.current.has(loadKey) && cachedItems) {
if (!cancelled) {
setItems(cachedItems);
setSelectedItem(null);
}
return;
}
try {
const lostStageCodes = getLostStageCodes(stageOptions);
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
@ -2027,7 +1698,10 @@ export default function Opportunities() {
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const data = await getOpportunityOverview(keyword, effectiveStage, false, LIST_PAGE_SIZE + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
if (!cancelled) {
setItems(data.items ?? []);
const loadedItems = data.items ?? [];
setItems(loadedItems);
overviewCacheRef.current[loadKey] = loadedItems;
loadedTabKeysRef.current.add(loadKey);
setSelectedItem(null);
}
} catch {
@ -2047,7 +1721,11 @@ export default function Opportunities() {
useEffect(() => {
setVisibleItemCount(LIST_PAGE_SIZE);
const lostCodes = getLostStageCodes(stageOptions);
if (archiveTab === "active" && lostCodes.includes(filter)) {
if (archiveTab !== prevArchiveTabRef.current) {
// 切换 tab 时总是重置阶段筛选,避免残留筛选值导致数据错误
prevArchiveTabRef.current = archiveTab;
setFilter("全部");
} else if (archiveTab === "active" && lostCodes.includes(filter)) {
setFilter("全部");
}
}, [keyword, filter, archiveTab, stageOptions]);
@ -2216,6 +1894,7 @@ export default function Opportunities() {
const data = await getOpportunityMeta();
if (!cancelled) {
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
setAllStageOptions((data.allStageOptions ?? []).filter((item) => item.value));
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
setProjectOwnershipLocationOptions((data.projectOwnershipLocationOptions ?? []).filter((item) => item.value));
@ -2226,6 +1905,7 @@ export default function Opportunities() {
} catch {
if (!cancelled) {
setStageOptions([]);
setAllStageOptions([]);
setOperatorOptions([]);
setProjectLocationOptions([]);
setProjectOwnershipLocationOptions([]);
@ -2312,6 +1992,7 @@ export default function Opportunities() {
setQuickCertificationLevelOptions(data.certificationLevelOptions ?? []);
setQuickChannelAttributeOptions(data.channelAttributeOptions ?? []);
setQuickInternalAttributeOptions(data.internalAttributeOptions ?? []);
setQuickIsOptions(data.isOptions ?? []);
setQuickChannelForm((current) => ({
...current,
channelCode: current.channelCode || data.nextChannelCode || "",
@ -2578,13 +2259,36 @@ export default function Opportunities() {
const exportStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
const exportArchived = archiveTab === "archived";
const exportExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
const overview = await getOpportunityOverview("", undefined, false, null, exportArchived, exportExcludeStageCodes, exportStageCodes);
const overview = await getOpportunityOverview("", undefined, true, null, exportArchived, exportExcludeStageCodes, exportStageCodes);
// 已签单 tab默认阶段筛选全选启用阶段将已签单商机实际使用的阶段码并入集合
// 避免因阶段字典被禁用而导致列表可见的商机在导出时被遗漏
let effectiveFilters = normalizedFilters;
if (archiveTab === "archived" && normalizedFilters.stageCodes !== undefined) {
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions, archiveTab);
if (areSameOpportunityStringSets(normalizedFilters.stageCodes, defaultStageCodes)) {
const archivedStageCodeSet = new Set<string>();
(overview.items ?? []).forEach((item) => {
if (item.archived) {
const stageCode = item.stageCode?.trim();
if (stageCode) {
archivedStageCodeSet.add(stageCode);
}
}
});
if (archivedStageCodeSet.size > 0) {
effectiveFilters = {
...normalizedFilters,
stageCodes: [...new Set([...normalizeOpportunityMultiSelectValues(normalizedFilters.stageCodes), ...archivedStageCodeSet])],
};
}
}
}
const exportItems = (overview.items ?? [])
.filter((item) => {
if (archiveTab === "lost") return !item.archived && isLostOpportunityStageOption(item);
return archiveTab === "active" ? !item.archived && !isLostOpportunityStageOption(item) : Boolean(item.archived);
})
.filter((item) => matchesOpportunityExportFilters(item, normalizedFilters, effectiveConfidenceOptions));
.filter((item) => matchesOpportunityExportFilters(item, effectiveFilters, effectiveConfidenceOptions));
const selectedFieldKeys = resolveSelectedOpportunityFields(normalizedFilters.selectedFields);
const exportTabLabel = archiveTab === "active" ? "未签单" : archiveTab === "lost" ? "已丢单" : "已签单";
if (exportItems.length <= 0) {
@ -2742,7 +2446,7 @@ export default function Opportunities() {
setQuickChannelForm((current) => {
const nextContacts = [...(current.contacts ?? [])];
nextContacts[index] = {
...(nextContacts[index] ?? createSharedEmptyChannelContact()),
...(nextContacts[index] ?? {}),
[key]: value,
};
return {
@ -2762,25 +2466,6 @@ export default function Opportunities() {
}
};
const addQuickChannelContact = () => {
setQuickChannelForm((current) => ({
...current,
contacts: [...(current.contacts ?? []), createSharedEmptyChannelContact()],
}));
};
const removeQuickChannelContact = (index: number) => {
setQuickChannelForm((current) => {
const currentContacts = current.contacts ?? [];
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createSharedEmptyChannelContact()],
};
});
setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index).map((rowIndex) => (rowIndex > index ? rowIndex - 1 : rowIndex)));
};
const handleQuickChannelProvinceChange = (value: string) => {
const nextProvince = value || "";
handleQuickChannelChange("province", nextProvince || undefined);
@ -2912,6 +2597,23 @@ export default function Opportunities() {
}
};
const doCreateOpportunity = async () => {
setSubmitting(true);
try {
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
await reload();
resetCreateState();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "新增商机失败");
setSubmitting(false);
}
};
const handleConfirmCreateWithDuplicate = async () => {
setDuplicateCheckOpen(false);
await doCreateOpportunity();
};
const handleCreateSubmit = async () => {
if (submitting) {
return;
@ -2925,16 +2627,20 @@ export default function Opportunities() {
return;
}
setSubmitting(true);
try {
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
await reload();
resetCreateState();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "新增商机失败");
setSubmitting(false);
if (!editOpen) {
try {
const duplicateResult = await checkOpportunityDuplicate(form.opportunityName.trim(), getLostStageCodes(stageOptions));
if (duplicateResult.duplicate && (duplicateResult.items ?? []).length > 0) {
setDuplicateCheckItems(duplicateResult.items ?? []);
setDuplicateCheckOpen(true);
return;
}
} catch (duplicateError) {
// 查重失败不阻断新增
}
}
await doCreateOpportunity();
};
const handleOpenEdit = () => {
@ -3323,6 +3029,7 @@ export default function Opportunities() {
exportError={exportError}
archiveTab={archiveTab}
stageOptions={stageOptions}
allStageOptions={allStageOptions}
confidenceOptions={effectiveConfidenceOptions}
projectLocationOptions={projectLocationOptions}
opportunityTypeOptions={opportunityTypeOptions}
@ -3675,13 +3382,12 @@ export default function Opportunities() {
certificationLevelOptions={quickCertificationLevelOptions}
channelAttributeOptions={quickChannelAttributeOptions}
internalAttributeOptions={quickInternalAttributeOptions}
wecomOptions={quickIsOptions}
channelOtherOptionValue={quickChannelOtherOptionValue}
duplicateMessage={quickChannelDuplicateMessage}
requiredMark={<RequiredMark />}
onChange={handleQuickChannelChange}
onContactChange={handleQuickChannelContactChange}
onAddContact={addQuickChannelContact}
onRemoveContact={removeQuickChannelContact}
onProvinceChange={handleQuickChannelProvinceChange}
/>
)}
@ -3770,6 +3476,72 @@ export default function Opportunities() {
) : null}
</AnimatePresence>
<AnimatePresence>
{duplicateCheckOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDuplicateCheckOpen(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="crm-tone-warning mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl">
<AlertTriangle className="crm-icon-lg" />
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"></h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
{form.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">
{duplicateCheckItems.map((item) => (
<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="mt-1 text-xs text-slate-400 dark:text-slate-500">
{[item.opportunityCode, item.customerName, item.stage].filter(Boolean).join(" · ") || `#${item.opportunityId}`}
</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">
<button
type="button"
onClick={() => setDuplicateCheckOpen(false)}
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>
</motion.div>
</>
)}
</AnimatePresence>
<AnimatePresence>
{detailItem && (
<>

View File

@ -12,6 +12,7 @@ import {
createOpportunity,
createSalesExpansion,
getCurrentUser,
getCrmExpansionOverview,
getExpansionCityOptions,
getExpansionMeta,
getExpansionOverview,
@ -35,6 +36,7 @@ import {
type ChannelExpansionItem,
type ChannelExpansionContact,
type CreateChannelExpansionPayload,
type CrmExpansionItem,
type CrmId,
type CreateOpportunityPayload,
type CreateWorkCheckInPayload,
@ -62,7 +64,6 @@ import {
QuickSalesForm as SharedQuickSalesForm,
type ChannelField,
type SalesCreateField,
createEmptyChannelContact as createSharedEmptyChannelContact,
defaultQuickChannelForm as sharedDefaultQuickChannelForm,
defaultQuickSalesForm as sharedDefaultQuickSalesForm,
isOtherOption as isSharedOtherOption,
@ -86,10 +87,14 @@ const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
const REPORT_SPEECH_MIN_RECORDING_MS = 1000;
/** CRM 拓展日报行采用纯文本(无 + 字段行),正文以该伪字段承载 */
const CRM_EDITOR_BODY_KEY = "__crmBody";
const reportFieldLabels = {
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
channel: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
opportunity: ["项目最新进展", OPPORTUNITY_NEXT_PLAN_LABEL],
crm: [],
} as const;
const COMPETITOR_OPTIONS = [
"深信服",
@ -698,6 +703,9 @@ function formatWorkBizType(value?: string) {
if (value === "channel") {
return "渠道拓展";
}
if (value === "crm") {
return "CRM拓展";
}
if (value === "opportunity") {
return "商机";
}
@ -881,6 +889,7 @@ export default function Work() {
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
const [channelOptions, setChannelOptions] = useState<WorkRelationOption[]>([]);
const [crmOptions, setCrmOptions] = useState<WorkRelationOption[]>([]);
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
const [opportunityItems, setOpportunityItems] = useState<OpportunityItem[]>([]);
const [reportOpportunityStageOptions, setReportOpportunityStageOptions] = useState<OpportunityDictOption[]>([]);
@ -906,6 +915,7 @@ export default function Work() {
const [quickCertificationLevelOptions, setQuickCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
const [quickChannelAttributeOptions, setQuickChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [quickIsOptions, setQuickIsOptions] = useState<ExpansionDictOption[]>([]);
const [quickOpportunityOperatorOptions, setQuickOpportunityOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [quickOpportunityProjectLocationOptions, setQuickOpportunityProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
const [quickOpportunityProjectOwnershipLocationOptions, setQuickOpportunityProjectOwnershipLocationOptions] = useState<OpportunityDictOption[]>([]);
@ -945,13 +955,13 @@ export default function Work() {
if (!objectPicker) {
return [];
}
const options = getOptionsByBizType(objectPicker.bizType, salesOptions, channelOptions, opportunityOptions);
const options = getOptionsByBizType(objectPicker.bizType, salesOptions, channelOptions, crmOptions, opportunityOptions);
const keyword = normalizeObjectPickerQuery(objectPicker.query).toLowerCase();
if (!keyword) {
return options;
}
return options.filter((option) => option.label.toLowerCase().includes(keyword));
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
}, [objectPicker, salesOptions, channelOptions, crmOptions, opportunityOptions]);
const userPickerOptions = useMemo(() => {
if (!userPicker) {
return [];
@ -1029,8 +1039,9 @@ export default function Work() {
return;
}
setReportTargetsLoading(true);
const [expansionResult, opportunityResult, opportunityMetaResult] = await Promise.allSettled([
const [expansionResult, crmExpansionResult, opportunityResult, opportunityMetaResult] = await Promise.allSettled([
getExpansionOverview(""),
getCrmExpansionOverview(""),
getOpportunityOverview(),
getOpportunityMeta(),
]);
@ -1040,6 +1051,10 @@ export default function Work() {
setChannelOptions(buildChannelOptions(expansionResult.value.channelItems ?? []));
}
if (crmExpansionResult.status === "fulfilled") {
setCrmOptions(buildCrmOptions(crmExpansionResult.value.crmItems ?? []));
}
if (opportunityResult.status === "fulfilled") {
const items = opportunityResult.value.items ?? [];
setOpportunityOptions(buildOpportunityOptions(items));
@ -1056,13 +1071,15 @@ export default function Work() {
}, [reportTargetsLoaded, reportTargetsLoading]);
const refreshReportTargets = useCallback(async () => {
const [expansionData, opportunityData, opportunityMeta] = await Promise.all([
const [expansionData, crmExpansionData, opportunityData, opportunityMeta] = await Promise.all([
getExpansionOverview(""),
getCrmExpansionOverview(""),
getOpportunityOverview(),
getOpportunityMeta(),
]);
setSalesOptions(buildSalesOptions(expansionData.salesItems ?? []));
setChannelOptions(buildChannelOptions(expansionData.channelItems ?? []));
setCrmOptions(buildCrmOptions(crmExpansionData.crmItems ?? []));
setOpportunityOptions(buildOpportunityOptions(opportunityData.items ?? []));
setOpportunityItems(opportunityData.items ?? []);
setReportOpportunityStageOptions((opportunityMeta.stageOptions ?? []).filter((item) => item.value));
@ -1070,6 +1087,7 @@ export default function Work() {
return {
salesItems: expansionData.salesItems ?? [],
channelItems: expansionData.channelItems ?? [],
crmItems: crmExpansionData.crmItems ?? [],
opportunityItems: opportunityData.items ?? [],
};
}, []);
@ -1548,11 +1566,11 @@ export default function Work() {
setCheckInPhotoUrls([]);
};
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => {
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "opportunity") => {
if (isOnlySeeRole) {
return;
}
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions);
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, crmOptions, opportunityOptions);
if (!currentOptions.length && !reportTargetsLoading) {
void loadReportTargets();
}
@ -1588,6 +1606,7 @@ export default function Work() {
setQuickCertificationLevelOptions(expansionMeta.certificationLevelOptions ?? []);
setQuickChannelAttributeOptions(expansionMeta.channelAttributeOptions ?? []);
setQuickInternalAttributeOptions(expansionMeta.internalAttributeOptions ?? []);
setQuickIsOptions(expansionMeta.isOptions ?? []);
setQuickOpportunityOperatorOptions((opportunityMeta.operatorOptions ?? []).filter((item) => item.value));
setQuickOpportunityProjectLocationOptions((opportunityMeta.projectLocationOptions ?? []).filter((item) => item.value));
setQuickOpportunityProjectOwnershipLocationOptions((opportunityMeta.projectOwnershipLocationOptions ?? []).filter((item) => item.value));
@ -1692,7 +1711,7 @@ export default function Work() {
setQuickChannelForm((current) => {
const nextContacts = [...(current.contacts ?? [])];
nextContacts[index] = {
...(nextContacts[index] ?? createSharedEmptyChannelContact()),
...(nextContacts[index] ?? {}),
[key]: value,
};
return {
@ -1702,24 +1721,6 @@ export default function Work() {
});
};
const addQuickChannelContact = () => {
setQuickChannelForm((current) => ({
...current,
contacts: [...(current.contacts ?? []), createSharedEmptyChannelContact()],
}));
};
const removeQuickChannelContact = (index: number) => {
setQuickChannelForm((current) => {
const nextContacts = (current.contacts ?? []).filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createSharedEmptyChannelContact()],
};
});
setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index).map((rowIndex) => (rowIndex > index ? rowIndex - 1 : rowIndex)));
};
const handleQuickChannelProvinceChange = (value: string) => {
const nextProvince = value || "";
handleQuickChannelChange("province", nextProvince || undefined);
@ -1922,7 +1923,7 @@ export default function Work() {
return;
}
event.preventDefault();
handleOpenObjectPicker("report", index, line?.bizType || "sales");
handleOpenObjectPicker("report", index, "opportunity");
};
const handleReportLineChange = (index: number, value: string) => {
@ -2268,7 +2269,7 @@ export default function Work() {
loading={loading}
checkInForm={checkInForm}
refreshingLocation={refreshingLocation}
onOpenObjectPicker={() => handleOpenObjectPicker("checkin", undefined, checkInForm.bizType || "sales")}
onOpenObjectPicker={() => handleOpenObjectPicker("checkin", undefined, checkInForm.bizName ? checkInForm.bizType : "opportunity")}
onRefreshLocation={() => void handleRefreshLocation()}
onOpenLocationAdjust={handleOpenLocationAdjust}
locationAccuracyMeters={locationAccuracyMeters}
@ -2576,7 +2577,7 @@ export default function Work() {
</div>
<div className="mb-3 flex flex-wrap gap-2">
{(["sales", "channel", "opportunity"] as BizType[]).map((bizType) => (
{(["opportunity", "sales", "channel", "crm"] as BizType[]).map((bizType) => (
<button
key={bizType}
type="button"
@ -2706,13 +2707,12 @@ export default function Work() {
certificationLevelOptions={quickCertificationLevelOptions}
channelAttributeOptions={quickChannelAttributeOptions}
internalAttributeOptions={quickInternalAttributeOptions}
wecomOptions={quickIsOptions}
channelOtherOptionValue={quickChannelAttributeOptions.find(isSharedOtherOption)?.value}
duplicateMessage={quickChannelDuplicateMessage}
requiredMark={<RequiredMark />}
onChange={handleQuickChannelChange}
onContactChange={handleQuickChannelContactChange}
onAddContact={addQuickChannelContact}
onRemoveContact={removeQuickChannelContact}
onProvinceChange={handleQuickChannelProvinceChange}
/>
) : (
@ -4916,7 +4916,7 @@ function ReportPanel({
) : null}
<button
type="button"
onClick={() => onOpenObjectPicker(index, item.bizType || "sales")}
onClick={() => onOpenObjectPicker(index, item.bizName ? item.bizType : "opportunity")}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-violet-200 bg-white text-violet-600 shadow-sm transition-colors hover:bg-violet-50 hover:text-violet-700 dark:border-violet-500/30 dark:bg-slate-900/60 dark:text-violet-300 dark:hover:bg-violet-500/10"
title="选择关联对象"
aria-label="选择关联对象"
@ -5736,6 +5736,20 @@ function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[
.map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
}
function buildCrmOptions(items: CrmExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<CrmId>();
return items
.filter((item): item is CrmExpansionItem & { id: CrmId } => isValidCrmId(item.id))
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
}
seenIds.add(item.id);
return true;
})
.map((item) => ({ id: item.id, label: item.endUser || `CRM拓展#${item.id}` }));
}
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
const seenIds = new Set<CrmId>();
return items
@ -5764,6 +5778,9 @@ function getBizTypeLabel(bizType: BizType) {
if (bizType === "channel") {
return "渠道拓展";
}
if (bizType === "crm") {
return "CRM拓展";
}
return "商机";
}
@ -5771,6 +5788,7 @@ function getOptionsByBizType(
bizType: BizType,
salesOptions: WorkRelationOption[],
channelOptions: WorkRelationOption[],
crmOptions: WorkRelationOption[],
opportunityOptions: WorkRelationOption[],
) {
if (bizType === "sales") {
@ -5779,6 +5797,9 @@ function getOptionsByBizType(
if (bizType === "channel") {
return channelOptions;
}
if (bizType === "crm") {
return crmOptions;
}
return opportunityOptions;
}
@ -5885,9 +5906,17 @@ function appendReportToLine(value?: string, toUsers?: WorkReportToUser[], trim =
function buildEditorTemplate(bizType: BizType, bizName: string, fieldValues?: Record<string, string>, toUsers?: WorkReportToUser[]) {
const lines = [buildEditorMentionLine(bizType, bizName)];
for (const field of getTemplateFields(bizType)) {
const value = fieldValues?.[field] || "";
lines.push(`+ ${field}${value}`);
if (bizType === "crm") {
// CRM 拓展为纯文本mention 行 + 自由正文(无 + 字段行)
const body = fieldValues?.[CRM_EDITOR_BODY_KEY]?.trim() || "";
if (body) {
lines.push(body);
}
} else {
for (const field of getTemplateFields(bizType)) {
const value = fieldValues?.[field] || "";
lines.push(`+ ${field}${value}`);
}
}
const toLine = buildReportToLine(toUsers);
if (toLine) {
@ -5913,6 +5942,17 @@ function parseTemplateValues(bizType: BizType, editorText: string) {
let currentField: string | null = null;
for (const rawLine of editorText.replace(/\r/g, "").split("\n")) {
if (bizType === "crm") {
// CRM 拓展为纯文本:正文为剔除 #/@/To 行后的所有行
const line = rawLine.trim();
if (!line || line.startsWith("#") || line.startsWith("@") || isReportToLine(line)) {
continue;
}
values[CRM_EDITOR_BODY_KEY] = values[CRM_EDITOR_BODY_KEY]
? `${values[CRM_EDITOR_BODY_KEY]}\n${line}`
: line;
continue;
}
const parsedFieldLine = parseTemplateFieldLine(rawLine, bizType, fieldSet);
if (parsedFieldLine) {
currentField = parsedFieldLine.field;
@ -5987,6 +6027,8 @@ function normalizeLoadedLineItem(item: WorkReportLineItem, opportunityStageOptio
if (normalized.bizType === "sales" || normalized.bizType === "channel") {
fieldValues["沟通内容"] = normalized.evaluationContent || extractContentByLabel(normalized.content, "沟通内容") || "";
fieldValues[LEGACY_NEXT_PLAN_LABEL] = normalized.nextPlan || extractContentByLabel(normalized.content, LEGACY_NEXT_PLAN_LABEL) || "";
} else if (normalized.bizType === "crm") {
fieldValues[CRM_EDITOR_BODY_KEY] = normalized.content || "";
} else {
fieldValues["项目最新进展"] = normalized.latestProgress || extractContentByLabel(normalized.content, "项目最新进展") || "";
const resolvedStageValue = normalized.stage || extractContentByLabel(normalized.content, OPPORTUNITY_STAGE_LABEL) || "";
@ -6082,6 +6124,12 @@ function validateReportLineItems(
}
continue;
}
if (item.bizType === "crm") {
if (!values[CRM_EDITOR_BODY_KEY]?.trim()) {
throw new Error(`${getBizTypeLabel(item.bizType)}${item.bizName}”请填写沟通内容`);
}
continue;
}
if (!values["沟通内容"]?.trim()) {
throw new Error(`${getBizTypeLabel(item.bizType)}${item.bizName}”请填写沟通内容`);
}
@ -6094,6 +6142,9 @@ function buildLinePreview(
opportunityStage?: string,
opportunityStageOptions: OpportunityDictOption[] = [],
) {
if (bizType === "crm") {
return values[CRM_EDITOR_BODY_KEY] || "";
}
if (bizType === "opportunity") {
const stageLabel = resolveOpportunityStageLabel(opportunityStage, opportunityStageOptions);
return [

View File

@ -85,9 +85,9 @@
.login-brand-lockup h1 {
margin: 0;
font-size: clamp(1.7rem, 2.4vw, 2.45rem);
font-size: clamp(1.05rem, 1.55vw, 1.4rem);
font-weight: 700;
line-height: 1.08;
line-height: 1.15;
white-space: nowrap;
color: var(--login-ink);
}
@ -421,7 +421,7 @@
}
.login-mobile-brand .login-brand-lockup h1 {
font-size: clamp(1.02rem, 5.8vw, 1.24rem);
font-size: clamp(0.8rem, 4.2vw, 1.02rem);
white-space: nowrap;
line-height: 1.1;
}

View File

@ -0,0 +1 @@
{"root":["./vite.config.ts","./dist/assets/adaptiveselect-bjmcddar.js","./dist/assets/attachmentpreviewmodal-csddkihl.js","./dist/assets/dashboard-74d_r61h.js","./dist/assets/expansion-b6ebc5he.js","./dist/assets/login-crrgkt-r.js","./dist/assets/opportunities-c_sts7jf.js","./dist/assets/ownertransfer--zptbtpm.js","./dist/assets/profile--rwtkmgh.js","./dist/assets/searchableselect-cy_rpnla.js","./dist/assets/wecomlogincallback-dyatcinw.js","./dist/assets/work-c86_bi5i.js","./dist/assets/exceljs.min-bv78e5lr.js","./dist/assets/index-cr80mrgc.js","./dist/assets/shared-dzkylluc.js","./dist/assets/vendor-echarts-wwjiuxbd.js","./dist/assets/vendor-libs-dxnwkbih.js","./dist/assets/vendor-react-b9zqwvum.js","./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/actiondialog.tsx","./src/components/adaptiveselect.tsx","./src/components/attachmentpreviewmodal.tsx","./src/components/layout.tsx","./src/components/protectedimage.tsx","./src/components/searchableselect.tsx","./src/components/themeprovider.tsx","./src/components/dashboard/dashboardanalyticschart.tsx","./src/components/dashboard/dashboardanalyticsechart.tsx","./src/features/crmquickcreate/shared.tsx","./src/hooks/useismobileviewport.ts","./src/hooks/useiswecombrowser.ts","./src/lib/auth.ts","./src/lib/tencentmap.ts","./src/lib/tencentmapgl.ts","./src/lib/utils.ts","./src/lib/wecom.ts","./src/pages/dashboard.tsx","./src/pages/expansion.tsx","./src/pages/login.tsx","./src/pages/opportunities.tsx","./src/pages/ownertransfer.tsx","./src/pages/profile.tsx","./src/pages/wecomlogincallback.tsx","./src/pages/work.tsx"],"version":"5.8.3"}

View File

@ -26,6 +26,17 @@ export default defineConfig(({mode}) => {
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-echarts': ['echarts'],
'vendor-libs': ['date-fns', 'lucide-react', 'motion', 'clsx', 'tailwind-merge'],
},
},
},
},
server: {
https,
allowedHosts: ['crmdev.oa.unissense.tech'],

View File

@ -5,7 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-CdI-T26o.js"></script>
<script type="module" crossorigin src="/assets/index-CVknQL8z.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-IjKGztbF.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-utils-C_FwqJtS.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-B7BNnrn2.js">
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -51,7 +51,7 @@ import {
ZoomInOutlined_default,
ZoomOutOutlined_default,
require_react_is
} from "./chunk-JVSQUNE5.js";
} from "./chunk-NDVMXJDK.js";
import {
require_react
} from "./chunk-RLJ2RCJQ.js";

View File

@ -2,121 +2,169 @@
"hash": "354f676b",
"configHash": "dbaa87de",
"lockfileHash": "056b0532",
"browserHash": "4d741381",
"browserHash": "5d69e024",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "bd6731e7",
"fileHash": "67d36d20",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "089bfd09",
"fileHash": "167df607",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "ef1fa8d6",
"fileHash": "9a5e1750",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "229eff9e",
"fileHash": "c19c3273",
"needsInterop": true
},
"@ant-design/icons": {
"src": "../../@ant-design/icons/es/index.js",
"file": "@ant-design_icons.js",
"fileHash": "79cdebf0",
"fileHash": "83c8a4ed",
"needsInterop": false
},
"antd": {
"src": "../../antd/es/index.js",
"file": "antd.js",
"fileHash": "5ad9a71d",
"fileHash": "b2422d43",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "e9c89b86",
"fileHash": "fd88514c",
"needsInterop": false
},
"dayjs": {
"src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "f57cff8b",
"fileHash": "af8b8f14",
"needsInterop": true
},
"i18next": {
"src": "../../i18next/dist/esm/i18next.js",
"file": "i18next.js",
"fileHash": "97b64fe9",
"fileHash": "6112cc54",
"needsInterop": false
},
"i18next-browser-languagedetector": {
"src": "../../i18next-browser-languagedetector/dist/esm/i18nextBrowserLanguageDetector.js",
"file": "i18next-browser-languagedetector.js",
"fileHash": "73c25f20",
"fileHash": "fb199fff",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "a00ea4e6",
"fileHash": "bd59847c",
"needsInterop": true
},
"react-i18next": {
"src": "../../react-i18next/dist/es/index.js",
"file": "react-i18next.js",
"fileHash": "b235953c",
"fileHash": "fccfdb1a",
"needsInterop": false
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "dde6a3e5",
"fileHash": "07d8cf1e",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "3b7b5f29",
"fileHash": "4c406036",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "f6d59b6c",
"fileHash": "46940452",
"needsInterop": false
},
"echarts": {
"src": "../../echarts/index.js",
"file": "echarts.js",
"fileHash": "bdf6a248",
"fileHash": "78bdf482",
"needsInterop": false
},
"echarts/core": {
"src": "../../echarts/core.js",
"file": "echarts_core.js",
"fileHash": "7ad69d16",
"needsInterop": false
},
"echarts/charts": {
"src": "../../echarts/charts.js",
"file": "echarts_charts.js",
"fileHash": "eaf288e7",
"needsInterop": false
},
"echarts/components": {
"src": "../../echarts/components.js",
"file": "echarts_components.js",
"fileHash": "7891b33e",
"needsInterop": false
},
"echarts/renderers": {
"src": "../../echarts/renderers.js",
"file": "echarts_renderers.js",
"fileHash": "8053e2d1",
"needsInterop": false
}
},
"chunks": {
"chunk-NDVMXJDK": {
"file": "chunk-NDVMXJDK.js"
},
"chunk-CM2AK5IQ": {
"file": "chunk-CM2AK5IQ.js"
},
"chunk-GL7YRBYQ": {
"file": "chunk-GL7YRBYQ.js"
},
"chunk-IDVUNHDH": {
"file": "chunk-IDVUNHDH.js"
},
"chunk-KI33SUI6": {
"file": "chunk-KI33SUI6.js"
},
"chunk-2KMPSB2E": {
"file": "chunk-2KMPSB2E.js"
},
"chunk-GXV2IAFY": {
"file": "chunk-GXV2IAFY.js"
},
"chunk-GNLF6T5P": {
"file": "chunk-GNLF6T5P.js"
},
"chunk-JUYRXMEZ": {
"file": "chunk-JUYRXMEZ.js"
},
"chunk-Z47ZLGR7": {
"file": "chunk-Z47ZLGR7.js"
},
"chunk-RRS4LCUC": {
"file": "chunk-RRS4LCUC.js"
},
"chunk-WVADJ63N": {
"file": "chunk-WVADJ63N.js"
},
"chunk-NUMECXU6": {
"file": "chunk-NUMECXU6.js"
},
"chunk-JVSQUNE5": {
"file": "chunk-JVSQUNE5.js"
},
"chunk-CM2AK5IQ": {
"file": "chunk-CM2AK5IQ.js"
},
"chunk-RLJ2RCJQ": {
"file": "chunk-RLJ2RCJQ.js"
},

View File

@ -1,7 +1,4 @@
"use client";
import {
require_react_dom
} from "./chunk-NUMECXU6.js";
import {
BarsOutlined_default,
CalendarOutlined_default,
@ -55,10 +52,13 @@ import {
ZoomInOutlined_default,
ZoomOutOutlined_default,
require_react_is
} from "./chunk-JVSQUNE5.js";
} from "./chunk-NDVMXJDK.js";
import {
require_dayjs_min
} from "./chunk-CM2AK5IQ.js";
import {
require_react_dom
} from "./chunk-NUMECXU6.js";
import {
require_react
} from "./chunk-RLJ2RCJQ.js";

View File

@ -1,5 +1,13 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import * as echarts from "echarts";
import * as echarts from "echarts/core";
import {
BarChart as EChartsBarChart,
FunnelChart as EChartsFunnelChart,
LineChart as EChartsLineChart,
PieChart as EChartsPieChart,
} from "echarts/charts";
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsOption } from "echarts";
import {
Activity,
@ -33,6 +41,18 @@ import {
} from "lucide-react";
import type { DashboardAnalyticsPreviewCard } from "@/features/dashboard-analytics/types";
echarts.use([
EChartsLineChart,
EChartsBarChart,
EChartsPieChart,
EChartsFunnelChart,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
CanvasRenderer,
]);
type ChartPoint = NonNullable<DashboardAnalyticsPreviewCard["chartData"]>[number];
type RenderType = NonNullable<DashboardAnalyticsPreviewCard["renderType"]>;
type ValueType = DashboardAnalyticsPreviewCard["valueType"];

View File

@ -10,7 +10,17 @@ export default defineConfig({
}
},
build: {
chunkSizeWarningLimit: 700
chunkSizeWarningLimit: 700,
rollupOptions: {
output: {
manualChunks: {
"vendor-react": ["react", "react-dom", "react-router-dom"],
"vendor-antd": ["antd", "@ant-design/icons"],
"vendor-echarts": ["echarts"],
"vendor-utils": ["axios", "zustand", "i18next", "react-i18next", "i18next-browser-languagedetector"],
}
}
}
},
server: {
port: 5173,

283
sql/20260827.sql 100644
View File

@ -0,0 +1,283 @@
-- =====================================================================
-- 2026-08-27 CRM 升级 - PostgreSQL 17
-- =====================================================================
-- 背景:
-- 1) 旧版库archive/init_pg17.sql + 各增量升级脚本)中以下三个表的
-- biz_type CHECK 约束缺少 'crm' 值,而 CRM 拓展功能会写入
-- biz_type = 'crm'(日报反写 CRM 拜访记录、CRM 拓展新增拜访记录、
-- 外勤打卡关联 CRM 拓展、日报消息关联 CRM 拓展),会被旧约束拒绝。
-- 2) CRM 拓展主表 crm_crm_expansion 与多联系人子表
-- crm_crm_expansion_contact 的建表脚本在项目 SQL 中缺失,本次补齐,
-- 结构以当前库实际定义为准(含主键、外键、索引,无触发器/注释,
-- 子表无外键约束,与现有库保持一致)。
-- 3) 新增 crm_oms_dict_mappingCRM 与 OMS 字典码值映射表,用于商机
-- 推送 OMS / OMS 导入回传时的字典码值转换。
-- 说明init_full_pg17.sql全新初始化已包含正确约束本脚本用于
-- 旧库增量升级。对新库执行同样安全幂等create if not exists、
-- drop constraint if exists
begin;
set search_path to public;
-- 1) crm_expansion_followup跟进/拜访记录表,需支持 sales/channel/crm
alter table crm_expansion_followup
drop constraint if exists crm_expansion_followup_biz_type_check;
alter table crm_expansion_followup
add constraint crm_expansion_followup_biz_type_check
check (biz_type in ('sales', 'channel', 'crm'));
-- 2) work_checkin外勤打卡可关联 sales/channel/opportunity/crm
alter table work_checkin
drop constraint if exists work_checkin_biz_type_check;
alter table work_checkin
add constraint work_checkin_biz_type_check
check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity', 'crm'));
-- 3) work_report_message日报消息可关联 sales/channel/opportunity/crm
alter table work_report_message
drop constraint if exists work_report_message_biz_type_check;
alter table work_report_message
add constraint work_report_message_biz_type_check
check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity', 'crm'));
-- 4) CRM 拓展主表(结构与当前库实际定义一致)
create table if not exists crm_crm_expansion (
id bigint generated by default as identity primary key,
end_user varchar(200) not null,
office_name varchar(200) not null,
industry_attr varchar(200),
extension_type varchar(50) not null,
purchase_date date,
warranty_expiry date,
online_status varchar(50) not null,
contact_name varchar(100),
contact_phone varchar(50),
contact_title varchar(100),
supplier_id bigint,
h3c_contact_id bigint,
has_expansion_opportunity varchar(10),
owner_user_id bigint not null,
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_crm_crm_expansion_supplier
foreign key (supplier_id) references crm_channel_expansion(id),
constraint fk_crm_crm_expansion_h3c_contact
foreign key (h3c_contact_id) references crm_sales_expansion(id)
);
create index if not exists idx_crm_crm_expansion_owner
on crm_crm_expansion (owner_user_id);
create index if not exists idx_crm_crm_expansion_supplier
on crm_crm_expansion (supplier_id);
-- 5) CRM 拓展多联系人子表(结构与当前库实际定义一致)
create table if not exists crm_crm_expansion_contact (
id bigint generated by default as identity primary key,
crm_expansion_id bigint not null,
contact_name varchar(255),
contact_mobile varchar(64),
contact_title varchar(128),
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_crm_expansion_contact_expansion
on crm_crm_expansion_contact (crm_expansion_id);
-- 5.5) CRM 拓展主表新增字段:软件点数 / 扩容时间 / 扩容规模
-- 软件点数:整数;扩容时间:日期(与采购时间/过保时间一致用 date
-- 扩容规模:文本。
alter table crm_crm_expansion
add column if not exists software_points int;
alter table crm_crm_expansion
add column if not exists expansion_time date;
alter table crm_crm_expansion
add column if not exists expansion_scale varchar(100);
comment on column crm_crm_expansion.software_points is '软件点数';
comment on column crm_crm_expansion.expansion_time is '扩容时间';
comment on column crm_crm_expansion.expansion_scale is '扩容规模';
-- 5.6) CRM 拓展主表新增字段:进货商名称 / 新华三对接人名称
-- 需求:进货商、新华三对接人在下拉里没有想选的人时,可直接手动输入文字保存。
-- 因此除保留 supplier_id / h3c_contact_id 关联外,新增名称冗余字段:
-- - 下拉选中时:存 id并冗余存名称supplier_name / h3c_contact_name
-- - 手动输入时supplier_id / h3c_contact_id 置空,名称存文本
-- 列表/详情展示以名称字段优先,历史仅有 id 的旧数据回退用 JOIN 关联名称。
alter table crm_crm_expansion
add column if not exists supplier_name varchar(255);
alter table crm_crm_expansion
add column if not exists h3c_contact_name varchar(255);
comment on column crm_crm_expansion.supplier_name is '进货商名称(下拉选中冗余存名;手动输入时存文字)';
comment on column crm_crm_expansion.h3c_contact_name is '新华三对接人名称(下拉选中冗余存名;手动输入时存文字)';
-- 5.7) CRM 拓展主表新增字段:是否有维保项目机会
-- 使用系统字典 sys_is1是 / 0否
alter table crm_crm_expansion
add column if not exists has_maintenance_opportunity varchar(10);
comment on column crm_crm_expansion.has_maintenance_opportunity is '是否有维保项目机会(系统是否字典 sys_is1是 / 0否';
-- 6) CRM 与 OMS 字典码值映射表:用于商机推送 OMS / OMS 导入回传时做字典码值转换
create table if not exists crm_oms_dict_mapping (
id bigint generated by default as identity primary key,
dict_type varchar(50) not null,
crm_value varchar(100) not null,
crm_label varchar(200),
oms_value varchar(100) not null,
oms_label varchar(200),
remark varchar(500),
status smallint not null default 1,
is_default boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_crm_oms_dict_mapping unique (dict_type, crm_value, oms_value)
);
comment on table crm_oms_dict_mapping is 'CRM与OMS字典码值映射表';
comment on column crm_oms_dict_mapping.dict_type is '字典类型(如 opportunity_stage 商机阶段、opportunity_type 商机类型)';
comment on column crm_oms_dict_mapping.crm_value is 'CRM侧字典码值item_value';
comment on column crm_oms_dict_mapping.crm_label is 'CRM侧字典标签冗余便于核对';
comment on column crm_oms_dict_mapping.oms_value is 'OMS侧码值';
comment on column crm_oms_dict_mapping.oms_label is 'OMS侧标签冗余便于核对';
comment on column crm_oms_dict_mapping.remark is '备注';
comment on column crm_oms_dict_mapping.status is '状态1 启用 0 停用';
comment on column crm_oms_dict_mapping.is_default is '是否为默认映射true 是 false 否用于CRM推OMS时的默认匹配';
comment on column crm_oms_dict_mapping.created_at is '创建时间';
comment on column crm_oms_dict_mapping.updated_at is '更新时间';
-- 7) 初始化数据项目阶段sj_xmjd映射
insert into crm_oms_dict_mapping (dict_type, crm_value, crm_label, oms_value, oms_label, is_default)
values
('sj_xmjd', 'S0', 'S0-配置报价', 'S0', 'S0-配置报价', true),
('sj_xmjd', 'S1', 'S1-深化设计', 'S1', 'S1-标前引导', true),
('sj_xmjd', 'S1', 'S1-深化设计', 'S2', 'S2-招投标签', false),
('sj_xmjd', 'S2', 'S2-招投标签', 'S3', 'S3-招投标签期', true),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4A', 'S4A-中标未签(总包)', false),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4B', 'S4B-中标未签(省代)', false),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4C', 'S4C-中标未签(汇智)', true),
('sj_xmjd', 'S4', 'S4-下单未签单', 'S5', 'S5-订单合同签订期', true),
('sj_xmjd', 'S5', 'S5-已签单', 'S6', 'S6-订单合同已生效', true),
('sj_xmjd', 'S5', 'S5-已签单', 'S7A', 'S7A-订单部分发货', false),
('sj_xmjd', 'S5', 'S5-已签单', 'S7B', 'S7B-订单全部发货', false),
('sj_xmjd', 'S5', 'S5-已签单', 'S8', 'S8-已签收', false),
('sj_xmjd', 'L', 'L-已丢单', 'L', 'L-已丢单', true)
on conflict (dict_type, crm_value, oms_value) do nothing;
-- 8) 渠道拓展覆盖地市:改为从表存储(便于后期统计)
-- 覆盖关系存 crm_channel_expansion_coverage每行一个覆盖项省/市)。
-- 主表原有的 coverage_province / coverage_city 两列已废弃,
-- 历史数据迁移到从表后删除,查询/写入均改为读写从表,避免冗余与不一致。
create table if not exists crm_channel_expansion_coverage (
id bigserial primary key,
channel_id bigint not null references crm_channel_expansion(id) on delete cascade,
province varchar(100),
city varchar(100)
);
comment on table crm_channel_expansion_coverage is '渠道拓展覆盖地市明细(每行一个覆盖项)';
comment on column crm_channel_expansion_coverage.province is '覆盖省份cnarea 一级区域名称city 为空表示全省)';
comment on column crm_channel_expansion_coverage.city is '覆盖市/区/县cnarea 二级区域名称)';
create index if not exists idx_cec_channel_id on crm_channel_expansion_coverage(channel_id);
create index if not exists idx_cec_province on crm_channel_expansion_coverage(province);
create index if not exists idx_cec_city on crm_channel_expansion_coverage(city);
-- ---------------------------------------------------------------------
-- 9.5) 销售人员拓展所属区域:改为从表存储(参考渠道拓展"覆盖地市"结构)
-- 所属区域为多选下拉,每个选中项存为一行(省/市),
-- 关联 crm_sales_expansion(id)on delete cascade。
-- ---------------------------------------------------------------------
create table if not exists crm_sales_expansion_coverage (
id bigserial primary key,
sales_expansion_id bigint not null references crm_sales_expansion(id) on delete cascade,
province varchar(100),
city varchar(100)
);
comment on table crm_sales_expansion_coverage is '销售人员拓展所属区域明细(每行一个区域项)';
comment on column crm_sales_expansion_coverage.province is '所属省份cnarea 一级区域名称city 为空表示全省)';
comment on column crm_sales_expansion_coverage.city is '所属市/区/县cnarea 二级区域名称)';
create index if not exists idx_csec_sales_expansion_id on crm_sales_expansion_coverage(sales_expansion_id);
create index if not exists idx_csec_province on crm_sales_expansion_coverage(province);
create index if not exists idx_csec_city on crm_sales_expansion_coverage(city);
-- ---------------------------------------------------------------------
-- 9) work_checkin.id 序列同步(打卡主键改为 identity 自动生成)
-- 背景:历史版本 insertCheckIn 使用 max(id)+1 显式主键identity 序列
-- 从未推进;且历史遗留存在 work_checkin_id_seq / _seq1 双序列。
-- 现 insertCheckIn 已改为由 identity 自动生成主键,需将 identity 实际
-- 绑定的序列同步到表内 max(id),避免后续插入主键冲突。
-- 使用 pg_get_serial_sequence 动态解析实际序列,避免对错序列。
-- 幂等:重复执行结果一致,可安全用于任意环境。
-- ---------------------------------------------------------------------
do $$
declare
seq_name text;
begin
select pg_get_serial_sequence('work_checkin', 'id') into seq_name;
if seq_name is not null then
execute format('select setval(%L, coalesce((select max(id) from work_checkin), 0) + 1, false)', seq_name);
raise notice 'work_checkin.id 序列 % 已同步', seq_name;
else
raise notice '未找到 work_checkin.id 的 identity 序列';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 10) 渠道拓展新增字段:注册资金
-- 需求:渠道拓展页面新增「注册资金」字段,必填,前端位置在「人员规模」
-- 字段之后。单位与年度营业额一致万元numeric(18,2)。
-- 幂等add column if not exists可安全用于任意环境。
-- ---------------------------------------------------------------------
alter table crm_channel_expansion
add column if not exists registered_capital numeric(18, 2);
comment on column crm_channel_expansion.registered_capital is '注册资金(万元)';
-- ---------------------------------------------------------------------
-- 11) 渠道拓展联系人新增字段:工作职责 / 生日 / 是否加企业微信 / 特别说明
-- 需求:渠道联系人固定 6 行,工作职责不可编辑,固定为
-- 销售负责人、技术负责人、商务负责人、宣传负责人、
-- 订单通知接单人、汇智日常联络人。
-- 除生日选填外,其余联系人字段均必填(至少完整填写一行)。
-- 是否加企业微信使用系统「是否」字典sys_is是(1) / 否(0)。
-- 工作职责为固定 6 项:销售负责人 / 技术负责人 / 商务负责人 /
-- 宣传负责人 / 订单通知接单人 / 汇智日常联络人。
-- 幂等add column if not exists可安全用于任意环境。
-- ---------------------------------------------------------------------
alter table crm_channel_expansion_contact
add column if not exists duty varchar(50);
alter table crm_channel_expansion_contact
add column if not exists birthday date;
alter table crm_channel_expansion_contact
add column if not exists wecom_added varchar(100);
alter table crm_channel_expansion_contact
add column if not exists special_note text;
comment on column crm_channel_expansion_contact.duty is '工作职责固定6项之一销售负责人/技术负责人/商务负责人/宣传负责人/订单通知接单人/汇智日常联络人)';
comment on column crm_channel_expansion_contact.birthday is '生日';
comment on column crm_channel_expansion_contact.wecom_added is '是否加企业微信(系统是否字典 sys_is1是 / 0否';
comment on column crm_channel_expansion_contact.special_note is '特别说明';
commit;

View File

@ -0,0 +1,128 @@
-- =====================================================================
-- 2026-08-28 商机阶段历史数据换算更新CRM 码值更换)
-- PostgreSQL 17
-- =====================================================================
-- 背景:
-- CRM 商机阶段码值更换。当前数据库 crm_opportunity.stage 存的是 OMS 侧
-- 码值,需换算回新 CRM 码值。权威对应关系以 crm_oms_dict_mapping 表
-- dict_type='sj_xmjd'为准oms_value -> crm_value。
--
-- 当前库映射表(已与字典 sys_dict_item/sj_xmjd 对齐):
-- OMS(当前库值) -> CRM(新值)
-- S0 -> S0 | S1 -> S1 | S2 -> S1 | S3 -> S2
-- S4A/S4B/S4C -> S3 | S5 -> S4
-- S6/S7A/S7B/S8 -> S5已签单统一为 S5
-- L -> L
--
-- 注意原方案的「细分已签单」S5/S6A/S6B/S7与当前字典/映射表不符
-- (字典当前为 S0/S1/S2/S3/S4/S5/L无 S6A/S6B/S7已废弃。本脚本
-- 一律以当前映射表为准,保证换算结果全部落在字典定义内。
--
-- 安全性说明(务必审阅后执行):
-- 1) 前提:当前库 stage 全部为 OMS 码值(用户已确认)。
-- 2) 历史旧阶段initial_contact/solution_discussion/bidding/
-- business_negotiation/won/lost不在此对应范围保持不动NULL 不处理。
-- 3) 脚本默认只读诊断A/B/C更新语句E/D已注释确认后按
-- 「先 E 校正映射表,再 D 换算」顺序取消注释并整段重跑(事务内,可回滚)。
-- 4) 新环境部署20260827.sql 第 7 节插入的是旧码映射种子,必须启用
-- E 节将其校正为当前 S5 版,再执行 D 换算(对当前库 E 为幂等)。
-- =====================================================================
begin;
set search_path to public;
-- ---------------------------------------------------------------------
-- A) 诊断:当前 crm_opportunity.stage 分布(全部值)
-- ---------------------------------------------------------------------
select stage, count(*) as cnt
from crm_opportunity
group by stage
order by stage;
-- ---------------------------------------------------------------------
-- B) 诊断:按 crm_oms_dict_mapping 统计需换算的记录
-- oms_value=当前库里值crm_value=将换算成的新 CRM 码值
-- ---------------------------------------------------------------------
select m.oms_value as _oms, m.crm_value as _crm, count(*) as cnt
from crm_opportunity op
join crm_oms_dict_mapping m
on m.dict_type = 'sj_xmjd' and m.oms_value = op.stage
where m.crm_value <> op.stage
group by m.oms_value, m.crm_value
order by m.oms_value;
-- ---------------------------------------------------------------------
-- C) 诊断将被换算的具体行样例id、商机编号、当前值、目标值
-- 请核对命中记录确实为 OMS 语义的阶段,确认无误后再启用更新
-- ---------------------------------------------------------------------
select op.id, op.opportunity_code, op.stage as _oms, m.crm_value as _crm
from crm_opportunity op
join crm_oms_dict_mapping m
on m.dict_type = 'sj_xmjd' and m.oms_value = op.stage
where m.crm_value <> op.stage
order by op.id;
-- ---------------------------------------------------------------------
-- E) 校正 crm_oms_dict_mappingsj_xmjd为当前权威版本S5 已签单统一)
-- 数据源:当前库实际映射表,与字典 sys_dict_item/sj_xmjd 对齐。
-- 默认注释。新环境20260827.sql 旧码种子)必须启用;当前库为幂等。
-- =====================================================================
/*
delete from crm_oms_dict_mapping where dict_type = 'sj_xmjd';
insert into crm_oms_dict_mapping
(dict_type, crm_value, crm_label, oms_value, oms_label, is_default)
values
('sj_xmjd', 'S0', 'S0-配置报价', 'S0', 'S0-配置报价', true),
('sj_xmjd', 'S1', 'S1-深化设计', 'S1', 'S1-标前引导', true),
('sj_xmjd', 'S1', 'S1-深化设计', 'S2', 'S2-招投标签', false),
('sj_xmjd', 'S2', 'S2-招投标签', 'S3', 'S3-招投标签期', true),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4A', 'S4A-中标未签(总包)', false),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4B', 'S4B-中标未签(省代)', false),
('sj_xmjd', 'S3', 'S3-中标未下单', 'S4C', 'S4C-中标未签(汇智)', true),
('sj_xmjd', 'S4', 'S4-下单未签单', 'S5', 'S5-订单合同签订期', true),
('sj_xmjd', 'S5', 'S5-已签单', 'S6', 'S6-订单合同已生效', true),
('sj_xmjd', 'S5', 'S5-已签单', 'S7A', 'S7A-订单部分发货', false),
('sj_xmjd', 'S5', 'S5-已签单', 'S7B', 'S7B-订单全部发货', false),
('sj_xmjd', 'S5', 'S5-已签单', 'S8', 'S8-已签收', false),
('sj_xmjd', 'L', 'L-已丢单', 'L', 'L-已丢单', true)
on conflict (dict_type, crm_value, oms_value) do update
set crm_label = excluded.crm_label,
oms_label = excluded.oms_label,
is_default = excluded.is_default,
updated_at = now();
*/
-- ---------------------------------------------------------------------
-- D) 执行更新:按 crm_oms_dict_mapping 把 OMS 码值换算回新 CRM 码值
-- 默认注释。确认 E映射表校正与上方诊断无误后取消注释并整段重跑。
-- =====================================================================
/*
-- 注意:这是单条 UPDATE每行仅按执行前的原始 stage 匹配一次、SET 一次完成,
-- 不会出现 S3->S2 之后再按 S2->S1 二次匹配的连环更新。
-- 请勿将映射拆成多条顺序 UPDATE 执行,否则先 S3->S2 生成的 S2 会被
-- 后续 S2->S1 语句误判为 OMS 原值再次转换。
update crm_opportunity op
set stage = m.crm_value,
updated_at = now()
from crm_oms_dict_mapping m
where m.dict_type = 'sj_xmjd'
and op.stage = m.oms_value
and op.stage <> m.crm_value;
-- 核对本次受影响记录数
select count(*) as updated_rows
from crm_opportunity op
join crm_oms_dict_mapping m
on m.dict_type = 'sj_xmjd' and m.oms_value = op.stage
where m.crm_value <> op.stage;
-- 核对更新后分布,确认已全部转为新 CRM 码值(应落在 S0/S1/S2/S3/S4/S5/L
select stage, count(*) as cnt
from crm_opportunity
group by stage
order by stage;
*/
commit;

View File

@ -0,0 +1,63 @@
-- ---------------------------------------------------------------------
-- 渠道拓展:汇智内部认证级别(字典 tz_rzjb标签调整
-- 需求:
-- 1) 省代 -> 汇智省代
-- 2) 金牌 -> 汇智金牌
-- 3) 非认证渠道 -> 潜力渠道
-- 4) 新增汇智精英渠道item_value = '05',启用)
-- 5) 国代('01'不在新标签集内改为停用status = 0
-- 幂等:以 item_value 作为稳定键UPDATE/INSERT 均可安全重复执行。
-- ---------------------------------------------------------------------
begin;
-- 1) 省代 -> 汇智省代
update sys_dict_item
set item_label = '汇智省代',
sort_order = 1,
status = 1,
updated_at = now()
where type_code = 'tz_rzjb'
and item_value = '02'
and coalesce(is_deleted, 0) = 0;
-- 2) 金牌 -> 汇智金牌
update sys_dict_item
set item_label = '汇智金牌',
sort_order = 2,
status = 1,
updated_at = now()
where type_code = 'tz_rzjb'
and item_value = '03'
and coalesce(is_deleted, 0) = 0;
-- 3) 非认证渠道 -> 潜力渠道
update sys_dict_item
set item_label = '潜力渠道',
sort_order = 4,
status = 1,
updated_at = now()
where type_code = 'tz_rzjb'
and item_value = '04'
and coalesce(is_deleted, 0) = 0;
-- 4) 国代不在新标签集内,改为停用
update sys_dict_item
set status = 0,
updated_at = now()
where type_code = 'tz_rzjb'
and item_value = '01'
and coalesce(is_deleted, 0) = 0;
-- 5) 新增:汇智精英渠道(幂等,重复执行不会插入第二行)
insert into sys_dict_item (type_code, item_label, item_value, sort_order, status, is_deleted, remark)
select 'tz_rzjb', '汇智精英渠道', '05', 3, 1, 0, '认证级别'
where not exists (
select 1
from sys_dict_item s
where s.type_code = 'tz_rzjb'
and s.item_value = '05'
and coalesce(s.is_deleted, 0) = 0
);
commit;

View File

@ -244,7 +244,7 @@ create table if not exists crm_channel_expansion_contact (
create table if not exists crm_expansion_followup (
id bigint generated by default as identity primary key,
biz_type varchar(20) not null check (biz_type in ('sales', 'channel')),
biz_type varchar(20) not null check (biz_type in ('sales', 'channel', 'crm')),
biz_id bigint not null,
followup_time timestamptz not null,
followup_type varchar(50) not null,
@ -265,7 +265,7 @@ create table if not exists work_checkin (
user_id bigint not null,
checkin_date date not null,
checkin_time timestamptz not null,
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity')),
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity', 'crm')),
biz_id bigint,
biz_name varchar(200),
longitude numeric(10, 6),
@ -316,7 +316,7 @@ create table if not exists work_report_message (
receiver_user_id bigint not null,
report_date date not null,
line_index integer not null default 0,
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity')),
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity', 'crm')),
biz_id bigint,
biz_name varchar(200),
content text not null,
@ -761,7 +761,7 @@ BEGIN
) THEN
ALTER TABLE public.work_checkin
ADD CONSTRAINT work_checkin_biz_type_check
CHECK (biz_type IS NULL OR biz_type IN ('sales', 'channel', 'opportunity'));
CHECK (biz_type IS NULL OR biz_type IN ('sales', 'channel', 'opportunity', 'crm'));
END IF;
END $$;

View File

@ -0,0 +1,172 @@
# 代码改动审计报告
> 审计对象:仓库 `unis_crm` 当前工作区所有未提交改动(含新增文件)
> 审计方式:对后端/前端各模块 diff 逐一审查 + 关键高危点代码实读复核
> 生成时间2026-09-03
## 一、概述
本次改动范围很大,覆盖五大模块:
| 模块 | 主要风险 |
|------|---------|
| 拓展模块(后端) | 渠道联系人必填收紧导致旧调用/回归+测试红;互移字段丢弃;并发唯一性 |
| 拓展模块(前端) | 编辑旧渠道静默丢联系人;互移字段丢失;迁移校验阻断 |
| 商机模块 | **列表缓存陈旧数据(高危数据错误)**;未签单导出遗漏禁用/非标准阶段 |
| 工作/日报/打卡 | **存量库 CHECK 约束未含 `crm` 导致写入失败(高危部署问题)**日报联动时间改为固定09:00 |
| OMS/登录鉴权 | OMS回调硬编码弱token、核心逻辑未落地Dashboard懒加载改动 |
**审计结论一句话**
- 大部分功能需求6行固定联系人、sys_is 字典企微、互移入口、阶段筛选动态获取、鉴权回归)**实现正确**
- 但有 **2 个高危问题**(商机列表缓存陈旧数据、存量库打卡`crm`约束)会直接影响线上功能,**强烈建议先修**
- 有 **4 个中危回归点**(渠道联系人必填收紧、互移字段丢失、日报联动时间、未签单导出遗漏)涉及数据一致性,建议按产品确认后修改;
- 其余为低危/防御性项,可暂缓。
---
## 二、高危问题(建议优先处理)
### H1. 商机列表 · Tab 内切换筛选后显示陈旧数据(数据正确性回归)
- **文件**[Opportunities.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Opportunities.tsx#L1676-1697)
- **根因**:缓存 key 是 `archiveTab:keyword:filter:stageOptions.length`(按筛选粒度),但缓存**槽**却只有一个 `tabItemsCacheRef.current[archiveTab]`(按 tab 粒度)。同一 tab 内先选“S2”再切回“全部”时`loadedTabKeysRef` 已在上次记录“全部”这个 key于是命中缓存分支直接回显**上一次 S2 筛选**的结果,而非重新请求“全部”。
- **影响**:同一 tab 内切换筛选/关键字返回与筛选不匹配的商机列表,且不刷新不恢复,属于数据展示错误。
- **修改建议**:缓存值按 `loadKey` 粒度存储(`Map<loadKey, items>`),命中已加载 key 时用对应 key 的数据,而不是当前槽位的值;或将 key 写入槽位时校验与当前 `loadKey` 一致,不一致则强制重新请求。
### H2. 工作打卡 · 存量库 CHECK 约束未含 `crm` 类型 → 写入直接报错(部署级)
- **文件**[WorkCheckInSchemaInitializer.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/WorkCheckInSchemaInitializer.java#L40-54)
- **根因**:为支持打卡关联 CRM 拓展,`biz_type` 需新增 `'crm'` 值。但约束 `work_checkin_biz_type_check``if not exists(conname=...)` 判断,**已经部署过旧版库**里该约束已存在,升级后分支被跳过 → 约束值列表仍不含 `crm`。而本次改动的 `WorkServiceImpl.saveCheckIn` 已开放写入 `biz_type='crm'` → 存量库上写入 CRM 关联打卡/日报/跟进时触发 CHECK 违反,返回 500。
- **影响**CRM 拓展关联的外勤打卡、日报、跟进记录在所有已部署环境落库失败。
- **修改建议**:改用“先 `DROP CONSTRAINT IF EXISTS``ADD CONSTRAINT`”的幂等方式重建约束Java initializer 与 `sql/init_full_pg17.sql` 两处同步);上线前在目标库执行一次 `ALTER TABLE ... DROP CONSTRAINT IF EXISTS work_checkin_biz_type_check; ALTER TABLE ... ADD CONSTRAINT ... check(biz_type is null or biz_type in ('sales','channel','opportunity','crm'));`
---
## 三、中危问题(数据一致性/行为变更,需产品确认后修改)
### M1. 渠道联系人必填收紧 → 旧调用方/单测被破坏(回归面)
- **文件**[ExpansionServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L906-943) [ExpansionServiceImplTest.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java#L253-259)
- **问题**:校验由“姓名/电话/职位必填”收紧为“行内任一字段有值时,**除生日外 wecomAdded、specialNote 也必填**”。① 既有单测 `buildContact()` 只填 name/mobile/title必抛异常导致测试红② 任何未升级、不传这两个字段的调用方新增/编辑渠道会被拒。
- **修改建议**:同步补全 `buildContact()` 两个字段;确认所有渠道联系人调用方(`Expansion.tsx` 大改、`crmQuickCreate/shared.tsx`)已同步传参;若存在不可控旧客户端,后端可对这两个字段降级为可空并在展示层兜底。
### M2. 渠道↔CRM 互移导致渠道联系人专有字段丢失(且回退路径必然报错)
- **文件**[ExpansionServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L1250-1284)
- **问题**
- 渠道→CRM`toCrmContactRequests` 只保留 name/mobile/title**职务、生日、是否加企业微信、特别说明被丢弃**且迁回渠道也无法恢复CRM 表无这些列);
- CRM→渠道`MoveCrmToChannelRequest.contacts` 为空时后端回退用源 CRM 联系人,但转换后 wecomAdded/specialNote 恒为 null遇到 `normalizeRequiredContacts`(必填)必抛异常——前端即使留空想走“回退”也会失败。
- **影响**:互移一次后核心联系信息丢失;回退路径不可用。
- **修改建议**:若需保留,在 `crm_crm_expansion_contact` 补冗余列并双向迁移;否则在互移弹窗**显著提示**“生日/企微/特别说明将不被带入”,并在前端默认补默认值(如 wecomAdded="否")规避报错。
### M3. 未签单 tab 导出遗漏“禁用/非标准阶段码”商机违反约束7
- **文件**[Opportunities.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Opportunities.tsx#L2252-2279)
- **问题**:把“列表可见商机的真实 stageCode 合并进默认导出阶段集合”的逻辑**只写在 `archived`(已签单)分支**未签单activetab 的导出默认 `stageCodes` 仅字典启用项,若存在非 archived、非丢单但携带禁用/非标准阶段(如 S7A/S7B、S4A、OMS 同步值的商机会“列表可见但导出遗漏”正违反既定约束7。
- **修改建议**:把该合并逻辑抽成通用函数,对 `active``archived` 都套用。
### M4. 日报联动跟进时间由“提交时刻”改为固定 09:00
- **文件**[WorkServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L2157-2159)
- **问题**`syncReportFollowUps` 的跟进时间 `followUpTime = reportDate.atTime(9:00)`,之前是取日报提交时间/当前时间。日报在 09:00 后提交时,跟进/提醒时间被回拨到当天早晨 09:00可能已过期、排序提前
- **修改建议**:若确为需求(统一营业开始时间)建议用 `max(提交时刻, 09:00)` 或下一工作日 09:00否则恢复提交时间语义。**需产品确认是误改还是有意**。
### M5. 注册资金registeredCapital被设为新增必填行为变更
- **文件**[Expansion.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Expansion.tsx#L983-985) 、`crmQuickCreate/shared.tsx`
- **问题**:对无该字段历史的旧渠道,编辑保存会被强制补填;快速创建/迁移同样强制。
- **修改建议**:若是新需求可保留;若为兼容旧数据,建议仅新记录必填、存量允许空。
### M6. OMS 推送阶段改为经 `crm_oms_dict_mapping` 映射 + base-url 默认值变更
- **文件**[OmsClient.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/OmsClient.java#L115-117) 、[application.yml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application.yml)
- **问题**`projectStage` 由透传改为查映射表 `mapStageToOms`;若映射表迁移未入目标库会抛异常中断推送;`oms.base-url` 默认从 `192.168.4.78` 改为 `192.168.2.158`
- **修改建议**确认映射表迁移为发布前置OMS 地址改为环境配置而非提交内网 IP 默认值;映射查询失败降级为透传。
### M7. 渠道联系人字段丢失/校验边界(前端)——展开
- **文件**[crmQuickCreate/shared.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/features/crmQuickCreate/shared.tsx#L64-146)
- **问题**
- `isEmptyChannelContact` 把“仅填生日”的行判为非空,再经 `isCompleteChannelContact`(要求 name/mobile/title/wecom/specialNote 全有)判为不完整 → **用户只给某行补个生日会被整体拦截提交**,与“生日选填、空行可留空”文案矛盾;
- 编辑旧渠道(>6 条/重复职责的历史数据)回填固定 6 行时,多余的被静默丢弃。
- **修改建议**:判空排除 birthday仅生日视为空行对重复职责/超 6 条给明确提示而非静默删除。
### M8. ICU 关联Dashboard 首页卡片改懒加载(向后兼容破坏)+ 潜在无限请求
- **文件**[DashboardAnalyticsConfigService.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java)、[Dashboard.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Dashboard.tsx)
- **问题**`getDashboardPanel` 由“真实计算卡片值”改为返回空 dataLoaded=false需前端走新接口 `/api/dashboard/analytics-cards/data` 补齐;本仓库前端已适配,但其他消费者/缓存会拿到空值。懒加载若某 key 服务端过滤未返回,会反复触发请求且失败态无提示。
- **修改建议**:确认无外部消费者则保留;请求侧记录已请求 key 去重,失败给卡片级错误态与重试。
---
## 四、低危/防御性问题(可暂缓)
| # | 位置 | 问题 | 建议 |
|---|------|------|------|
| L1 | `OmsCallbackController.java` L25,51,63-71 | token 硬编码弱值、回调报文不打码、核心阶段回写仍是 TODO 却返回“成功” | token 配置化、脱敏日志、先落地幂等写入再上线 |
| L2 | `ExpansionMapper.xml` `insertChannelCoverage/insertSalesRegion` | 整批拼单条 INSERT数据极多时超限 | 分批或设上限 |
| L3 | `crm_crm_expansion.end_user` 无唯一约束 | 应用层 count 校验有并发窗口 | 如“最终用户唯一”是诉求,加部分唯一索引 |
| L4 | 渠道→CRM 互移 `clearOpportunityChannelExpansion``ExpansionServiceImpl.java` L448 | 商机与渠道关联清空且不指向新 CRM不可追踪 | 如需追踪,新增来源映射列/记入商机备注 |
| L5 | 渠道→CRM 互移未迁移覆盖地市coverage | 覆盖区域信息迁移后丢失 | 与产品确认处置 |
| L6 | `OpportunityMapper.xml` L288-317 阶段值 | 历史遗留 `o.stage='lost'` 不满足 `not in('L')`,丢单列表/导出遗漏旧数据(改动前已存在) | SQL 层合并 legacy 值 `in ('L','lost')` |
| L7 | `OpportunityServiceImpl.java` L64-65,944-974 | 集成反写强制 S5 在状态计算后执行可能导致 status/stage 不一致;`WON_STAGE_CODES` 未被使用 | 先定 stage 再算 status移除/使用死代码 |
| L8 | `Work.tsx` L1926,2269,4919 | 打卡/日报对象选择器默认类型由 `sales` 改为 `opportunity` | 若非有意,恢复 `"sales"` |
| L9 | `SearchOrInputSelect.tsx` L115-122 | 选错后无“清除/取消”能力 | 补“不选择”项valueId 置 0 |
| L10 | `SearchableSelect.tsx` vs `SearchOrInputSelect` 选中比较 | 严格相等 vs `Number()` 宽松比较(当前同为 number暂不影响 | 统一比较方式 |
| L11 | `Opportunities.tsx` L1674-1725 | 切 tab 时 reset 与 load 时序错位,短暂脏列表/闪烁 | 用标志位跳过 stale 请求 |
| L12 | 导出 `getOpportunityOverview(..., limit=null, includeDetails=true)` | 大商机量导出全量拉取+附加跟进,慢/占内存 | 确认导出列是否需跟进字段,必要时分批 |
---
## 五、建议处理顺序
**第一优先级(影响线上数据正确性/可用性,建议本次发布前必须处理):**
1. 商机列表缓存陈旧数据H1
2. 存量库打卡 `crm` CHECK 约束H2
**第二优先级(数据一致性/明确回归,建议尽快确认修复):**
3. 渠道联系人必填收紧导致的单测红 + 旧调用回归M1
4. 互移字段丢失 + 回退路径报错M2/M7
5. 未签单导出遗漏禁用/非标准阶段M3
6. 日报联动时间回拨M4先确认是否有意
7. 注册资金必填M5确认是否新需求
**第三优先级(防御性/待产品决策):**
8. OMS 回调安全 + 阶段回写落地L1/M6
9. Dashboard 懒加载兼容性M8
10. 其余低危项L2L12
---
## 六、已核验无问题项(避免过度修改)
- 商机阶段筛选动态获取字典、`IN/NOT IN` 绑定参数、切 tab 重置筛选 → 正确;
- 渠道联系人固定 6 行、工作职责只读、`sys_is` 字典取值 → 正确;
- 互移事务边界、`FOR UPDATE` 防重复迁移、NO ACTION 外键清理顺序 → 正确;
- 鉴权auth.ts、路由App.tsx 懒加载)、登录回归 → 未受影响;
- ECharts 模块化注册齐全、Dashboard 单卡失败不拖垮、异常处理器只将未命中资源由 500 转 404 → 无回归。
> 注本报告基于对工作区未提交改动的静态审查。互移moveChannelToCrm / moveCrmToChannel路径目前**无单元测试覆盖**,如决定保留互移功能,建议补充测试后再上线。
---
## 附《移至CRM拓展 / 移至渠道拓展》专项验证结果
> 复核范围两迁移动后端实现、mapper SQL、实时库表结构、前端两个移动弹窗表单初始化 / 校验 / 联系人编辑器 / 提交 API 编码)。
> 依据实时库确认:渠道联系人表 `crm_channel_expansion_contact``duty/birthday/wecom_added/special_note`CRM 联系人表 `crm_crm_expansion_contact` **仅** name/mobile/title。
### 已验证无问题的部分
- 事务边界:两个移动方法均 `@Transactional`,任一失败整体回滚。
- 并发防重:`selectChannelExpansionForMove` / `selectCrmExpansionForMove``FOR UPDATE` 行锁。
- 引用迁移完整:`updateFollowUpBiz` / `updateCheckinBiz` / `updateReportMessageBiz` 双向把跟进/打卡/日报改挂到新记录biz_type + biz_id 一起迁移。
- 覆盖地市:`crm_channel_expansion_coverage.channel_id` 为 `ON DELETE CASCADE`,删渠道自动清理,无孤儿行。
- 前台出错色 setterchannel→CRM 目标用 `invalidMoveCrmContactRows`、CRM→channel 用 `invalidMoveChannelContactRows`,命名反直觉但**未写反**,正确。
- `extensionType`(多选数组)在前端 API `encodeExpansionMultiValue` 拼串后提交,后端为 String正常`channelAttribute` / `coverageProvince` 等同理。
- 移动 API 携带 `coverageItems`(来自 `...rest`),后端 `replaceChannelCoverage` 正常写入。
### 已确认的 Bug / 需落地的字段诉求(按影响排序)
| 级别 | 编号 | 位置 | 问题 | 影响 / 建议 |
|------|------|------|------|------------|
| 高 | M-M1 | `selectChannelContactsForMove`([ExpansionMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml#L1370)) + `toCrmContactRequests`([ExpansionServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L1250)) | 渠道→CRM 迁移时,渠道联系人的 `duty/birthday/wecom_added/special_note`(是否加企微/特别说明/生日等)被丢弃。列表查询 `selectChannelContacts`:988 明明会查出这些字段(dto 有),迁移查询却只取 name/mobile/title | 迁移后渠道联系人的拓展字段**不可逆丢失**,且 CRM 联系人表无对应列可存。需产品确认:要么给 crm 联系人表加列并迁移带上字段,要么迁移前提示用户这些数据将不保留 |
| 中 | M-M2 | `clearOpportunityChannelExpansion`([ExpansionMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml#L1358)) | 渠道→CRM 迁移把 `crm_opportunity.channel_expansion_id` 置空;机会表无 CRM 关联列,商机不能挂到新 CRM | 原渠道下的商机迁移后失去渠道关联business 记录仍在但被游离)。需产品确认是否要用销售/其它维度重新关联 |
| 中 | M-M3 | `moveCrmToChannel` 仅接 endUser/industryAttr/purchaseDate/officeName→province其余 CRM 独有字段不迁移;前端 `handleOpenMove` CRM 分支完全重置为空白 | CRM→渠道迁移业务数据几乎全丢失软件点数/扩容机会/进货商/新华三/在线情况/过保时间等),且弹窗无任何预填,等于重填一份 | 结构性(渠道表无这些列)但无任何提示。建议弹窗明示「部分字段无法迁移需重新填写」,避免误以为迁移完整 |
| 中 | M-M4 | `deleteChannelExpansion` 上级联删覆盖CRM→渠道 覆盖写入正常 | 渠道→CRM 迁移后,渠道的覆盖省/市被级联删除且不转入 CRMCRM 无覆盖字段) | 属设计取舍但无提示,容易误判。建议在弹窗提示覆盖地市将不迁移 |
| 低 | M-M5 | `clearCrmSupplierRefs`([ExpansionMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml#L1364)) 只置 `supplier_id=null`,不清 `supplier_name` | 其它 CRM 把被迁渠道当进货商时,迁移后 supplier_id 空但 supplier_name 残留 | 详情显示名称但跳转/关联失效。建议连 name 一起清或回退 JOIN |
| 低 | M-M6 | `resolveChannelProvince`([ExpansionServiceImpl.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L1301)) 反查失败时返回 office_name 原文 | CRM→渠道 若 office_name 非字典码province 会存成「代表处名」而非省份,与 city(cnarea) 不一致 | 违反「省市区严格对应」的既定约束,建议失败时回退到字典 label 或允许前端补选省份 |
| 低 | M-M7 | `handleOpenMove`:3479 渠道→CRM | 把渠道「成立时间」自动作为「采购时间」预填、并自动 +3 年当过保时间 | 用户不手改时采购/过保可能不符实际。建议采购时间不强预填,或仅作可删的占位 |
| 低 | M-M8 | `moveCrmToChannel` 弹窗不暴露 stage/landedFlag | 迁移后渠道 stage 恒为 initial_contact、landedFlag 恒 false | 无法体现 CRM 侧真实状态;属字段语义取舍,需产品确认 |
### 与上一轮已修复问题的关系
- 大雪花 id 经前端 `Number(id)` 精度丢失导致 「移至 CRM 拓展」FK 违反(`fk_crm_crm_expansion_h3c_contact`),以及下拉「全部被勾选」——两者同源,本轮已确认修复([SearchOrInputSelect.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/components/SearchOrInputSelect.tsx) 比较改 String[Expansion.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Expansion.tsx) onChange 不再 `Number()`)。
- 建议M-M1/M-M2/M-M3 为产品级取舍上线前需与需求方对齐M-M5/M-M6 为低成本可改项;互移路径仍缺单元测试,建议优先补。

View File

@ -0,0 +1,143 @@
# UNIS CRM 系统全量审计分析报告
- 审计日期2026-09-01
- 审计范围后端Spring Boot + MyBatis、前端frontend / frontend1 两个 React 工程、SQL 脚本、Docker/Nginx 部署配置
- 审计方式:静态代码审查(未改动任何代码)
- 结论概览:商机/渠道/CRM 拓展互移、商机 tab 阶段筛选导出等核心业务逻辑**正确**;但存在 **2 个越权类安全漏洞、1 个前端业务阻断缺陷、1 个全新部署致命缺口** 及若干配置/一致性问题,建议按优先级修复。
---
## 一、严重问题(建议优先修复)
### 1. 打卡照片 / 日报附件下载接口存在越权访问IDOR
- 位置:
- [WorkController.java#L152-L159](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/WorkController.java#L152-L159)`GET /api/work/checkin-photos/{fileName}`
- [WorkController.java#L169-L176](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/WorkController.java#L169-L176)`GET /api/work/report-attachments/{fileName}`
- [WorkServiceImpl.java#L468-L476](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L468-L476)、[WorkServiceImpl.java#L506-L514](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L506-L514)
- 问题:两个下载接口不接收 `X-User-Id`,服务层仅做了「目录穿越」校验(拒绝 `..`、`/`、`\`**未校验文件属主/数据权限**。文件名格式为 `{userId}-{uuid}.{ext}`(见 [WorkServiceImpl.java#L462](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L462)、[WorkServiceImpl.java#L495](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L495))。任何已登录用户只要拿到/猜到他人文件名(文件名含 userId 前缀,可枚举)即可下载其打卡照片和日报附件。
- 建议:下载时校验文件前缀 userId 与当前登录用户一致,或通过数据权限服务校验归属。
### 2. 生产配置硬编码大量敏感凭据,且 jwt-secret 使用默认值
- 位置:[application.yml#L4-L10、L20-L29、L53、L78、L88](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application.yml#L4-L10)、[application-prod.yml#L51](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application-prod.yml#L51)
- 问题:
- MinIO`admin/Admin@123456`、PostgreSQL`unis@123`、Redis`zghz@123`)、企微 secret、OMS api-key 全部明文写入仓库。
- 两个 profile 的 `jwt-secret` 均为默认值 `change-me-please-change-me-32bytes`,且 docker-compose 未通过环境变量覆盖([docker-compose.yml#L14-L16](file:///Users/kangwenjing/Downloads/crm/unis_crm/docker-compose.yml#L14-L16) 仅传 `TZ``SPRING_PROFILES_ACTIVE`)。一旦使用默认值,攻击者可伪造任意用户/租户的 access token。
- 建议:改为注入环境变量/密钥管理,强制覆盖 `jwt-secret`
### 3. 日报提交失败后前端永久锁死,需刷新页面才能恢复
- 位置:[Work.tsx#L2175-L2226](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Work.tsx#L2175-L2226)
- 问题:`reportSubmitInFlightRef.current = true` 与 `setReportSubmitLocked(true)` 在 [L2205-L2206](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Work.tsx#L2205-L2206) 设置,但只在**成功路径**L2214-L2215复位。一旦 `saveWorkDailyReport` 抛错网络异常、后端校验失败、401 等),两个标记永久保持 `true`,提交按钮被守卫 [L2176](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Work.tsx#L2176) 永久禁用,提示「提交未确认,请刷新后重试」。当日已填写的整份日报(行项/附件/明日计划)必须刷新页面才能再次提交,丢失风险高。
- 建议:将两个标记的复位移入 `catch``finally`(与 `setSubmittingReport(false)` 一起)。
### 4. 全新安装脚本 `init_full_pg17.sql` 缺少 4 张运行时依赖表
- 位置:[init_full_pg17.sql](file:///Users/kangwenjing/Downloads/crm/unis_crm/sql/init_full_pg17.sql)(自声明为全新环境权威初始化入口)
- 问题:该脚本**没有**创建以下后端实际依赖的表:
- `crm_crm_expansion` / `crm_crm_expansion_contact`CRM 拓展主/从表,见 [CrmExpansionMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/CrmExpansionMapper.xml)
- `crm_oms_dict_mapping`OMS 字典映射,见 [CrmOmsDictMappingMapper.xml](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/CrmOmsDictMappingMapper.xml)
- `crm_channel_expansion_coverage`(覆盖地市从表,见 [ExpansionMapper.xml#L106-L119](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml#L106-L119)
- 这 4 张表只存在于增量脚本 [20260827.sql#L45、L77、L92、L142](file:///Users/kangwenjing/Downloads/crm/unis_crm/sql/20260827.sql#L45);已核实 8 个启动期 SchemaInitializer **均未补建**[common/](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/) 下只有 dashboard/日历/日报提醒/语音/数据权限等表)。
- 影响:全新库只跑 `init_full_pg17.sql`CRM 拓展模块、商机 OMS 推送/回传、渠道覆盖地市会在运行时抛 `relation does not exist`。生产库(已执行 20260827.sql不受影响但属部署链路硬缺口。
- 建议:在 `init_full_pg17.sql` 中补齐这 4 张表(可直接并入 20260827.sql 的定义)。
### 5. OMS 集成回写的阶段/状态校验与新阶段码不一致(残留死代码,不阻断实际同步)
- 位置:[OpportunityServiceImpl.java#L920-L925](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java#L920-L925)、[OpportunityServiceImpl.java#L944-L974](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java#L944-L974)
- 已确认用户核实OMS 反写只根据 `archived` 字段触发「已签约」,**不下发 `status=won/lost`**。因此 `archived=true → 强制 stage=S5` 路径([L922-L925](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java#L922-L925))实际可正常同步「已签单」,**不构成阻断**。
- 残留问题(低危):
- `resolveIntegrationStatus` 仅识别字面量 `"won"`/`"lost"` 阶段,但阶段经 `mapStageToCrm` 映射后为 `S5`/`L`,这两个分支是**死代码**`WON_STAGE_CODES = Set.of("won","S6")`[L65](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java#L65))定义后从未使用,且 `S6` 实为 OMS 码,佐证旧逻辑未随阶段码迁移同步更新。
- 若 OMS 未来开始下发 `status=won/lost`,仍会走到 `switch``case "won","lost"` 抛错;若 OMS 下发 `stage`(如 `L`)但不带 status商机 `status` 会落为 `active`(语义不符,但 `crm_opportunity.status` 允许 `active`,不报错)。
- 建议:后续可在 `resolveIntegrationStatus` 中按映射后新码S5→won、L→lost推导 status并清理死代码 `WON_STAGE_CODES`;非阻塞,可留作低优先维护项。
---
## 二、业务逻辑问题
### 6. 打卡可重复提交(当日无去重)
- 位置:[WorkServiceImpl.java#L324-L355](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L324-L355)
- 问题:`saveCheckIn` 每次直接 `insertCheckIn`,未先查当日是否已有记录;`updateCheckIn`[WorkMapper.java#L94](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java#L94))为从未调用的死代码;打卡表当日索引非唯一([WorkCheckInSchemaInitializer.java#L37](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/WorkCheckInSchemaInitializer.java#L37))。双击/重试会产生多条当日打卡,统计重复。
### 7. 日报导出按「业务类型」筛选在 5000 条截断之后执行
- 位置:[WorkServiceImpl.java#L316-L321](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L316-L321)
- 问题:`exportDailyReports` 先合并去重并 `limit(EXPORT_LIMIT=5000)`,再用内存 `hasReportLineOfType` 过滤;而打卡导出的 `bizType` 是在 SQL 内过滤([WorkMapper.xml#L35-L37](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/work/WorkMapper.xml#L35-L37))。日期区间大、其它类型行数多时,目标类型日报会被 5000 上限截断,导出结果偏少/为空。
### 8. 打卡日期边界使用 DB `current_date`,与日报 Asia/Shanghai 口径不一致
- 位置:[WorkMapper.xml#L96、L720、L744](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/work/WorkMapper.xml#L96)
- 问题:打卡按 `checkin_date = current_date`(依赖 DB 会话时区),日报统一用 Asia/Shanghai[WorkServiceImpl.java#L372-L373](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L372-L373))。若 DB 服务器时区非上海,北京时间凌晨 0-8 点打卡的归属日期会错位。
### 9. Dashboard「当前阶段」硬编码旧枚举与商机/拓展新阶段码不一致
- 位置:[DashboardMapper.xml#L186-L194商机、L247-L254销售、L307-L314渠道](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml#L186-L194)
- 问题:`case o.stage when 'initial_contact'...'won'/'lost'` 已与新版字典码值S0/S1/S2/S3/S4/S5/L见 [20260828update.sql](file:///Users/kangwenjing/Downloads/crm/unis_crm/sql/20260828update.sql))脱节,首页动态会直接显示原始码(如 `S5`、`L`)。建议改为动态 join `sys_dict_item/sj_xmjd`
### 10. 跟进记录新增接口返回的 ID 是「影响行数」而非真实主键
- 位置:[OpportunityServiceImpl.java#L315-L327](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java#L315-L327)、[ExpansionServiceImpl.java#L497-L506](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L497-L506)
- 问题:`insertOpportunityFollowUp` / `insertExpansionFollowUp` 未配置 `useGeneratedKeys``inserted` 恒为 1接口返回的 `Long` 实际是行数而非跟进记录 ID。前端当前未依赖该 ID影响较低但属错误返回值。
### 11. 商机导出采用「全量拉取 + 前端过滤」
- 位置:[Opportunities.tsx#L2255](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Opportunities.tsx#L2255)
- 问题:导出调用 `getOpportunityOverview("", undefined, false, null, ...)``limit=null` 一次性拉取租户内全部商机再在客户端过滤。商机规模大时单次请求体量大、耗时长,存在超时/内存风险。建议改为服务端筛选或分批拉取。
### 12. 语音识别配置页缺少「未选租户」防护
- 位置:[speech-recognition-settings/index.tsx#L156、L201-L217](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx#L156)
- 问题未选租户时保存会落到全局租户0静默改写全局配置而日报提醒页有 `isTenantUnselected` 拦截([report-reminder-settings/index.tsx](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend1/src/features/report-reminder/pages/report-reminder-settings/index.tsx))。行为不一致。
### 13. 前端 `request()``fetchWithAuth()` 对 401 处理不一致
- 位置:[auth.ts#L1259-L1271](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/auth.ts#L1259-L1271) vs [auth.ts#L1298-L1311](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/auth.ts#L1298-L1311)
- 问题:`request()` 遇 401 直接 `handleUnauthorizedResponse()`(清登录态跳登录页),不做「刷新+重试」;`fetchWithAuth()` 会先刷新再重试。token 被提前吊销或服务端判定失效但本地未过期时,用户会在操作中被直接登出并丢失已输入内容。
### 14. 附件上传走 XHR 但未做 token 主动刷新
- 位置:[auth.ts#L1668-L1723](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/auth.ts#L1668-L1723)
- 问题:`uploadWorkReportAttachment` 直接读 `localStorage.accessToken` 发 XHR不经过 `ensureFreshAccessToken`。长时间编辑后 token 到期,上传 401 → 直接登出,正在填写的日报/打卡表单数据丢失。建议上传前先刷新 token。
---
## 三、潜在风险 / 代码规范
| # | 问题 | 位置 |
|---|------|------|
| 15 | 生产环境 MyBatis `log-impl: StdOutImpl` 打印全部 SQL 与绑定参数(含敏感数据) | [application.yml#L36](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application.yml#L36) |
| 16 | 语音转写整文件读入内存、无独立大小限制;打卡照片无大小限制(依赖全局 500MB可致 OOM/存储膨胀 | [WorkServiceImpl.java#L516-L573](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L516-L573)、[WorkServiceImpl.java#L448-L465](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L448-L465) |
| 17 | 定时任务共享默认单线程调度器,`ReportReminderScheduler`(每分钟)与 `BusinessCalendarAutoSync`(每日 3:15可能相互阻塞且无分布式锁 | [ReportReminderScheduler.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/ReportReminderScheduler.java)、[BusinessCalendarAutoSync.java](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/BusinessCalendarAutoSync.java) |
| 18 | 数据权限完全依赖 unisbase 插件在 SQL 上动态注入SQL 本身无兜底条件;插件缺失/非 Web 上下文可能返回全库数据 | [WorkMapper.java#L32-L72](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java#L32) |
| 19 | 前端权限判断 fail-open权限码列表为空时全部放行`if (!codes.length) return true`),权限拉取失败时 UI 虚假放行 | [auth.ts#L1469-L1483](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/auth.ts#L1469-L1483) |
| 20 | `20260828update.sql``UPDATE ... FROM crm_oms_dict_mapping` 依赖 `oms_value` 唯一,但表唯一约束为 `(dict_type, crm_value, oms_value)`,未约束 `(dict_type, oms_value)` 唯一,后续新增映射可能产生非确定性更新 | [20260828update.sql#L106-L112](file:///Users/kangwenjing/Downloads/crm/unis_crm/sql/20260828update.sql#L106-L112)、[20260827.sql#L104](file:///Users/kangwenjing/Downloads/crm/unis_crm/sql/20260827.sql#L104) |
| 21 | 生产 profile 企微默认 `enabled:false`,与 SSO 期望redirect-uri 指向 crm.unissense.top不一致`/api/wecom/sso/**`、`/api/opportunities/integration/**` 为 permit-all | [application-prod.yml#L72-L82](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application-prod.yml#L72-L82) |
| 22 | frontend1 `http.ts` 鉴权白名单用 `url.includes(path)` 子串匹配,路径匹配过度放宽 | [http.ts#L14、L68-L70](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend1/src/api/http.ts#L14) |
| 23 | 生产 MinIO `endpoint``https://miniodown.nex.unisspace.com``use_ssl: false`,可能连接异常 | [application-prod.yml#L5-L10](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/application-prod.yml#L5-L10) |
| 24 | 后端 Dockerfile 复制 `target/unis-crm-backend-1.0.0-SNAPSHOT.jar`,但仓库内 jar 位于 `backend/` 根目录(非 target/),依赖 CI 先执行 `mvn package`,否则 `docker compose build` 会失败 | [backend/Dockerfile#L7](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/Dockerfile#L7) |
| 25 | 打卡/日报元数据使用字符串内嵌标记(`[[CHECKIN_PHOTOS]]`、`[[WORK_REPORT_LINES]]` 等)拼入 remark 字段,解析依赖精确格式,健壮性差 | [WorkServiceImpl.java#L91-L102](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L91-L102) |
| 26 | `ActionDialog` 确认回调 `void onConfirm()` 未捕获 rejection | [ActionDialog.tsx#L150](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/components/ActionDialog.tsx#L150) |
| 27 | 腾讯地图生产 Key 硬编码进前端源码作兜底 | [tencentMap.ts#L1](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/tencentMap.ts#L1) |
| 28 | 移动端多选下拉点击遮罩关闭不「回滚」已勾选项,与常规确定/取消交互不一致,易误改覆盖省市 | [AdaptiveSelect.tsx#L204-L216](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/components/AdaptiveSelect.tsx#L204-L216) |
| 29 | 浏览器定位最坏路径约 33s弱信号下用户易误以为卡死 | [tencentMap.ts#L145-L176](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/lib/tencentMap.ts#L145-L176) |
| 30 | Dashboard 权限/消息加载静默吞异常,问题被掩盖 | [DashboardServiceImpl.java#L171-L207](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java#L171-L207) |
---
## 四、已核对且判定为正常的重点项
1. **商机 tab 阶段筛选/导出约束(与项目硬约束一致)**
- 未签单 tab`excludeStageCodes=lostStageCodes`(排除 L-已丢单)✓([Opportunities.tsx#L2254](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Opportunities.tsx#L2254)、后端 [OpportunityMapper.xml#L288-L294](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml#L288-L294)
- 已丢单 tab`stageCodes=lostStageCodes`
- 切换 tab 重置阶段筛选 ✓([Opportunities.tsx#L1718-L1725](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/src/pages/Opportunities.tsx#L1718-L1725)
- 阶段选项动态取自 `sys_dict_item/sj_xmjd`,未硬编码 ✓
2. **渠道拓展/CRM 拓展互移流程**:属主校验、字段映射(覆盖省市/办公地址/联系人)、跟进/签到/日报消息的 biz_type 迁移、旧记录清理均正确([ExpansionServiceImpl.java#L398-L495](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java#L398-L495)),与前端弹窗字段、校验一致。
3. **覆盖地市存储模型**:后端读写 `crm_channel_expansion_coverage` 从表([ExpansionMapper.xml#L106-L119](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml#L106-L119)`insertChannelExpansion` 不引用废弃的 coverage 列,与 20260827.sql 的设计一致 ✓
4. **商机集成 archived→S5 强制**:已实现并通过单测([OpportunityServiceImplTest.java#L211-L228](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java#L211-L228));且经确认 OMS 反写仅用 `archived` 字段触发「已签约」(不下发 `status=won/lost`),实际同步正常 ✓
5. **SQL 增量脚本幂等性**`create table if not exists`、`drop constraint if exists`、`on conflict do nothing/update` 均正确20260828update.sql 的换算用单条 UPDATE 避免连环转换 ✓
6. **前后端字段一致性**`MoveCrmToChannelRequest`coverageProvince/coverageCity/coverageItems、`CreateOpportunityRequest` 与前端序列化一致confidencePct 正则与前端兜底一致 ✓
7. **Nginx 延迟解析**:两个前端 nginx 均使用 `resolver + set $backend_host` 延迟解析域名,符合约束 ✓([default.conf.template#L8、L16-L19](file:///Users/kangwenjing/Downloads/crm/unis_crm/frontend/nginx/default.conf.template#L8)
8. **前端 Dockerfile/.dockerignore**`.dockerignore` 已排除 node_modules、.git、系统目录多阶段构建合理 ✓
9. **日报保存逻辑**:「当日重复提交→更新」「北京时间 10 点前归属前一天」「提醒在事务提交后触发」均正确([WorkServiceImpl.java#L357-L415](file:///Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java#L357-L415)
---
## 五、修复优先级建议
| 优先级 | 问题编号 | 理由 |
|--------|---------|------|
| P0 | #1、#2 | 越权与凭据泄露,安全风险最高 |
| P0 | #4 | 全新部署直接功能瘫痪 |
| P1 | #3 | 前端业务阻断,日报当天无法提交 |
| P2 | #6、#7、#8、#9、#11、#13、#14 | 数据正确性与一致性 |
| P3 | #5 及其余 | #5 已确认不阻断实际同步OMS 仅用 archived 反写),降为低危维护项 |
> 说明:本报告仅做静态审计,未运行代码;部分结论(如 #5 是否实际触发、#11 的量级、#8 的时区)建议结合运行时数据/日志复核后再修复。

View File

@ -0,0 +1,141 @@
# UNIS CRM 系统测试报告
- **测试日期**2026-09-03
- **测试环境**:本机开发环境(后端 127.0.0.1:8080CRM 前端 3000/3002数据库/Redis 192.168.124.202
- **测试账号**:管理员 admin_crm (userId=19)、普通账号 13752913297/周瑾·部门领导 (userId=20)
- **测试方式**API 全量扫描 + 越权/安全专项测试 + 后端单元测试 + 前端构建验证
---
## 一、总体结论
系统**核心业务功能基本正常**(登录/刷新、工作台、商机管理、渠道拓展、归属转移、签到/日报、经营分析、导出等主流程均可跑通,错误提示友好,前端两个工程均可成功构建),但存在 **2 个严重安全问题、若干高危缺陷**,建议在上线前优先修复。
| 等级 | 数量 | 概述 |
|---|---|---|
| 🔴 严重 | 2 | X-User-Id 伪造身份越权;管理配置接口无鉴权(含密钥泄露、可被任意用户改写) |
| 🟠 高 | 2 | 全局异常处理缺陷(大量 500JWT 密钥使用仓库默认值 |
| 🟡 中 | 3 | CORS 全开;单测失败 2 例;验证码禁用无防爆破 |
| ⚪ 低 | 4 | OMS 回调占位 token中英文报错混杂密钥硬编码设计确认项 |
---
## 二、正常功能清单(已验证通过 ✅)
| 模块 | 验证内容 | 结果 |
|---|---|---|
| 认证 | 登录、错误密码统一提示、token 刷新、刷新后旧 token 失效(会话轮换)、伪造/篡改 token 被会话绑定校验拦截 | ✅ |
| 工作台 | /api/dashboard/home欢迎信息/统计/待办/动态)、待办完成、消息已读 | ✅ |
| 经营分析 | 卡片列表数据、单卡片详情、管理端配置读取与预览 | ✅ |
| 商机管理 | meta/overview2102条/详情/oms售前选项、创建含逐步字段校验、编辑、跟进记录、删除不存在的资源有正确报错 | ✅ |
| 渠道拓展 | meta/城市级联(含无效省份返回空)/overview/form-options/crm-overview、表单校验 | ✅ |
| 归属转移 | target-users、preview普通接口强制"原归属人=当前账号"✅)、管理接口有权限码校验 | ✅ |
| 工作日报 | 签到(需关联对象+现场照片+定位)、照片上传(MinIO)、日报提交、历史查询、导出、逆地理编码(腾讯地图,含越界/非数字校验) | ✅ |
| 权限隔离(正常路径) | 语音识别配置、数据授权配置对普通用户正确返回"无权";商机创建有 opportunity:create 权限校验 | ✅ |
| 内部集成接口 | /api/opportunities/integration/update 的 X-Internal-Secret 校验有效 | ✅ |
| 企业微信 SSO | /api/wecom/sso/entry 正确 302 到企业微信 OAuth | ✅ |
| 构建 | frontendvite build ✅、frontend1vite build ✅)、后端编译 ✅ | ✅ |
---
## 三、缺陷清单
### 🔴 BUG-01【严重】后端完全信任客户端 `X-User-Id` 请求头,任意登录用户可冒充其他用户
- **位置**:所有 `/api/*` 业务 Controller`OpportunityController`、`DashboardController` 等)通过 `@RequestHeader("X-User-Id")` 获取当前用户,`CurrentUserUtils.requireCurrentUserId()` 只校验非空,不校验与 token 身份一致
- **复现**(普通用户 token + 管理员 userId 即可读到管理员数据):
```bash
curl -H "Authorization: Bearer <普通用户token>" -H "X-User-Id: 19" \
http://127.0.0.1:8080/api/dashboard/home
# 返回 crm管理员 的首页数据userId=19, realName=crm管理员
```
- **影响**:横向/纵向越权——查看他人首页、个人概览、商机、日报、导出他人签到/日报数据;配合数据权限体系被整体绕过(周瑾本人已配置全量可见,但任何低权限用户均可伪造 19/43/… 任意 userId
- **建议**:在安全过滤器中以 JWT claims 覆盖/校验 `X-User-Id`(不一致即 403或改为从 SecurityContext 取当前用户
### 🔴 BUG-02【严重】`/sys/api/admin` 多个管理配置接口无任何权限校验(可读可写、密钥明文泄露、跨租户)
- **位置**`ReportReminderAdminController`、`DashboardAnalyticsAdminController`(控制器与 Service 层均无 admin 校验)
- **受影响接口**(普通用户 token 直接访问,全部返回 200
- `GET/PUT /sys/api/admin/wecom-app-config` —— **企微 corpId/agentId/secret 明文返回**PUT 无字段校验,`{}` 即可清空配置(已实测并当场恢复);`?tenantId=1` 可跨租户读取
- `GET/PUT /sys/api/admin/report-reminder-config`、`POST /sys/api/admin/report-reminder-config/test`test 接口可被滥用向员工发企微消息)
- `GET/PUT /sys/api/admin/dashboard-analytics-config`(及 preview、calendar/sync-current-year
- `GET /sys/api/admin/wecom-config-status`、`GET /sys/api/admin/report-reminder-calendar-status`
- **对照**:同目录下 `speech-recognition-config`、`user-data-scope/*` 有 Service 层权限校验(普通用户 401"无权查看")✅,说明这是部分接口遗漏
- **建议**:为上述接口统一增加与 speech/user-data-scope 相同的权限校验;企微 secret 不应明文回显(脱敏显示)
### 🟠 BUG-03【高】全局异常处理缺陷参数缺失 / JSON 解析错误 / 类型不匹配 一律返回 500
- **位置**`CrmGlobalExceptionHandler` 未覆盖 `MissingServletRequestParameterException`、`HttpMessageNotReadableException`、`MethodArgumentTypeMismatchException` 等
- **复现实例**(全部返回 500"系统内部错误",应为 400 + 明确提示):
- `GET /api/dashboard/analytics-cards/data`(缺 cardKeys
- `GET /api/expansion/areas/cities`(缺 provinceName
- `GET /api/owner-transfer/preview`(缺 fromUserId/toUserId曾因传错参数名 sourceUserId 触发)
- `GET /sys/api/admin/user-data-scope/assignment`(缺参数)
- `POST /sys/auth/refresh`(无效 refreshToken
- `POST /api/opportunities``{"amount":"abc"}` 或非法 JSON
- `POST /api/wecom/sso/exchange`(伪造 ticket、`GET /api/wecom/sso/js-sdk-config`(缺 url
- **建议**:补充对应 @ExceptionHandler,返回 400 与字段级错误信息,避免前端展示"系统内部错误"
### 🟠 BUG-04【高】运行环境使用仓库内默认 JWT 密钥
- **证据**:用 `application.yml` 中的默认值 `change-me-please-change-me-32bytes` 签发的 token签名校验**通过**(返回"Login expired"会话校验失败,而非"Invalid token"签名失败)——证明当前 8080 实例就是用该默认密钥签发 token
- **现状缓解**unisbase 框架有 sessionId↔userId 的 Redis 会话绑定校验,单纯伪造 payload 会被拦截;但任何拿到源码的人已具备签名能力,一旦会话校验有任何绕过即全面失守
- **建议**立即更换为强随机密钥并走环境变量注入prod 配置里同为此默认值)
### 🟡 BUG-05【中】CORS 配置全开
- **证据**:任意 `Origin: http://evil.example.com` 都被反射为 `Access-Control-Allow-Origin`,且 `Access-Control-Allow-Credentials: true`
- **影响**:结合 BUG-01/02恶意站点可借用户浏览器凭证发起跨域读操作
- **建议**CORS 白名单化,仅允许已知前端域名
### 🟡 BUG-06【中】后端单元测试 2/113 失败
- `ExpansionServiceImplTest.createChannelExpansion_shouldRejectDuplicateChannelName`:期望"渠道重复…",实际先抛"请完整填写渠道联系人的姓名、职务…"
- `ExpansionServiceImplTest.updateChannelExpansion_shouldCheckDuplicateChannelNameExcludingSelf`:同类错误
- **原因**:联系人必填校验(`normalizeRequiredContacts`)先于渠道重名校验执行,且测试夹具未包含完整联系人信息——**测试与最新校验逻辑未同步**(或校验顺序需调整:重名提示应优先于字段完整性)
- **建议**:补齐测试数据或调整校验顺序后修复用例
### 🟡 BUG-07【中】登录验证码被禁用无防爆破机制
- `GET /sys/auth/captcha` 返回 `500 "Captcha disabled"`,登录接口不校验验证码,也无失败锁定(配置中 max-attempts: 5 未生效)
- **建议**生产环境启用验证码或登录失败限流Redis 计数)
### ⚪ BUG-08【低】OMS 回调接口为占位实现且不可用
- `OmsCallbackController`:硬编码占位 token `your_secret_token_here`;该路径未加入 permit-all先被全局过滤器 401 拦截(实际调不通);处理逻辑为 TODO 空壳
- **建议**:要么实现(配置化 token + 加入 permit-all + 白名单),要么移除
### ⚪ BUG-09【低】报错文案中英文混杂
- 会话失效时框架层返回英文 `"Login expired"`,业务层返回中文"登录已失效,请重新登录";前端需同时兼容两种文案
- **建议**:统一国际化
### ⚪ BUG-10【低】敏感信息硬编码入库
- `application.yml` / `application-prod.yml` 中明文包含:数据库/Redis/MinIO 密码、企微 secret、内部集成 secret、OMS apiKey、腾讯地图 keyprod 与 dev 相同)
- **建议**:全部改环境变量注入,并轮换已泄露密钥
### ❓ CONFIRM-01【设计确认】普通账号数据权限为全租户
- 周瑾部门领导userId=20可见全部 2102 条商机(与其他用户数据一致)、全量经营分析卡片、全员签到/日报导出。数据授权表sys_user_data_scope_user中她无任何显式授权推测来自 unisbase DataScope 的角色规则
- **请确认**:部门领导是否应具有全租户数据可见权;经营分析(全公司金额 46478 万)是否应对普通员工开放
---
## 四、测试遗留数据(均可识别、可清理)
| 类型 | ID | 归属 | 标识 |
|---|---|---|---|
| 商机 | 2228 | 周瑾(20) | 【自动化测试】请勿使用-权限验证 |
| 商机 | 2229 | admin(19) | 【自动化测试】请勿使用-权限验证 |
| 签到 | 1025 | 周瑾(20) | 地点前缀【自动化测试】 |
| 日报 | 999 | 周瑾(20) | 内容前缀【自动化测试】 |
| 签到照片 | 20-20d874400111404ebe2aa48fad846576.jpg | MinIO | — |
> 注:测试中曾意外清空租户 4 企微应用配置BUG-02 复现所致),**已当场用原值恢复并二次验证一致**。商机 2229 曾被改名/改金额用于越权验证,已恢复。
## 五、修复优先级建议
1. **立即**BUG-01X-User-Id 校验、BUG-02admin 接口鉴权 + secret 脱敏、BUG-04更换 JWT 密钥)
2. **本周**BUG-03异常处理器补齐、BUG-05CORS 白名单、BUG-06修复单测
3. **排期**BUG-07/08/09/10 及 CONFIRM-01 设计确认