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
parent
80eb1a493f
commit
abb76e472f
|
|
@ -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→渠道 各执行一遍,核对主表、联系人、跟进记录、打卡/日报关联、商机引用均正确迁移或置空。
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# 2026-09-03 系统测试
|
||||
|
||||
- 对 unis_crm 做了全系统测试(后端8080 + 前端3000/3002 + frontend1),产出《系统测试报告-20260903.md》
|
||||
- 关键发现:
|
||||
- 严重:后端信任 X-User-Id 头(任何登录用户可冒充任意 userId,IDOR)
|
||||
- 严重:/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(均带【自动化测试】前缀);企微配置曾意外清空已恢复
|
||||
|
|
@ -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.env(9/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 必报 InvalidColumnReference,psycopg2 直连 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/default(202 库 postgres/unis@123 只读排查用)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
""");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_is:1是 / 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_is:1是 / 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_is:1是 / 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
* 根据字典类型和CRM码值查询默认映射的OMS码值
|
||||
* @param dictType 字典类型(如 sj_xmjd)
|
||||
* @param crmValue CRM侧码值
|
||||
* @return OMS侧码值,如果不存在则返回null
|
||||
*/
|
||||
String selectOmsValueByCrmValue(@Param("dictType") String dictType, @Param("crmValue") String crmValue);
|
||||
|
||||
/**
|
||||
* 根据字典类型和OMS码值查询映射的CRM码值
|
||||
* @param dictType 字典类型(如 sj_xmjd)
|
||||
* @param omsValue OMS侧码值
|
||||
* @return CRM侧码值,如果不存在则返回null
|
||||
*/
|
||||
String selectCrmValueByOmsValue(@Param("dictType") String dictType, @Param("omsValue") String omsValue);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字典类型和CRM码值获取对应的OMS默认码值
|
||||
* @param dictType 字典类型
|
||||
* @param crmValue CRM码值
|
||||
* @return OMS码值,如果找不到映射返回null
|
||||
*/
|
||||
public String getOmsValue(String dictType, String crmValue) {
|
||||
if (dictType == null || crmValue == null) {
|
||||
return null;
|
||||
}
|
||||
return mappingMapper.selectOmsValueByCrmValue(dictType, crmValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字典类型和OMS码值获取对应的CRM码值
|
||||
* @param dictType 字典类型
|
||||
* @param omsValue OMS码值
|
||||
* @return CRM码值,如果找不到映射返回null
|
||||
*/
|
||||
public String getCrmValue(String dictType, String omsValue) {
|
||||
if (dictType == null || omsValue == null) {
|
||||
return null;
|
||||
}
|
||||
return mappingMapper.selectCrmValueByOmsValue(dictType, omsValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将项目阶段(sj_xmjd)的CRM码值转换为OMS码值
|
||||
* @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)的OMS码值转换为CRM码值
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ""));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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-effort:OMS 异常不阻断新增商机(商机已创建成功,可稍后手动推送)
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("不支持的跟进对象类型");
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 <> #{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 < 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 <> ''
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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_mapping:CRM 与 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_is:1是 / 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_is:1是 / 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_is:1是 / 0否)';
|
||||
comment on column crm_channel_expansion_contact.special_note is '特别说明';
|
||||
|
||||
commit;
|
||||
|
|
@ -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_mapping(sj_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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 $$;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`(已签单)分支**;未签单(active)tab 的导出默认 `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. 其余低危项(L2–L12)
|
||||
|
||||
---
|
||||
|
||||
## 六、已核验无问题项(避免过度修改)
|
||||
|
||||
- 商机阶段筛选动态获取字典、`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`,删渠道自动清理,无孤儿行。
|
||||
- 前台出错色 setter:channel→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 迁移后,渠道的覆盖省/市被级联删除且不转入 CRM(CRM 无覆盖字段) | 属设计取舍但无提示,容易误判。建议在弹窗提示覆盖地市将不迁移 |
|
||||
| 低 | 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 为低成本可改项;互移路径仍缺单元测试,建议优先补。
|
||||
|
|
@ -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 的时区)建议结合运行时数据/日志复核后再修复。
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
# UNIS CRM 系统测试报告
|
||||
|
||||
- **测试日期**:2026-09-03
|
||||
- **测试环境**:本机开发环境(后端 127.0.0.1:8080,CRM 前端 3000/3002,数据库/Redis 192.168.124.202)
|
||||
- **测试账号**:管理员 admin_crm (userId=19)、普通账号 13752913297/周瑾·部门领导 (userId=20)
|
||||
- **测试方式**:API 全量扫描 + 越权/安全专项测试 + 后端单元测试 + 前端构建验证
|
||||
|
||||
---
|
||||
|
||||
## 一、总体结论
|
||||
|
||||
系统**核心业务功能基本正常**(登录/刷新、工作台、商机管理、渠道拓展、归属转移、签到/日报、经营分析、导出等主流程均可跑通,错误提示友好,前端两个工程均可成功构建),但存在 **2 个严重安全问题、若干高危缺陷**,建议在上线前优先修复。
|
||||
|
||||
| 等级 | 数量 | 概述 |
|
||||
|---|---|---|
|
||||
| 🔴 严重 | 2 | X-User-Id 伪造身份越权;管理配置接口无鉴权(含密钥泄露、可被任意用户改写) |
|
||||
| 🟠 高 | 2 | 全局异常处理缺陷(大量 500);JWT 密钥使用仓库默认值 |
|
||||
| 🟡 中 | 3 | CORS 全开;单测失败 2 例;验证码禁用无防爆破 |
|
||||
| ⚪ 低 | 4 | OMS 回调占位 token;中英文报错混杂;密钥硬编码;设计确认项 |
|
||||
|
||||
---
|
||||
|
||||
## 二、正常功能清单(已验证通过 ✅)
|
||||
|
||||
| 模块 | 验证内容 | 结果 |
|
||||
|---|---|---|
|
||||
| 认证 | 登录、错误密码统一提示、token 刷新、刷新后旧 token 失效(会话轮换)、伪造/篡改 token 被会话绑定校验拦截 | ✅ |
|
||||
| 工作台 | /api/dashboard/home(欢迎信息/统计/待办/动态)、待办完成、消息已读 | ✅ |
|
||||
| 经营分析 | 卡片列表数据、单卡片详情、管理端配置读取与预览 | ✅ |
|
||||
| 商机管理 | meta/overview(2102条)/详情/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 | ✅ |
|
||||
| 构建 | frontend(vite build ✅)、frontend1(vite 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、腾讯地图 key(prod 与 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-01(X-User-Id 校验)、BUG-02(admin 接口鉴权 + secret 脱敏)、BUG-04(更换 JWT 密钥)
|
||||
2. **本周**:BUG-03(异常处理器补齐)、BUG-05(CORS 白名单)、BUG-06(修复单测)
|
||||
3. **排期**:BUG-07/08/09/10 及 CONFIRM-01 设计确认
|
||||
Loading…
Reference in New Issue