diff --git a/.image/common/crm-feature.png b/.image/common/crm-feature.png new file mode 100644 index 0000000000..e1c9670c6f Binary files /dev/null and b/.image/common/crm-feature.png differ diff --git a/README.md b/README.md index 296df47ec8..8fcb94cb02 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,12 @@ 演示地址: +### CRM 系统 + +![功能图](/.image/common/crm-feature.png) + +演示地址: + ## 🐨 技术栈 ### 模块 @@ -279,7 +285,7 @@ | 框架 | 说明 | 版本 | 学习指南 | |---------------------------------------------------------------------------------------------|------------------|----------------|----------------------------------------------------------------| -| [Spring Boot](https://spring.io/projects/spring-boot) | 应用开发框架 | 2.7.17 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | +| [Spring Boot](https://spring.io/projects/spring-boot) | 应用开发框架 | 2.7.18 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | | [MySQL](https://www.mysql.com/cn/) | 数据库服务器 | 5.7 / 8.0+ | | | [Druid](https://github.com/alibaba/druid) | JDBC 连接池、监控组件 | 1.2.19 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?yudao) | | [MyBatis Plus](https://mp.baomidou.com/) | MyBatis 增强工具包 | 3.5.3.2 | [文档](http://www.iocoder.cn/Spring-Boot/MyBatis/?yudao) | diff --git a/pom.xml b/pom.xml index 126c95f170..2bd7f4cb2a 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ 1.5.0 1.18.30 - 2.7.17 + 2.7.18 1.5.5.Final UTF-8 diff --git a/sql/mysql/optinal/crm_data.sql b/sql/mysql/optinal/crm_data.sql deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/sql/mysql/optinal/crm_menu.sql b/sql/mysql/optinal/crm_menu.sql deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/sql/mysql/ruoyi-vue-pro.sql b/sql/mysql/ruoyi-vue-pro.sql index 3306b7c720..c7eeef72d3 100644 --- a/sql/mysql/ruoyi-vue-pro.sql +++ b/sql/mysql/ruoyi-vue-pro.sql @@ -11,7 +11,7 @@ Target Server Version : 80200 (8.2.0) File Encoding : 65001 - Date: 17/02/2024 21:00:07 + Date: 26/02/2024 12:38:00 */ SET NAMES utf8mb4; @@ -385,7 +385,7 @@ CREATE TABLE `infra_api_error_log` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 15324 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '系统异常日志'; +) ENGINE = InnoDB AUTO_INCREMENT = 15536 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '系统异常日志'; -- ---------------------------- -- Records of infra_api_error_log @@ -690,7 +690,7 @@ CREATE TABLE `infra_file` ( `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1261 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文件表'; +) ENGINE = InnoDB AUTO_INCREMENT = 1264 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文件表'; -- ---------------------------- -- Records of infra_file @@ -879,7 +879,7 @@ CREATE TABLE `system_dict_data` ( `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1508 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典数据表'; +) ENGINE = InnoDB AUTO_INCREMENT = 1509 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典数据表'; -- ---------------------------- -- Records of system_dict_data @@ -1153,26 +1153,14 @@ INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `st INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1399, 6, '支付宝', '6', 'crm_receivable_return_type', 0, 'default', '', '', '1', '2023-10-18 21:55:38', '1', '2023-10-18 21:55:38', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1400, 7, '微信支付', '7', 'crm_receivable_return_type', 0, 'default', '', '', '1', '2023-10-18 21:55:53', '1', '2023-10-18 21:55:53', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1401, 8, '其他', '8', 'crm_receivable_return_type', 0, 'default', '', '', '1', '2023-10-18 21:56:06', '1', '2023-10-18 21:56:06', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1402, 1, 'A 农、林、牧、渔业', '1', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:15', '1', '2023-10-28 23:02:15', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1403, 2, 'B 采矿业', '2', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:29', '1', '2023-10-28 23:02:29', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1404, 3, 'C 制造业', '3', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:41', '1', '2023-10-28 23:02:41', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1405, 4, 'D 电力、热力、燃气及水生产和供应业', '4', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:54', '1', '2023-10-28 23:02:54', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1406, 5, 'E 建筑业', '5', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:03', '1', '2023-10-28 23:03:03', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1407, 6, 'F 批发和零售业', '6', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:13', '1', '2023-10-28 23:03:13', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1408, 7, 'G 交通运输、仓储和邮政业', '7', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:27', '1', '2023-10-28 23:03:27', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1409, 8, 'H 住宿和餐饮业', '8', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:37', '1', '2023-10-28 23:03:37', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1410, 9, 'I 信息传输、软件和信息技术服务业', '9', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:47', '1', '2023-10-28 23:03:47', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1411, 10, 'J 金融业', '10', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:57', '1', '2023-10-28 23:03:57', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1412, 11, 'K 房地产业', '11', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:04:15', '1', '2023-10-28 23:04:22', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1413, 12, 'L 租赁和商务服务业', '12', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:04:33', '1', '2023-10-28 23:04:33', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1414, 13, 'M 科学研究和技术服务业', '13', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:04:43', '1', '2023-10-28 23:04:43', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1415, 14, 'N 水利、环境和公共设施管理业', '14', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:04:53', '1', '2023-10-28 23:04:53', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1416, 15, 'O 居民服务、修理和其他服务业', '15', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:05:05', '1', '2023-10-28 23:05:05', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1417, 16, 'P 教育', '16', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:05:15', '1', '2023-10-28 23:05:15', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1418, 17, 'Q 卫生和社会工作', '17', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:05:44', '1', '2023-10-28 23:05:44', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1419, 18, 'R 文化、体育和娱乐业', '18', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:05:55', '1', '2023-10-28 23:05:55', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1420, 19, 'S 公共管理、社会保障和社会组织', '19', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:06:05', '1', '2023-10-28 23:06:05', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1421, 20, 'T 国际组织', '20', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:06:15', '1', '2023-10-28 23:06:15', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1402, 1, 'IT', '1', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:15', '1', '2024-02-18 23:30:38', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1403, 2, '金融业', '2', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:29', '1', '2024-02-18 23:30:43', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1404, 3, '房地产', '3', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:41', '1', '2024-02-18 23:30:48', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1405, 4, '商业服务', '4', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:02:54', '1', '2024-02-18 23:30:54', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1406, 5, '运输/物流', '5', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:03', '1', '2024-02-18 23:31:00', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1407, 6, '生产', '6', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:13', '1', '2024-02-18 23:31:08', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1408, 7, '政府', '7', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:27', '1', '2024-02-18 23:31:13', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1409, 8, '文化传媒', '8', 'crm_customer_industry', 0, 'default', '', '', '1', '2023-10-28 23:03:37', '1', '2024-02-18 23:31:20', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1422, 1, 'A (重点客户)', '1', 'crm_customer_level', 0, 'primary', '', '', '1', '2023-10-28 23:07:13', '1', '2023-10-28 23:07:13', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1423, 2, 'B (普通客户)', '2', 'crm_customer_level', 0, 'info', '', '', '1', '2023-10-28 23:07:35', '1', '2023-10-28 23:07:35', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1424, 3, 'C (非优先客户)', '3', 'crm_customer_level', 0, 'default', '', '', '1', '2023-10-28 23:07:53', '1', '2023-10-28 23:07:53', b'0'); @@ -1258,6 +1246,7 @@ INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `st INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1505, 71, '采购入库(作废)', '71', 'erp_stock_record_biz_type', 0, 'danger', '', '', '1', '2024-02-16 13:10:10', '1', '2024-02-16 19:40:40', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1506, 80, '采购退货出库', '80', 'erp_stock_record_biz_type', 0, '', '', '', '1', '2024-02-16 13:10:17', '1', '2024-02-16 13:10:17', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1507, 81, '采购退货出库(作废)', '81', 'erp_stock_record_biz_type', 0, 'danger', '', '', '1', '2024-02-16 13:10:26', '1', '2024-02-16 19:40:33', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1508, 3, 'CRM', '3', 'bpm_model_category', 0, 'success', '', '', '1', '2024-02-24 07:58:38', '1', '2024-02-24 07:58:44', b'0'); COMMIT; -- ---------------------------- @@ -1353,7 +1342,7 @@ INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creat INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (183, '砍价记录的状态', 'promotion_bargain_record_status', 0, '', '1', '2023-10-05 10:41:08', '1', '2023-10-05 10:41:08', b'0', '1970-01-01 00:00:00'); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (184, '拼团记录的状态', 'promotion_combination_record_status', 0, '', '1', '2023-10-08 07:24:25', '1', '2023-10-08 07:24:25', b'0', '1970-01-01 00:00:00'); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (185, '回款-回款方式', 'crm_receivable_return_type', 0, '回款-回款方式', '1', '2023-10-18 21:54:10', '1', '2023-10-18 21:54:10', b'0', '1970-01-01 00:00:00'); -INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (186, '客户所属行业', 'crm_customer_industry', 0, 'CRM 客户所属行业', '1', '2023-10-28 22:57:07', '1', '2023-10-28 15:11:16', b'0', NULL); +INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (186, 'CRM 客户行业', 'crm_customer_industry', 0, 'CRM 客户所属行业', '1', '2023-10-28 22:57:07', '1', '2024-02-18 23:30:22', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (187, '客户等级', 'crm_customer_level', 0, 'CRM 客户等级', '1', '2023-10-28 22:59:12', '1', '2023-10-28 15:11:16', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (188, '客户来源', 'crm_customer_source', 0, 'CRM 客户来源', '1', '2023-10-28 23:00:34', '1', '2023-10-28 15:11:16', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (600, 'Banner 位置', 'promotion_banner_position', 0, '', '1', '2023-10-08 07:24:25', '1', '2023-11-04 13:04:02', b'0', '1970-01-01 00:00:00'); @@ -1415,7 +1404,7 @@ CREATE TABLE `system_login_log` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2906 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '系统访问记录'; +) ENGINE = InnoDB AUTO_INCREMENT = 2920 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '系统访问记录'; -- ---------------------------- -- Records of system_login_log @@ -1545,7 +1534,7 @@ CREATE TABLE `system_menu` ( `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2702 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '菜单权限表'; +) ENGINE = InnoDB AUTO_INCREMENT = 2712 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '菜单权限表'; -- ---------------------------- -- Records of system_menu @@ -2121,7 +2110,7 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2497, '主子表(ERP)', '', 2, 11, 1070, 'demo03-erp', 'ep:calendar', 'infra/demo/demo03/erp/index', 'Demo03StudentERP', 0, b'1', b'1', b'1', '', '2023-11-16 15:50:59', '1', '2023-11-17 13:19:56', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2516, '客户公海配置', '', 2, 0, 2524, 'customer-pool-config', 'ep:data-analysis', 'crm/customer/poolConfig/index', 'CrmCustomerPoolConfig', 0, b'1', b'1', b'1', '', '2023-11-18 13:33:31', '1', '2024-01-03 19:52:06', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2517, '客户公海配置保存', 'crm:customer-pool-config:update', 3, 1, 2516, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-11-18 13:33:31', '', '2023-11-18 13:33:31', b'0'); -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2518, '客户限制配置', '', 2, 0, 2524, 'customer-limit-config', 'ep:avatar', 'crm/customer/limitConfig/index', 'CrmCustomerLimitConfig', 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '1', '2024-01-03 19:58:25', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2518, '客户限制配置', '', 2, 1, 2524, 'customer-limit-config', 'ep:avatar', 'crm/customer/limitConfig/index', 'CrmCustomerLimitConfig', 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '1', '2024-02-24 16:43:33', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2519, '客户限制配置查询', 'crm:customer-limit-config:query', 3, 1, 2518, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '', '2023-11-18 13:33:53', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2520, '客户限制配置创建', 'crm:customer-limit-config:create', 3, 2, 2518, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '', '2023-11-18 13:33:53', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2521, '客户限制配置更新', 'crm:customer-limit-config:update', 3, 3, 2518, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '', '2023-11-18 13:33:53', b'0'); @@ -2129,7 +2118,7 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2523, '客户限制配置导出', 'crm:customer-limit-config:export', 3, 5, 2518, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-11-18 13:33:53', '', '2023-11-18 13:33:53', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2524, '系统配置', '', 1, 999, 2397, 'config', 'ep:connection', '', '', 0, b'1', b'1', b'1', '1', '2023-11-18 21:58:00', '1', '2024-02-17 17:14:34', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2525, 'WebSocket 测试', '', 2, 7, 2, 'websocket', 'ep:connection', 'infra/webSocket/index', 'InfraWebSocket', 0, b'1', b'1', b'1', '1', '2023-11-23 19:41:55', '1', '2023-11-24 19:22:30', b'0'); -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2526, '产品管理', '', 2, 10, 2397, 'product', 'fa:product-hunt', 'crm/product/index', 'CrmProduct', 0, b'1', b'1', b'1', '1', '2023-12-05 22:45:26', '1', '2023-12-05 22:46:45', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2526, '产品管理', '', 2, 80, 2397, 'product', 'fa:product-hunt', 'crm/product/index', 'CrmProduct', 0, b'1', b'1', b'1', '1', '2023-12-05 22:45:26', '1', '2024-02-20 20:36:20', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2527, '产品查询', 'crm:product:query', 3, 1, 2526, '', '', '', '', 0, b'1', b'1', b'1', '1', '2023-12-05 22:47:16', '1', '2023-12-05 22:47:16', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2528, '产品创建', 'crm:product:create', 3, 2, 2526, '', '', '', '', 0, b'1', b'1', b'1', '1', '2023-12-05 22:47:41', '1', '2023-12-05 22:47:48', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2529, '产品更新', 'crm:product:update', 3, 3, 2526, '', '', '', '', 0, b'1', b'1', b'1', '1', '2023-12-05 22:48:03', '1', '2023-12-05 22:48:03', b'0'); @@ -2163,8 +2152,8 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2557, '钱包余额', '', 2, 1, 2551, 'wallet-balance', 'fa:leaf', 'pay/wallet/balance/index', 'WalletBalance', 0, b'1', b'1', b'1', '', '2023-12-29 02:32:54', '', '2023-12-29 02:32:54', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2558, '钱包余额查询', 'pay:wallet:query', 3, 1, 2557, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2023-12-29 02:32:54', '', '2023-12-29 02:32:54', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2559, '转账订单', '', 2, 3, 1117, 'transfer', 'ep:credit-card', 'pay/transfer/index', 'PayTransfer', 0, b'1', b'1', b'1', '', '2023-12-29 02:32:54', '', '2023-12-29 02:32:54', b'0'); -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2560, '商业智能', '', 1, 200, 2397, 'bi', 'ep:data-line', '', '', 0, b'1', b'1', b'1', '1', '2024-01-26 22:50:35', '1', '2024-02-17 17:14:39', b'0'); -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2561, '排行榜', '', 2, 1, 2560, 'ranking', 'fa:area-chart', 'crm/bi/rank/index', 'BiRanking', 0, b'1', b'1', b'1', '1', '2024-01-26 22:52:09', '1', '2024-02-03 01:10:43', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2560, '数据统计', '', 1, 200, 2397, 'statistics', 'ep:data-line', '', '', 0, b'1', b'1', b'1', '1', '2024-01-26 22:50:35', '1', '2024-02-24 20:10:07', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2561, '排行榜', '', 2, 1, 2560, 'ranking', 'fa:area-chart', 'crm/statistics/rank/index', 'CrmStatisticsRank', 0, b'1', b'1', b'1', '1', '2024-01-26 22:52:09', '1', '2024-02-24 20:11:17', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2562, '客户导入', 'crm:customer:import', 3, 6, 2391, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-01 13:09:00', '1', '2024-02-01 13:09:05', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2563, 'ERP 系统', '', 1, 300, 0, '/erp', 'fa-solid:store', '', '', 0, b'1', b'1', b'1', '1', '2024-02-04 15:37:25', '1', '2024-02-04 15:37:25', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2564, '产品管理', '', 1, 40, 2563, 'product', 'fa:product-hunt', '', '', 0, b'1', b'1', b'1', '1', '2024-02-04 15:38:43', '1', '2024-02-04 15:38:43', b'0'); @@ -2305,6 +2294,16 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2699, '收款单导出', 'erp:finance-receipt:export', 3, 5, 2694, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2024-02-05 16:08:56', '', '2024-02-12 00:45:05', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2700, '收款单审批', 'erp:finance-receipt:update-status', 3, 6, 2694, '', '', '', NULL, 0, b'1', b'1', b'1', '', '2024-02-05 16:08:56', '', '2024-02-12 00:45:08', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2701, '待办事项', '', 2, 0, 2397, 'backlog', 'fa-solid:tasks', 'crm/backlog/index', 'CrmBacklog', 0, b'1', b'1', b'1', '1', '2024-02-17 17:17:11', '1', '2024-02-17 17:17:11', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2702, 'ERP 首页', 'erp:statistics:query', 2, 0, 2563, 'home', 'ep:home-filled', 'views/erp/home/index.vue', 'ErpHome', 0, b'1', b'1', b'1', '1', '2024-02-18 16:49:40', '1', '2024-02-18 19:06:39', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2703, '商机状态配置', '', 2, 4, 2524, 'business-status', 'fa-solid:charging-station', 'crm/business/status/index', 'CrmBusinessStatus', 0, b'1', b'1', b'1', '1', '2024-02-21 20:15:17', '1', '2024-02-21 20:15:17', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2704, '商机状态查询', 'crm:business-status:query', 3, 1, 2703, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-21 20:35:36', '1', '2024-02-21 20:36:06', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2705, '商机状态创建', 'crm:business-status:create', 3, 2, 2703, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-21 20:35:57', '1', '2024-02-21 20:35:57', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2706, '商机状态更新', 'crm:business-status:update', 3, 3, 2703, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-21 20:36:21', '1', '2024-02-21 20:36:21', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2707, '商机状态删除', 'crm:business-status:delete', 3, 4, 2703, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-21 20:36:36', '1', '2024-02-21 20:36:36', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2708, '合同配置', '', 2, 5, 2524, 'contract-config', 'ep:connection', 'crm/contract/config/index', 'CrmContractConfig', 0, b'1', b'1', b'1', '1', '2024-02-24 16:44:40', '1', '2024-02-24 16:44:48', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2709, '客户公海配置查询', 'crm:customer-pool-config:query', 3, 2, 2516, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-24 16:45:19', '1', '2024-02-24 16:45:28', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2710, '合同配置更新', 'crm:contract-config:update', 3, 1, 2708, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-24 16:45:56', '1', '2024-02-24 16:45:56', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (2711, '合同配置查询', 'crm:contract-config:query', 3, 2, 2708, '', '', '', '', 0, b'1', b'1', b'1', '1', '2024-02-24 16:46:16', '1', '2024-02-24 16:46:16', b'0'); COMMIT; -- ---------------------------- @@ -2425,7 +2424,7 @@ CREATE TABLE `system_oauth2_access_token` ( PRIMARY KEY (`id`) USING BTREE, INDEX `idx_access_token`(`access_token` ASC) USING BTREE, INDEX `idx_refresh_token`(`refresh_token` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 4781 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OAuth2 访问令牌'; +) ENGINE = InnoDB AUTO_INCREMENT = 4999 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OAuth2 访问令牌'; -- ---------------------------- -- Records of system_oauth2_access_token @@ -2493,7 +2492,7 @@ CREATE TABLE `system_oauth2_client` ( -- Records of system_oauth2_client -- ---------------------------- BEGIN; -INSERT INTO `system_oauth2_client` (`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, `access_token_validity_seconds`, `refresh_token_validity_seconds`, `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, `authorities`, `resource_ids`, `additional_information`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1, 'default', 'admin123', '芋道源码', 'http://test.yudao.iocoder.cn/a5e2e244368878a366b516805a4aabf1.png', '我是描述', 0, 1800, 43200, '[\"https://www.iocoder.cn\",\"https://doc.iocoder.cn\"]', '[\"password\",\"authorization_code\",\"implicit\",\"refresh_token\"]', '[\"user.read\",\"user.write\"]', '[]', '[\"user.read\",\"user.write\"]', '[]', '{}', '1', '2022-05-11 21:47:12', '1', '2022-07-05 16:23:52', b'0'); +INSERT INTO `system_oauth2_client` (`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, `access_token_validity_seconds`, `refresh_token_validity_seconds`, `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, `authorities`, `resource_ids`, `additional_information`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1, 'default', 'admin123', '芋道源码', 'http://test.yudao.iocoder.cn/a5e2e244368878a366b516805a4aabf1.png', '我是描述', 0, 1800, 2592000, '[\"https://www.iocoder.cn\",\"https://doc.iocoder.cn\"]', '[\"password\",\"authorization_code\",\"implicit\",\"refresh_token\"]', '[\"user.read\",\"user.write\"]', '[]', '[\"user.read\",\"user.write\"]', '[]', '{}', '1', '2022-05-11 21:47:12', '1', '2024-02-22 16:31:52', b'0'); INSERT INTO `system_oauth2_client` (`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, `access_token_validity_seconds`, `refresh_token_validity_seconds`, `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, `authorities`, `resource_ids`, `additional_information`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (40, 'test', 'test2', 'biubiu', 'http://test.yudao.iocoder.cn/277a899d573723f1fcdfb57340f00379.png', '啦啦啦啦', 0, 1800, 43200, '[\"https://www.iocoder.cn\"]', '[\"password\",\"authorization_code\",\"implicit\"]', '[\"user_info\",\"projects\"]', '[\"user_info\"]', '[]', '[]', '{}', '1', '2022-05-12 00:28:20', '1', '2023-12-02 21:01:01', b'0'); INSERT INTO `system_oauth2_client` (`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, `access_token_validity_seconds`, `refresh_token_validity_seconds`, `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, `authorities`, `resource_ids`, `additional_information`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (41, 'yudao-sso-demo-by-code', 'test', '基于授权码模式,如何实现 SSO 单点登录?', 'http://test.yudao.iocoder.cn/fe4ed36596adad5120036ef61a6d0153654544d44af8dd4ad3ffe8f759933d6f.png', NULL, 0, 1800, 43200, '[\"http://127.0.0.1:18080\"]', '[\"authorization_code\",\"refresh_token\"]', '[\"user.read\",\"user.write\"]', '[]', '[]', '[]', NULL, '1', '2022-09-29 13:28:31', '1', '2022-09-29 13:28:31', b'0'); INSERT INTO `system_oauth2_client` (`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, `access_token_validity_seconds`, `refresh_token_validity_seconds`, `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, `authorities`, `resource_ids`, `additional_information`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (42, 'yudao-sso-demo-by-password', 'test', '基于密码模式,如何实现 SSO 单点登录?', 'http://test.yudao.iocoder.cn/604bdc695e13b3b22745be704d1f2aa8ee05c5f26f9fead6d1ca49005afbc857.jpeg', NULL, 0, 1800, 43200, '[\"http://127.0.0.1:18080\"]', '[\"password\",\"refresh_token\"]', '[\"user.read\",\"user.write\"]', '[]', '[]', '[]', NULL, '1', '2022-10-04 17:40:16', '1', '2022-10-04 20:31:21', b'0'); @@ -2547,7 +2546,7 @@ CREATE TABLE `system_oauth2_refresh_token` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1346 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OAuth2 刷新令牌'; +) ENGINE = InnoDB AUTO_INCREMENT = 1358 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OAuth2 刷新令牌'; -- ---------------------------- -- Records of system_oauth2_refresh_token @@ -2587,7 +2586,7 @@ CREATE TABLE `system_operate_log` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 10187 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志记录'; +) ENGINE = InnoDB AUTO_INCREMENT = 10568 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志记录'; -- ---------------------------- -- Records of system_operate_log @@ -2620,7 +2619,7 @@ CREATE TABLE `system_operate_log_v2` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 8892 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志记录 V2 版本'; +) ENGINE = InnoDB AUTO_INCREMENT = 9018 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志记录 V2 版本'; -- ---------------------------- -- Records of system_operate_log_v2 @@ -2678,7 +2677,7 @@ CREATE TABLE `system_role` ( `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 142 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色信息表'; +) ENGINE = InnoDB AUTO_INCREMENT = 143 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色信息表'; -- ---------------------------- -- Records of system_role @@ -2686,6 +2685,7 @@ CREATE TABLE `system_role` ( BEGIN; INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (1, '超级管理员', 'super_admin', 1, 1, '', 0, 1, '超级管理员', 'admin', '2021-01-05 17:03:48', '', '2022-02-22 05:08:21', b'0', 1); INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (2, '普通角色', 'common', 2, 2, '', 0, 1, '普通角色', 'admin', '2021-01-05 17:03:48', '', '2022-02-22 05:08:20', b'0', 1); +INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (3, 'CRM 管理员', 'crm_admin', 2, 1, '', 0, 1, 'CRM 专属角色', '1', '2024-02-24 10:51:13', '1', '2024-02-24 02:51:32', b'0', 1); INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (101, '测试账号', 'test', 0, 2, '[]', 0, 2, '我想测试', '', '2021-01-06 13:49:35', '1', '2023-12-07 08:41:16', b'0', 1); INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (109, '租户管理员', 'tenant_admin', 0, 1, '', 0, 1, '系统自动生成', '1', '2022-02-22 00:56:14', '1', '2022-02-22 00:56:14', b'0', 121); INSERT INTO `system_role` (`id`, `name`, `code`, `sort`, `data_scope`, `data_scope_dept_ids`, `status`, `type`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (110, '测试角色', 'test', 0, 1, '[]', 0, 2, '嘿嘿', '110', '2022-02-23 00:14:34', '110', '2022-02-23 13:14:58', b'0', 121); @@ -4683,7 +4683,7 @@ CREATE TABLE `system_sms_log` ( `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 588 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '短信日志'; +) ENGINE = InnoDB AUTO_INCREMENT = 608 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '短信日志'; -- ---------------------------- -- Records of system_sms_log @@ -4992,7 +4992,7 @@ CREATE TABLE `system_users` ( -- Records of system_users -- ---------------------------- BEGIN; -INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (1, 'admin', '$2a$10$mRMIYLDtRHlf6.9ipiqH1.Z.bh/R9dO9d5iHiGYPigi6r5KOoR2Wm', '芋道源码', '管理员', 103, '[1]', 'aoteman@126.com', '18818260277', 2, 'http://127.0.0.1:48080/admin-api/infra/file/4/get/37e56010ecbee472cdd821ac4b608e151e62a74d9633f15d085aee026eedeb60.png', 0, '0:0:0:0:0:0:0:1', '2024-02-17 17:10:34', 'admin', '2021-01-05 17:03:47', NULL, '2024-02-17 17:10:34', b'0', 1); +INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (1, 'admin', '$2a$10$mRMIYLDtRHlf6.9ipiqH1.Z.bh/R9dO9d5iHiGYPigi6r5KOoR2Wm', '芋道源码', '管理员', 103, '[1]', 'aoteman@126.com', '18818260277', 2, 'http://127.0.0.1:48080/admin-api/infra/file/4/get/37e56010ecbee472cdd821ac4b608e151e62a74d9633f15d085aee026eedeb60.png', 0, '0:0:0:0:0:0:0:1', '2024-02-23 13:12:22', 'admin', '2021-01-05 17:03:47', NULL, '2024-02-23 13:12:22', b'0', 1); INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (100, 'yudao', '$2a$10$11U48RhyJ5pSBYWSn12AD./ld671.ycSzJHbyrtpeoMeYiw31eo8a', '芋道', '不要吓我', 104, '[1]', 'yudao@iocoder.cn', '15601691300', 1, '', 1, '127.0.0.1', '2022-07-09 23:03:33', '', '2021-01-07 09:07:17', NULL, '2022-07-09 23:03:33', b'0', 1); INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (103, 'yuanma', '$2a$10$YMpimV4T6BtDhIaA8jSW.u8UTGBeGhc/qwXP4oxoMr4mOw9.qttt6', '源码', NULL, 106, NULL, 'yuanma@iocoder.cn', '15601701300', 0, '', 0, '127.0.0.1', '2022-07-08 01:26:27', '', '2021-01-13 23:50:35', NULL, '2022-07-08 01:26:27', b'0', 1); INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (104, 'test', '$2a$10$GP8zvqHB//TekuzYZSBYAuBQJiNq1.fxQVDYJ.uBCOnWCtDVKE4H6', '测试号', NULL, 107, '[1,2]', '111@qq.com', '15601691200', 1, '', 0, '0:0:0:0:0:0:0:1', '2023-09-24 18:21:19', '', '2021-01-21 02:13:53', NULL, '2023-09-24 18:21:19', b'0', 1); diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index 4ef58422f4..c2e2ab2f47 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -106,16 +106,6 @@ - - cn.iocoder.boot - yudao-spring-boot-starter-biz-dict - ${revision} - - - cn.iocoder.boot - yudao-spring-boot-starter-biz-pay - ${revision} - cn.iocoder.boot yudao-spring-boot-starter-biz-tenant @@ -131,16 +121,6 @@ yudao-spring-boot-starter-biz-ip ${revision} - - cn.iocoder.boot - yudao-spring-boot-starter-captcha - ${revision} - - - cn.iocoder.boot - yudao-spring-boot-starter-desensitize - ${revision} - @@ -387,11 +367,6 @@ - - cn.iocoder.boot - yudao-spring-boot-starter-flowable - ${revision} - org.flowable flowable-spring-boot-starter-process @@ -548,11 +523,6 @@ okhttp ${okhttp3.version} - - cn.iocoder.boot - yudao-spring-boot-starter-file - ${revision} - io.minio minio diff --git a/yudao-example/yudao-sso-demo-by-code/pom.xml b/yudao-example/yudao-sso-demo-by-code/pom.xml new file mode 100644 index 0000000000..958c3eb369 --- /dev/null +++ b/yudao-example/yudao-sso-demo-by-code/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + + cn.iocoder.boot + yudao-sso-demo-by-code + 1.0.0-snapshot + jar + + ${project.artifactId} + 基于授权码模式,如何实现 SSO 单点登录? + https://github.com/YunaiV/ruoyi-vue-pro + + + + 8 + 8 + UTF-8 + + 2.7.18 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + cn.hutool + hutool-all + 5.8.22 + + + + org.projectlombok + lombok + true + + + + diff --git a/yudao-example/yudao-sso-demo-by-password/pom.xml b/yudao-example/yudao-sso-demo-by-password/pom.xml new file mode 100644 index 0000000000..c10e6b39f8 --- /dev/null +++ b/yudao-example/yudao-sso-demo-by-password/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + + cn.iocoder.boot + yudao-sso-demo-by-password + 1.0.0-snapshot + jar + + ${project.artifactId} + 基于密码模式,如何实现 SSO 单点登录? + https://github.com/YunaiV/ruoyi-vue-pro + + + + 8 + 8 + UTF-8 + + 2.7.18 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + cn.hutool + hutool-all + 5.8.22 + + + + org.projectlombok + lombok + true + + + + diff --git a/yudao-framework/pom.xml b/yudao-framework/pom.xml index a9ef0a5a5e..820fc4f2b3 100644 --- a/yudao-framework/pom.xml +++ b/yudao-framework/pom.xml @@ -15,24 +15,20 @@ yudao-spring-boot-starter-redis yudao-spring-boot-starter-web yudao-spring-boot-starter-security + yudao-spring-boot-starter-websocket - yudao-spring-boot-starter-file yudao-spring-boot-starter-monitor yudao-spring-boot-starter-protection yudao-spring-boot-starter-job yudao-spring-boot-starter-mq yudao-spring-boot-starter-excel + yudao-spring-boot-starter-test yudao-spring-boot-starter-biz-operatelog - yudao-spring-boot-starter-biz-dict - yudao-spring-boot-starter-biz-tenant yudao-spring-boot-starter-biz-data-permission yudao-spring-boot-starter-biz-ip - - yudao-spring-boot-starter-captcha - yudao-spring-boot-starter-websocket yudao-framework diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java index 8ffd21cccf..cb4ddec34a 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java @@ -257,11 +257,11 @@ public class CollectionUtils { return !CollectionUtil.isEmpty(from) ? from.get(0) : null; } - public static T findFirst(List from, Predicate predicate) { + public static T findFirst(Collection from, Predicate predicate) { return findFirst(from, predicate, Function.identity()); } - public static U findFirst(List from, Predicate predicate, Function func) { + public static U findFirst(Collection from, Predicate predicate, Function func) { if (CollUtil.isEmpty(from)) { return null; } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java index 59656cbdda..87c20798ea 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java @@ -132,4 +132,40 @@ public class LocalDateTimeUtils { return LocalDateTimeUtil.between(dateTime, LocalDateTime.now(), ChronoUnit.DAYS); } + /** + * 获取今天的开始时间 + * + * @return 今天 + */ + public static LocalDateTime getToday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + } + + /** + * 获取昨天的开始时间 + * + * @return 昨天 + */ + public static LocalDateTime getYesterday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now().minusDays(1)); + } + + /** + * 获取本月的开始时间 + * + * @return 本月 + */ + public static LocalDateTime getMonth() { + return beginOfMonth(LocalDateTime.now()); + } + + /** + * 获取本年的开始时间 + * + * @return 本年 + */ + public static LocalDateTime getYear() { + return LocalDateTime.now().with(TemporalAdjusters.firstDayOfYear()).with(LocalTime.MIN); + } + } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java index 2c7288efa6..2cbe93ca83 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java @@ -136,6 +136,21 @@ public class JsonUtils { } } + /** + * 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null + * + * @param text 字符串 + * @param typeReference 类型引用 + * @return 指定类型的对象 + */ + public static T parseObjectQuietly(String text, TypeReference typeReference) { + try { + return objectMapper.readValue(text, typeReference); + } catch (IOException e) { + return null; + } + } + public static List parseArray(String text, Class clazz) { if (StrUtil.isEmpty(text)) { return new ArrayList<>(); diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/package-info.java b/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/package-info.java deleted file mode 100644 index 7ba720a9d3..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 占位 - */ -package cn.iocoder.yudao.framework.dict.core; diff --git a/yudao-framework/yudao-spring-boot-starter-biz-ip/src/main/java/cn/iocoder/yudao/framework/ip/core/utils/AreaUtils.java b/yudao-framework/yudao-spring-boot-starter-biz-ip/src/main/java/cn/iocoder/yudao/framework/ip/core/utils/AreaUtils.java index 5a7340095b..8e27a31acb 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-ip/src/main/java/cn/iocoder/yudao/framework/ip/core/utils/AreaUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-ip/src/main/java/cn/iocoder/yudao/framework/ip/core/utils/AreaUtils.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.function.Function; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.findFirst; /** * 区域工具类 @@ -74,6 +75,57 @@ public class AreaUtils { return areas.get(id); } + /** + * 获得指定区域对应的编号 + * + * @param pathStr 区域路径,例如说:河南省/石家庄市/新华区 + * @return 区域 + */ + public static Area parseArea(String pathStr) { + String[] paths = pathStr.split("/"); + Area area = null; + for (String path : paths) { + if (area == null) { + area = findFirst(areas.values(), item -> item.getName().equals(path)); + } else { + area = findFirst(area.getChildren(), item -> item.getName().equals(path)); + } + } + return area; + } + + /** + * 获取所有节点的全路径名称如:河南省/石家庄市/新华区 + * + * @param areas 地区树 + * @return 所有节点的全路径名称 + */ + public static List getAreaNodePathList(List areas) { + List paths = new ArrayList<>(); + areas.forEach(area -> getAreaNodePathList(area, "", paths)); + return paths; + } + + /** + * 构建一棵树的所有节点的全路径名称,并将其存储为 "祖先/父级/子级" 的形式 + * + * @param node 父节点 + * @param path 全路径名称 + * @param paths 全路径名称列表,省份/城市/地区 + */ + private static void getAreaNodePathList(Area node, String path, List paths) { + if (node == null) { + return; + } + // 构建当前节点的路径 + String currentPath = path.isEmpty() ? node.getName() : path + "/" + node.getName(); + paths.add(currentPath); + // 递归遍历子节点 + for (Area child : node.getChildren()) { + getAreaNodePathList(child, currentPath, paths); + } + } + /** * 格式化区域 * @@ -88,13 +140,13 @@ public class AreaUtils { * 格式化区域 * * 例如说: - * 1. id = “静安区”时:上海 上海市 静安区 - * 2. id = “上海市”时:上海 上海市 - * 3. id = “上海”时:上海 - * 4. id = “美国”时:美国 + * 1. id = “静安区”时:上海 上海市 静安区 + * 2. id = “上海市”时:上海 上海市 + * 3. id = “上海”时:上海 + * 4. id = “美国”时:美国 * 当区域在中国时,默认不显示中国 * - * @param id 区域编号 + * @param id 区域编号 * @param separator 分隔符 * @return 格式化后的区域 */ diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/pom.xml b/yudao-framework/yudao-spring-boot-starter-captcha/pom.xml deleted file mode 100644 index 654d756696..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/pom.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - cn.iocoder.boot - yudao-framework - ${revision} - - 4.0.0 - yudao-spring-boot-starter-captcha - jar - - ${project.artifactId} - 验证码拓展 - 1. 基于 aj-captcha 实现滑块验证码,文档:https://ajcaptcha.beliefteam.cn/captcha-doc/ - - - - - com.xingyuv - spring-boot-starter-captcha-plus - - - - org.springframework.boot - spring-boot-starter - - - - - cn.iocoder.boot - yudao-spring-boot-starter-redis - - - - - diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/config/YudaoCaptchaConfiguration.java b/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/config/YudaoCaptchaConfiguration.java deleted file mode 100644 index 21f19efe63..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/config/YudaoCaptchaConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -package cn.iocoder.yudao.framework.captcha.config; - -import cn.iocoder.yudao.framework.captcha.core.service.RedisCaptchaServiceImpl; -import com.xingyuv.captcha.properties.AjCaptchaProperties; -import com.xingyuv.captcha.service.CaptchaCacheService; -import com.xingyuv.captcha.service.impl.CaptchaServiceFactory; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.data.redis.core.StringRedisTemplate; - -import javax.annotation.Resource; - -@AutoConfiguration -public class YudaoCaptchaConfiguration { - - @Resource - private StringRedisTemplate stringRedisTemplate; - - @Bean - public CaptchaCacheService captchaCacheService(AjCaptchaProperties config) { - // 缓存类型 redis/local/.... - CaptchaCacheService ret = CaptchaServiceFactory.getCache(config.getCacheType().name()); - if (ret instanceof RedisCaptchaServiceImpl) { - ((RedisCaptchaServiceImpl) ret).setStringRedisTemplate(stringRedisTemplate); - } - return ret; - } - -} diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/enums/CaptchaRedisKeyConstants.java b/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/enums/CaptchaRedisKeyConstants.java deleted file mode 100644 index 5fa5b58584..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/enums/CaptchaRedisKeyConstants.java +++ /dev/null @@ -1,28 +0,0 @@ -package cn.iocoder.yudao.framework.captcha.core.enums; - -/** - * 验证码 Redis Key 枚举类 - * - * @author 芋道源码 - */ -public interface CaptchaRedisKeyConstants { - - /** - * 验证码的请求限流 - * - * KEY 格式:AJ.CAPTCHA.REQ.LIMIT-%s-%s - * VALUE 数据类型:String // 例如说:验证失败 5 次,get 接口锁定 - * 过期时间:60 秒 - */ - String AJ_CAPTCHA_REQ_LIMIT = "AJ.CAPTCHA.REQ.LIMIT-%s-%s"; - - /** - * 验证码的坐标 - * - * KEY 格式:RUNNING:CAPTCHA:%s // AbstractCaptchaService.REDIS_CAPTCHA_KEY - * VALUE 数据类型:String // PointVO.class {"secretKey":"PP1w2Frr2KEejD2m","x":162,"y":5} - * 过期时间:120 秒 - */ - String AJ_CAPTCHA_RUNNING = "RUNNING:CAPTCHA:%s"; - -} diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/package-info.java b/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/package-info.java deleted file mode 100644 index e78d9eab22..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/package-info.java +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 验证码拓展 - * 1. 基于 aj-captcha 实现滑块验证码,文档:https://ajcaptcha.beliefteam.cn/captcha-doc/ - * - * @author 星语 - */ -package cn.iocoder.yudao.framework.captcha; diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService b/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService deleted file mode 100644 index afede97de0..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService +++ /dev/null @@ -1 +0,0 @@ -cn.iocoder.yudao.framework.captcha.core.service.RedisCaptchaServiceImpl diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 8411d2cc34..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -cn.iocoder.yudao.framework.captcha.config.YudaoCaptchaConfiguration \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-excel/pom.xml b/yudao-framework/yudao-spring-boot-starter-excel/pom.xml index 5280f72f76..3da22b0527 100644 --- a/yudao-framework/yudao-spring-boot-starter-excel/pom.xml +++ b/yudao-framework/yudao-spring-boot-starter-excel/pom.xml @@ -21,11 +21,17 @@ yudao-common + + + org.springframework.boot + spring-boot-starter + + cn.iocoder.boot - yudao-spring-boot-starter-biz-dict - true + yudao-module-system-api + ${revision} @@ -46,6 +52,24 @@ com.alibaba easyexcel + + + com.google.guava + guava + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-ip + true + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java similarity index 88% rename from yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java rename to yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java index 1b29fd165e..ffa2b5e28b 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/config/YudaoDictAutoConfiguration.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.framework.dict.config; -import cn.iocoder.yudao.framework.dict.core.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; import cn.iocoder.yudao.module.system.api.dict.DictDataApi; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.Bean; diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java similarity index 78% rename from yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java rename to yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java index 94a787e9db..9bf8b1cebe 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/package-info.java @@ -1,5 +1,5 @@ /** - * 字典数据模块,提供 {@link cn.iocoder.yudao.framework.dict.core.util.DictFrameworkUtils} 工具类 + * 字典数据模块,提供 {@link cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils} 工具类 * * 通过将字典缓存在内存中,保证性能 */ diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtils.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/util/DictFrameworkUtils.java similarity index 98% rename from yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtils.java rename to yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/util/DictFrameworkUtils.java index d0a765b92d..7c7aea8072 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/dict/util/DictFrameworkUtils.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.dict.core.util; +package cn.iocoder.yudao.framework.dict.util; import cn.hutool.core.util.ObjectUtil; import cn.iocoder.yudao.framework.common.core.KeyValue; diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/AreaConvert.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/AreaConvert.java new file mode 100644 index 0000000000..9778b17aea --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/AreaConvert.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import cn.iocoder.yudao.framework.ip.core.Area; +import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.ReadCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 数据地区转换器 + * + * @author HUIHUI + */ +@Slf4j +public class AreaConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 解析地区编号 + String label = readCellData.getStringValue(); + Area area = AreaUtils.parseArea(label); + if (area == null) { + log.error("[convertToJavaData][label({}) 解析不掉]", label); + return null; + } + // 将 value 转换成对应的属性 + Class fieldClazz = contentProperty.getField().getType(); + return Convert.convert(fieldClazz, area.getId()); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/DictConvert.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/DictConvert.java index 182c83a72b..b5bc085d5b 100644 --- a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/DictConvert.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/DictConvert.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.framework.excel.core.convert; import cn.hutool.core.convert.Convert; -import cn.iocoder.yudao.framework.dict.core.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; import com.alibaba.excel.converters.Converter; import com.alibaba.excel.enums.CellDataTypeEnum; diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/enums/ExcelColumn.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/enums/ExcelColumn.java new file mode 100644 index 0000000000..dd8a8374c1 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/enums/ExcelColumn.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.framework.excel.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +// TODO @puhui999:列表有办法通过 field name 么?主要考虑一个点,可能导入模版的顺序可能会变 +/** + * Excel 列名枚举 + * 默认枚举 26 列列名如果有需求更多的列名请自行补充 + * + * @author HUIHUI + */ +@Getter +@AllArgsConstructor +public enum ExcelColumn { + + A(0), B(1), C(2), D(3), E(4), F(5), G(6), H(7), I(8), + J(9), K(10), L(11), M(12), N(13), O(14), P(15), Q(16), + R(17), S(18), T(19), U(20), V(21), W(22), X(23), Y(24), + Z(25); + + /** + * 列索引 + */ + private final int colNum; + +} diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/SelectSheetWriteHandler.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/SelectSheetWriteHandler.java new file mode 100644 index 0000000000..38d01bd879 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/SelectSheetWriteHandler.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.framework.excel.core.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.excel.core.enums.ExcelColumn; +import com.alibaba.excel.write.handler.SheetWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import org.apache.poi.hssf.usermodel.HSSFDataValidation; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddressList; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 基于固定 sheet 实现下拉框 + * + * @author HUIHUI + */ +public class SelectSheetWriteHandler implements SheetWriteHandler { + + /** + * 数据起始行从 0 开始 + * + * 约定:本项目第一行有标题所以从 1 开始如果您的 Excel 有多行标题请自行更改 + */ + public static final int FIRST_ROW = 1; + /** + * 下拉列需要创建下拉框的行数,默认两千行如需更多请自行调整 + */ + public static final int LAST_ROW = 2000; + + private static final String DICT_SHEET_NAME = "字典sheet"; + + // TODO @puhui999:Map> 可以么?之前用 keyvalue 的原因,返回给前端,无法用 linkedhashmap,默认 key 会乱序 + private final List>> selectMap; + + public SelectSheetWriteHandler(List>> selectMap) { + if (CollUtil.isEmpty(selectMap)) { + this.selectMap = null; + return; + } + // 校验一下 key 是否唯一 + Map nameCounts = selectMap.stream() + .collect(Collectors.groupingBy(item -> item.getKey().name(), Collectors.counting())); + Assert.isFalse(nameCounts.entrySet().stream().allMatch(entry -> entry.getValue() > 1), "下拉数据 key 重复请排查!!!"); + + selectMap.sort(Comparator.comparing(item -> item.getValue().size())); // 升序不然创建下拉会报错 + this.selectMap = selectMap; + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + if (CollUtil.isEmpty(selectMap)) { + return; + } + + // 1. 获取相应操作对象 + DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper(); // 需要设置下拉框的 sheet 页的数据验证助手 + Workbook workbook = writeWorkbookHolder.getWorkbook(); // 获得工作簿 + + // 2. 创建数据字典的 sheet 页 + Sheet dictSheet = workbook.createSheet(DICT_SHEET_NAME); + for (KeyValue> keyValue : selectMap) { + int rowLength = keyValue.getValue().size(); + // 2.1 设置字典 sheet 页的值 每一列一个字典项 + for (int i = 0; i < rowLength; i++) { + Row row = dictSheet.getRow(i); + if (row == null) { + row = dictSheet.createRow(i); + } + row.createCell(keyValue.getKey().getColNum()).setCellValue(keyValue.getValue().get(i)); + } + // 2.2 设置单元格下拉选择 + setColumnSelect(writeSheetHolder, workbook, helper, keyValue); + } + } + + /** + * 设置单元格下拉选择 + */ + private static void setColumnSelect(WriteSheetHolder writeSheetHolder, Workbook workbook, DataValidationHelper helper, + KeyValue> keyValue) { + // 1.1 创建可被其他单元格引用的名称 + Name name = workbook.createName(); + String excelColumn = keyValue.getKey().name(); + // 1.2 下拉框数据来源 eg:字典sheet!$B1:$B2 + String refers = DICT_SHEET_NAME + "!$" + excelColumn + "$1:$" + excelColumn + "$" + keyValue.getValue().size(); + name.setNameName("dict" + keyValue.getKey()); // 设置名称的名字 + name.setRefersToFormula(refers); // 设置公式 + + // 2.1 设置约束 + DataValidationConstraint constraint = helper.createFormulaListConstraint("dict" + keyValue.getKey()); // 设置引用约束 + // 设置下拉单元格的首行、末行、首列、末列 + CellRangeAddressList rangeAddressList = new CellRangeAddressList(FIRST_ROW, LAST_ROW, + keyValue.getKey().getColNum(), keyValue.getKey().getColNum()); + DataValidation validation = helper.createValidation(constraint, rangeAddressList); + if (validation instanceof HSSFDataValidation) { + validation.setSuppressDropDownArrow(false); + } else { + validation.setSuppressDropDownArrow(true); + validation.setShowErrorBox(true); + } + // 2.2 阻止输入非下拉框的值 + validation.setErrorStyle(DataValidation.ErrorStyle.STOP); + validation.createErrorBox("提示", "此值不存在于下拉选择中!"); + // 2.3 添加下拉框约束 + writeSheetHolder.getSheet().addValidationData(validation); + } + +} \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java index 7fbf0e7de6..c33cd8bf64 100644 --- a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java @@ -1,5 +1,8 @@ package cn.iocoder.yudao.framework.excel.core.util; +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.excel.core.enums.ExcelColumn; +import cn.iocoder.yudao.framework.excel.core.handler.SelectSheetWriteHandler; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.converters.longconverter.LongStringConverter; import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; @@ -21,20 +24,37 @@ public class ExcelUtils { /** * 将列表以 Excel 响应给前端 * - * @param response 响应 - * @param filename 文件名 + * @param response 响应 + * @param filename 文件名 * @param sheetName Excel sheet 名 - * @param head Excel head 头 - * @param data 数据列表哦 - * @param 泛型,保证 head 和 data 类型的一致性 + * @param head Excel head 头 + * @param data 数据列表哦 + * @param 泛型,保证 head 和 data 类型的一致性 * @throws IOException 写入失败的情况 */ public static void write(HttpServletResponse response, String filename, String sheetName, Class head, List data) throws IOException { + write(response, filename, sheetName, head, data, null); + } + + /** + * 将列表以 Excel 响应给前端 + * + * @param response 响应 + * @param filename 文件名 + * @param sheetName Excel sheet 名 + * @param head Excel head 头 + * @param data 数据列表哦 + * @param selectMap 下拉选择数据 Map<下拉所对应的列表名,下拉数据> + * @throws IOException 写入失败的情况 + */ + public static void write(HttpServletResponse response, String filename, String sheetName, + Class head, List data, List>> selectMap) throws IOException { // 输出 Excel EasyExcel.write(response.getOutputStream(), head) .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度 + .registerWriteHandler(new SelectSheetWriteHandler(selectMap)) // 基于固定 sheet 实现下拉框 .registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度 .sheet(sheetName).doWrite(data); // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 @@ -43,7 +63,7 @@ public class ExcelUtils { } public static List read(MultipartFile file, Class head) throws IOException { - return EasyExcel.read(file.getInputStream(), head, null) + return EasyExcel.read(file.getInputStream(), head, null) .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 .doReadAllSync(); } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-framework/yudao-spring-boot-starter-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-biz-dict/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to yudao-framework/yudao-spring-boot-starter-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtilsTest.java new file mode 100644 index 0000000000..3bce63c510 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/dict/core/util/DictFrameworkUtilsTest.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.framework.dict.core.util; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import cn.iocoder.yudao.module.system.api.dict.dto.DictDataRespDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * {@link DictFrameworkUtils} 的单元测试 + */ +public class DictFrameworkUtilsTest extends BaseMockitoUnitTest { + + @Mock + private DictDataApi dictDataApi; + + @BeforeEach + public void setUp() { + DictFrameworkUtils.init(dictDataApi); + } + + @Test + public void testGetDictDataLabel() { + // mock 数据 + DictDataRespDTO dataRespDTO = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.getDictData(dataRespDTO.getDictType(), dataRespDTO.getValue())).thenReturn(dataRespDTO); + + // 断言返回值 + assertEquals(dataRespDTO.getLabel(), DictFrameworkUtils.getDictDataLabel(dataRespDTO.getDictType(), dataRespDTO.getValue())); + } + + @Test + public void testParseDictDataValue() { + // mock 数据 + DictDataRespDTO resp = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.parseDictData(resp.getDictType(), resp.getLabel())).thenReturn(resp); + + // 断言返回值 + assertEquals(resp.getValue(), DictFrameworkUtils.parseDictDataValue(resp.getDictType(), resp.getLabel())); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/config/YudaoFileAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/config/YudaoFileAutoConfiguration.java deleted file mode 100644 index e22ee3dd09..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/config/YudaoFileAutoConfiguration.java +++ /dev/null @@ -1,21 +0,0 @@ -package cn.iocoder.yudao.framework.file.config; - -import cn.iocoder.yudao.framework.file.core.client.FileClientFactory; -import cn.iocoder.yudao.framework.file.core.client.FileClientFactoryImpl; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; - -/** - * 文件配置类 - * - * @author 芋道源码 - */ -@AutoConfiguration -public class YudaoFileAutoConfiguration { - - @Bean - public FileClientFactory fileClientFactory() { - return new FileClientFactoryImpl(); - } - -} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClient.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClient.java deleted file mode 100644 index 1e2c082fee..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClient.java +++ /dev/null @@ -1,48 +0,0 @@ -package cn.iocoder.yudao.framework.file.core.client.db; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient; - -/** - * 基于 DB 存储的文件客户端的配置类 - * - * @author 芋道源码 - */ -public class DBFileClient extends AbstractFileClient { - - private DBFileContentFrameworkDAO dao; - - public DBFileClient(Long id, DBFileClientConfig config) { - super(id, config); - } - - @Override - protected void doInit() { - } - - @Override - public String upload(byte[] content, String path, String type) { - getDao().insert(getId(), path, content); - // 拼接返回路径 - return super.formatFileUrl(config.getDomain(), path); - } - - @Override - public void delete(String path) { - getDao().delete(getId(), path); - } - - @Override - public byte[] getContent(String path) { - return getDao().selectContent(getId(), path); - } - - private DBFileContentFrameworkDAO getDao() { - // 延迟获取,因为 SpringUtil 初始化太慢 - if (dao == null) { - dao = SpringUtil.getBean(DBFileContentFrameworkDAO.class); - } - return dao; - } - -} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileContentFrameworkDAO.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileContentFrameworkDAO.java deleted file mode 100644 index 9423e065da..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileContentFrameworkDAO.java +++ /dev/null @@ -1,36 +0,0 @@ -package cn.iocoder.yudao.framework.file.core.client.db; - -/** - * 文件内容 Framework DAO 接口 - * - * @author 芋道源码 - */ -public interface DBFileContentFrameworkDAO { - - /** - * 插入文件内容 - * - * @param configId 配置编号 - * @param path 路径 - * @param content 内容 - */ - void insert(Long configId, String path, byte[] content); - - /** - * 删除文件内容 - * - * @param configId 配置编号 - * @param path 路径 - */ - void delete(Long configId, String path); - - /** - * 获得文件内容 - * - * @param configId 配置编号 - * @param path 路径 - * @return 内容 - */ - byte[] selectContent(Long configId, String path); - -} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/enums/FileStorageEnum.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/enums/FileStorageEnum.java deleted file mode 100644 index 03c6ed8a7d..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/enums/FileStorageEnum.java +++ /dev/null @@ -1,55 +0,0 @@ -package cn.iocoder.yudao.framework.file.core.enums; - -import cn.hutool.core.util.ArrayUtil; -import cn.iocoder.yudao.framework.file.core.client.FileClient; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.db.DBFileClient; -import cn.iocoder.yudao.framework.file.core.client.db.DBFileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.ftp.FtpFileClient; -import cn.iocoder.yudao.framework.file.core.client.ftp.FtpFileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.local.LocalFileClient; -import cn.iocoder.yudao.framework.file.core.client.local.LocalFileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.s3.S3FileClient; -import cn.iocoder.yudao.framework.file.core.client.s3.S3FileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.sftp.SftpFileClient; -import cn.iocoder.yudao.framework.file.core.client.sftp.SftpFileClientConfig; -import lombok.AllArgsConstructor; -import lombok.Getter; - -/** - * 文件存储器枚举 - * - * @author 芋道源码 - */ -@AllArgsConstructor -@Getter -public enum FileStorageEnum { - - DB(1, DBFileClientConfig.class, DBFileClient.class), - - LOCAL(10, LocalFileClientConfig.class, LocalFileClient.class), - FTP(11, FtpFileClientConfig.class, FtpFileClient.class), - SFTP(12, SftpFileClientConfig.class, SftpFileClient.class), - - S3(20, S3FileClientConfig.class, S3FileClient.class), - ; - - /** - * 存储器 - */ - private final Integer storage; - - /** - * 配置类 - */ - private final Class configClass; - /** - * 客户端类 - */ - private final Class clientClass; - - public static FileStorageEnum getByStorage(Integer storage) { - return ArrayUtil.firstMatch(o -> o.getStorage().equals(storage), values()); - } - -} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-framework/yudao-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 9e5f422391..0000000000 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -cn.iocoder.yudao.framework.file.config.YudaoFileAutoConfiguration \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java index 028ac8d71f..444f7856ed 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java @@ -38,10 +38,6 @@ public class BannerApplicationRunner implements ApplicationRunner { if (isNotPresent("cn.iocoder.yudao.framework.flowable.config.YudaoFlowableConfiguration")) { System.out.println("[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]"); } - // 微信公众号 - if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) { - System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]"); - } // 商城系统 if (isNotPresent("cn.iocoder.yudao.module.trade.framework.web.config.TradeWebConfiguration")) { System.out.println("[商城系统 yudao-module-mall - 已禁用][参考 https://doc.iocoder.cn/mall/build/ 开启]"); @@ -50,6 +46,14 @@ public class BannerApplicationRunner implements ApplicationRunner { if (isNotPresent("cn.iocoder.yudao.module.erp.framework.web.config.ErpWebConfiguration")) { System.out.println("[ERP 系统 yudao-module-erp - 已禁用][参考 https://doc.iocoder.cn/erp/build/ 开启]"); } + // CRM 系统 + if (isNotPresent("cn.iocoder.yudao.module.crm.framework.web.config.CrmWebConfiguration")) { + System.out.println("[CRM 系统 yudao-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]"); + } + // 微信公众号 + if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) { + System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]"); + } // 支付平台 if (isNotPresent("cn.iocoder.yudao.module.pay.framework.pay.config.PayConfiguration")) { System.out.println("[支付系统 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index c494bfefb3..740eb1a0cd 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -319,7 +319,13 @@ public class GlobalExceptionHandler { return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://doc.iocoder.cn/erp/build/ 开启]"); } - // 6. 支付平台 + // 6. CRM 系统 + if (message.contains("crm_")) { + log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://doc.iocoder.cn/crm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://doc.iocoder.cn/crm/build/ 开启]"); + } + // 7. 支付平台 if (message.contains("pay_")) { log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://doc.iocoder.cn/pay/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), diff --git a/yudao-module-bpm/pom.xml b/yudao-module-bpm/pom.xml new file mode 100644 index 0000000000..491c99771a --- /dev/null +++ b/yudao-module-bpm/pom.xml @@ -0,0 +1,28 @@ + + + + yudao + cn.iocoder.boot + ${revision} + + 4.0.0 + + yudao-module-bpm-api + yudao-module-bpm-biz + yudao-spring-boot-starter-flowable + + yudao-module-bpm + pom + + ${project.artifactId} + + bpm 包下,业务流程管理(Business Process Management),我们放工作流的功能。 + 例如说:流程定义、表单配置、审核中心(我的申请、我的待办、我的已办)等等 + bpm 解释:https://baike.baidu.com/item/BPM/1933 + + 工作流基于 Flowable 6 实现,分成流程定义、流程表单、流程实例、流程任务等功能模块。 + + + diff --git a/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEvent.java b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEvent.java new file mode 100644 index 0000000000..8d0b2de551 --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEvent.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.bpm.event; + +import lombok.Data; +import org.springframework.context.ApplicationEvent; + +import javax.validation.constraints.NotNull; + +/** + * 流程实例的结果发生变化的 Event + * 定位:由于额外增加了 {@link BpmProcessInstanceExtDO#getResult()} 结果,所以增加该事件 + * + * @author 芋道源码 + */ +@SuppressWarnings("ALL") +@Data +public class BpmProcessInstanceResultEvent extends ApplicationEvent { + + /** + * 流程实例的编号 + */ + @NotNull(message = "流程实例的编号不能为空") + private String id; + /** + * 流程实例的 key + */ + @NotNull(message = "流程实例的 key 不能为空") + private String processDefinitionKey; + /** + * 流程实例的结果 + */ + @NotNull(message = "流程实例的结果不能为空") + private Integer result; + /** + * 流程实例对应的业务标识 + * 例如说,请假 + */ + private String businessKey; + + public BpmProcessInstanceResultEvent(Object source) { + super(source); + } + +} diff --git a/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEventListener.java b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEventListener.java new file mode 100644 index 0000000000..bff99a8c19 --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/event/BpmProcessInstanceResultEventListener.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.bpm.event; + +import cn.hutool.core.util.StrUtil; +import org.springframework.context.ApplicationListener; + +/** + * {@link BpmProcessInstanceResultEvent} 的监听器 + * + * @author 芋道源码 + */ +public abstract class BpmProcessInstanceResultEventListener + implements ApplicationListener { + + @Override + public final void onApplicationEvent(BpmProcessInstanceResultEvent event) { + if (!StrUtil.equals(event.getProcessDefinitionKey(), getProcessDefinitionKey())) { + return; + } + onEvent(event); + } + + /** + * @return 返回监听的流程定义 Key + */ + protected abstract String getProcessDefinitionKey(); + + /** + * 处理事件 + * + * @param event 事件 + */ + protected abstract void onEvent(BpmProcessInstanceResultEvent event); + +} diff --git a/yudao-module-bpm/yudao-module-bpm-biz/pom.xml b/yudao-module-bpm/yudao-module-bpm-biz/pom.xml new file mode 100644 index 0000000000..f1ac4c0ee8 --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-biz/pom.xml @@ -0,0 +1,74 @@ + + + + cn.iocoder.boot + yudao-module-bpm + ${revision} + + 4.0.0 + yudao-module-bpm-biz + + ${project.artifactId} + + bpm 包下,业务流程管理(Business Process Management),我们放工作流的功能,基于 Flowable 6 版本实现。 + 例如说:流程定义、表单配置、审核中心(我的申请、我的待办、我的已办)等等 + + + + cn.iocoder.boot + yudao-module-bpm-api + ${revision} + + + cn.iocoder.boot + yudao-module-system-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-data-permission + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-flowable + ${revision} + + + diff --git a/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/task/BpmProcessInstanceConvert.java b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/task/BpmProcessInstanceConvert.java new file mode 100644 index 0000000000..41f2184c5c --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/task/BpmProcessInstanceConvert.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.module.bpm.convert.task; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstancePageItemRespVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceRespVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionExtDO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.task.BpmProcessInstanceExtDO; +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEvent; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenProcessInstanceApproveReqDTO; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenProcessInstanceRejectReqDTO; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.factory.Mappers; + +import java.util.List; +import java.util.Map; + +/** + * 流程实例 Convert + * + * @author 芋道源码 + */ +@Mapper(uses = DateUtils.class) +public interface BpmProcessInstanceConvert { + + BpmProcessInstanceConvert INSTANCE = Mappers.getMapper(BpmProcessInstanceConvert.class); + + default PageResult convertPage(PageResult page, + Map> taskMap) { + List list = convertList(page.getList()); + list.forEach(respVO -> respVO.setTasks(convertList2(taskMap.get(respVO.getId())))); + return new PageResult<>(list, page.getTotal()); + } + + List convertList(List list); + + @Mapping(source = "processInstanceId", target = "id") + BpmProcessInstancePageItemRespVO convert(BpmProcessInstanceExtDO bean); + + List convertList2(List tasks); + + default BpmProcessInstanceRespVO convert2(HistoricProcessInstance processInstance, BpmProcessInstanceExtDO processInstanceExt, + ProcessDefinition processDefinition, BpmProcessDefinitionExtDO processDefinitionExt, + String bpmnXml, AdminUserRespDTO startUser, DeptRespDTO dept) { + BpmProcessInstanceRespVO respVO = convert2(processInstance); + copyTo(processInstanceExt, respVO); + // definition + respVO.setProcessDefinition(convert2(processDefinition)); + copyTo(processDefinitionExt, respVO.getProcessDefinition()); + respVO.getProcessDefinition().setBpmnXml(bpmnXml); + // user + if (startUser != null) { + respVO.setStartUser(convert2(startUser)); + if (dept != null) { + respVO.getStartUser().setDeptName(dept.getName()); + } + } + return respVO; + } + + BpmProcessInstanceRespVO convert2(HistoricProcessInstance bean); + + @Mapping(source = "from.id", target = "to.id", ignore = true) + void copyTo(BpmProcessInstanceExtDO from, @MappingTarget BpmProcessInstanceRespVO to); + + BpmProcessInstanceRespVO.ProcessDefinition convert2(ProcessDefinition bean); + + @Mapping(source = "from.id", target = "to.id", ignore = true) + void copyTo(BpmProcessDefinitionExtDO from, @MappingTarget BpmProcessInstanceRespVO.ProcessDefinition to); + + BpmProcessInstanceRespVO.User convert2(AdminUserRespDTO bean); + + default BpmProcessInstanceResultEvent convert(Object source, HistoricProcessInstance instance, Integer result) { + BpmProcessInstanceResultEvent event = new BpmProcessInstanceResultEvent(source); + event.setId(instance.getId()); + event.setProcessDefinitionKey(instance.getProcessDefinitionKey()); + event.setBusinessKey(instance.getBusinessKey()); + event.setResult(result); + return event; + } + + default BpmProcessInstanceResultEvent convert(Object source, ProcessInstance instance, Integer result) { + BpmProcessInstanceResultEvent event = new BpmProcessInstanceResultEvent(source); + event.setId(instance.getId()); + event.setProcessDefinitionKey(instance.getProcessDefinitionKey()); + event.setBusinessKey(instance.getBusinessKey()); + event.setResult(result); + return event; + } + + default BpmMessageSendWhenProcessInstanceApproveReqDTO convert2ApprovedReq(ProcessInstance instance){ + return new BpmMessageSendWhenProcessInstanceApproveReqDTO() + .setStartUserId(NumberUtils.parseLong(instance.getStartUserId())) + .setProcessInstanceId(instance.getId()) + .setProcessInstanceName(instance.getName()); + } + + default BpmMessageSendWhenProcessInstanceRejectReqDTO convert2RejectReq(ProcessInstance instance, String reason) { + return new BpmMessageSendWhenProcessInstanceRejectReqDTO() + .setProcessInstanceName(instance.getName()) + .setProcessInstanceId(instance.getId()) + .setReason(reason) + .setStartUserId(NumberUtils.parseLong(instance.getStartUserId())); + } + +} diff --git a/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/framework/bpm/core/event/BpmProcessInstanceResultEventPublisher.java b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/framework/bpm/core/event/BpmProcessInstanceResultEventPublisher.java new file mode 100644 index 0000000000..319d923c7a --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/framework/bpm/core/event/BpmProcessInstanceResultEventPublisher.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.bpm.framework.bpm.core.event; + +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEvent; +import lombok.AllArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.validation.annotation.Validated; + +import javax.validation.Valid; + +/** + * {@link BpmProcessInstanceResultEvent} 的生产者 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Validated +public class BpmProcessInstanceResultEventPublisher { + + private final ApplicationEventPublisher publisher; + + public void sendProcessInstanceResultEvent(@Valid BpmProcessInstanceResultEvent event) { + publisher.publishEvent(event); + } + +} diff --git a/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveResultListener.java b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveResultListener.java new file mode 100644 index 0000000000..f24a18ad01 --- /dev/null +++ b/yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveResultListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.bpm.service.oa.listener; + +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEvent; +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEventListener; +import cn.iocoder.yudao.module.bpm.service.oa.BpmOALeaveService; +import cn.iocoder.yudao.module.bpm.service.oa.BpmOALeaveServiceImpl; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * OA 请假单的结果的监听器实现类 + * + * @author 芋道源码 + */ +@Component +public class BpmOALeaveResultListener extends BpmProcessInstanceResultEventListener { + + @Resource + private BpmOALeaveService leaveService; + + @Override + protected String getProcessDefinitionKey() { + return BpmOALeaveServiceImpl.PROCESS_KEY; + } + + @Override + protected void onEvent(BpmProcessInstanceResultEvent event) { + leaveService.updateLeaveResult(Long.parseLong(event.getBusinessKey()), event.getResult()); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-biz-dict/pom.xml b/yudao-module-bpm/yudao-spring-boot-starter-flowable/pom.xml similarity index 51% rename from yudao-framework/yudao-spring-boot-starter-biz-dict/pom.xml rename to yudao-module-bpm/yudao-spring-boot-starter-flowable/pom.xml index 01d6cd614f..ae0ed46eef 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-dict/pom.xml +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/pom.xml @@ -4,16 +4,12 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> cn.iocoder.boot - yudao-framework + yudao-module-bpm ${revision} 4.0.0 - yudao-spring-boot-starter-biz-dict - jar - ${project.artifactId} - 字典类型、数据 - https://github.com/YunaiV/ruoyi-vue-pro + yudao-spring-boot-starter-flowable @@ -21,24 +17,21 @@ yudao-common - - - org.springframework.boot - spring-boot-starter - - - + cn.iocoder.boot - yudao-module-system-api - ${revision} + yudao-spring-boot-starter-security - + - com.google.guava - guava + org.flowable + flowable-spring-boot-starter-process + + + org.flowable + flowable-spring-boot-starter-actuator - + diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/config/YudaoFlowableConfiguration.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/config/YudaoFlowableConfiguration.java new file mode 100644 index 0000000000..859eca510d --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/config/YudaoFlowableConfiguration.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.framework.flowable.config; + +import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum; +import cn.iocoder.yudao.framework.flowable.core.web.FlowableWebFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.AsyncListenableTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@AutoConfiguration +public class YudaoFlowableConfiguration { + + /** + * 参考 {@link org.flowable.spring.boot.FlowableJobConfiguration} 类,创建对应的 AsyncListenableTaskExecutor Bean + * + * 如果不创建,会导致项目启动时,Flowable 报错的问题 + */ + @Bean(name = "applicationTaskExecutor") + @ConditionalOnMissingBean(name = "applicationTaskExecutor") + public AsyncListenableTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("flowable-task-Executor-"); + executor.setAwaitTerminationSeconds(30); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAllowCoreThreadTimeOut(true); + executor.initialize(); + return executor; + } + + /** + * 配置 flowable Web 过滤器 + */ + @Bean + public FilterRegistrationBean flowableWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new FlowableWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.FLOWABLE_FILTER); + return registrationBean; + } + +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/context/FlowableContextHolder.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/context/FlowableContextHolder.java new file mode 100644 index 0000000000..efc6d5340d --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/context/FlowableContextHolder.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.framework.flowable.core.context; + +import cn.hutool.core.collection.CollUtil; +import com.alibaba.ttl.TransmittableThreadLocal; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 工作流--用户用到的上下文相关信息 + */ +public class FlowableContextHolder { + + private static final ThreadLocal>> ASSIGNEE = new TransmittableThreadLocal<>(); + + /** + * 通过流程任务的定义 key ,拿到提前选好的审批人 + * 此方法目的:首次创建流程实例时,数据库中还查询不到 assignee 字段,所以存入上下文中获取 + * + * @param taskDefinitionKey 流程任务 key + * @return 审批人 ID 集合 + */ + public static List getAssigneeByTaskDefinitionKey(String taskDefinitionKey) { + if (CollUtil.isNotEmpty(ASSIGNEE.get())) { + return ASSIGNEE.get().get(taskDefinitionKey); + } + return Collections.emptyList(); + } + + /** + * 存入提前选好的审批人到上下文线程变量中 + * + * @param assignee 流程任务 key -> 审批人 ID 炅和 + */ + public static void setAssignee(Map> assignee) { + ASSIGNEE.set(assignee); + } + +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/enums/BpmnModelConstants.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/enums/BpmnModelConstants.java new file mode 100644 index 0000000000..c3cce4272c --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/enums/BpmnModelConstants.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.framework.flowable.core.enums; + +/** + * 流程常量信息 + */ +public interface BpmnModelConstants { + + String BPMN_FILE_SUFFIX = ".bpmn"; + + /** + * BPMN 中的命名空间 + * + * 这个东西有可能导致无法切换工作流程的实现 + */ + String NAMESPACE = "http://flowable.org/bpmn"; + + /** + * 自定义属性 dataType + */ + String PROCESS_CUSTOM_DATA_TYPE = "dataType"; + +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/package-info.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/package-info.java new file mode 100644 index 0000000000..de8d6279d7 --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/package-info.java @@ -0,0 +1 @@ +package cn.iocoder.yudao.framework.flowable.core; diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/BpmnModelUtils.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/BpmnModelUtils.java new file mode 100644 index 0000000000..7b23a467a0 --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/BpmnModelUtils.java @@ -0,0 +1,309 @@ +package cn.iocoder.yudao.framework.flowable.core.util; + +import cn.hutool.core.collection.CollUtil; +import org.flowable.bpmn.converter.BpmnXMLConverter; +import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.*; + +import java.util.*; + +/** + * 流程模型转操作工具类 + */ +public class BpmnModelUtils { + + /** + * 根据节点,获取入口连线 + * + * @param source 起始节点 + * @return 入口连线列表 + */ + public static List getElementIncomingFlows(FlowElement source) { + if (source instanceof FlowNode) { + return ((FlowNode) source).getIncomingFlows(); + } + return new ArrayList<>(); + } + + /** + * 根据节点,获取出口连线 + * + * @param source 起始节点 + * @return 出口连线列表 + */ + public static List getElementOutgoingFlows(FlowElement source) { + if (source instanceof FlowNode) { + return ((FlowNode) source).getOutgoingFlows(); + } + return new ArrayList<>(); + } + + /** + * 获取流程元素信息 + * + * @param model bpmnModel 对象 + * @param flowElementId 元素 ID + * @return 元素信息 + */ + public static FlowElement getFlowElementById(BpmnModel model, String flowElementId) { + Process process = model.getMainProcess(); + return process.getFlowElement(flowElementId); + } + + /** + * 获得 BPMN 流程中,指定的元素们 + * + * @param model + * @param clazz 指定元素。例如说,{@link UserTask}、{@link Gateway} 等等 + * @return 元素们 + */ + public static List getBpmnModelElements(BpmnModel model, Class clazz) { + List result = new ArrayList<>(); + model.getProcesses().forEach(process -> { + process.getFlowElements().forEach(flowElement -> { + if (flowElement.getClass().isAssignableFrom(clazz)) { + result.add((T) flowElement); + } + }); + }); + return result; + } + + /** + * 比较 两个bpmnModel 是否相同 + * @param oldModel 老的bpmn model + * @param newModel 新的bpmn model + */ + public static boolean equals(BpmnModel oldModel, BpmnModel newModel) { + // 由于 BpmnModel 未提供 equals 方法,所以只能转成字节数组,进行比较 + return Arrays.equals(getBpmnBytes(oldModel), getBpmnBytes(newModel)); + } + + /** + * 把 bpmnModel 转换成 byte[] + * @param model bpmnModel + */ + public static byte[] getBpmnBytes(BpmnModel model) { + if (model == null) { + return new byte[0]; + } + BpmnXMLConverter converter = new BpmnXMLConverter(); + return converter.convertToXML(model); + } + + // ========== 遍历相关的方法 ========== + + /** + * 找到 source 节点之前的所有用户任务节点 + * + * @param source 起始节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 已找到的用户任务节点 + * @return 用户任务节点 数组 + */ + public static List getPreviousUserTaskList(FlowElement source, Set hasSequenceFlow, List userTaskList) { + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + // 如果该节点为开始节点,且存在上级子节点,则顺着上级子节点继续迭代 + if (source instanceof StartEvent && source.getSubProcess() != null) { + userTaskList = getPreviousUserTaskList(source.getSubProcess(), hasSequenceFlow, userTaskList); + } + + // 根据类型,获取入口连线 + List sequenceFlows = getElementIncomingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 类型为用户节点,则新增父级节点 + if (sequenceFlow.getSourceFlowElement() instanceof UserTask) { + userTaskList.add((UserTask) sequenceFlow.getSourceFlowElement()); + } + // 类型为子流程,则添加子流程开始节点出口处相连的节点 + if (sequenceFlow.getSourceFlowElement() instanceof SubProcess) { + // 获取子流程用户任务节点 + List childUserTaskList = findChildProcessUserTaskList((StartEvent) ((SubProcess) sequenceFlow.getSourceFlowElement()).getFlowElements().toArray()[0], null, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + } + } + // 继续迭代 + userTaskList = getPreviousUserTaskList(sequenceFlow.getSourceFlowElement(), hasSequenceFlow, userTaskList); + } + return userTaskList; + } + + /** + * 迭代获取子流程用户任务节点 + * + * @param source 起始节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 需要撤回的用户任务列表 + * @return 用户任务节点 + */ + public static List findChildProcessUserTaskList(FlowElement source, Set hasSequenceFlow, List userTaskList) { + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + + // 根据类型,获取出口连线 + List sequenceFlows = getElementOutgoingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 如果为用户任务类型,且任务节点的 Key 正在运行的任务中存在,添加 + if (sequenceFlow.getTargetFlowElement() instanceof UserTask) { + userTaskList.add((UserTask) sequenceFlow.getTargetFlowElement()); + continue; + } + // 如果节点为子流程节点情况,则从节点中的第一个节点开始获取 + if (sequenceFlow.getTargetFlowElement() instanceof SubProcess) { + List childUserTaskList = findChildProcessUserTaskList((FlowElement) (((SubProcess) sequenceFlow.getTargetFlowElement()).getFlowElements().toArray()[0]), hasSequenceFlow, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + continue; + } + } + // 继续迭代 + userTaskList = findChildProcessUserTaskList(sequenceFlow.getTargetFlowElement(), hasSequenceFlow, userTaskList); + } + return userTaskList; + } + + + /** + * 迭代从后向前扫描,判断目标节点相对于当前节点是否是串行 + * 不存在直接回退到子流程中的情况,但存在从子流程出去到父流程情况 + * + * @param source 起始节点 + * @param target 目标节点 + * @param visitedElements 已经经过的连线的 ID,用于判断线路是否重复 + * @return 结果 + */ + public static boolean isSequentialReachable(FlowElement source, FlowElement target, Set visitedElements) { + visitedElements = visitedElements == null ? new HashSet<>() : visitedElements; + // 不能是开始事件和子流程 + if (source instanceof StartEvent && isInEventSubprocess(source)) { + return false; + } + + // 根据类型,获取入口连线 + List sequenceFlows = getElementIncomingFlows(source); + if (CollUtil.isEmpty(sequenceFlows)) { + return true; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (visitedElements.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + visitedElements.add(sequenceFlow.getId()); + // 这条线路存在目标节点,这条线路完成,进入下个线路 + FlowElement sourceFlowElement = sequenceFlow.getSourceFlowElement(); + if (target.getId().equals(sourceFlowElement.getId())) { + continue; + } + // 如果目标节点为并行网关,则不继续 + if (sourceFlowElement instanceof ParallelGateway) { + return false; + } + // 否则就继续迭代 + if (!isSequentialReachable(sourceFlowElement, target, visitedElements)) { + return false; + } + } + return true; + } + + /** + * 判断当前节点是否属于不同的子流程 + * + * @param flowElement 被判断的节点 + * @return true 表示属于子流程 + */ + private static boolean isInEventSubprocess(FlowElement flowElement) { + FlowElementsContainer flowElementsContainer = flowElement.getParentContainer(); + while (flowElementsContainer != null) { + if (flowElementsContainer instanceof EventSubProcess) { + return true; + } + + if (flowElementsContainer instanceof FlowElement) { + flowElementsContainer = ((FlowElement) flowElementsContainer).getParentContainer(); + } else { + flowElementsContainer = null; + } + } + return false; + } + + /** + * 根据正在运行的任务节点,迭代获取子级任务节点列表,向后找 + * + * @param source 起始节点 + * @param runTaskKeyList 正在运行的任务 Key,用于校验任务节点是否是正在运行的节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 需要撤回的用户任务列表 + * @return 子级任务节点列表 + */ + public static List iteratorFindChildUserTasks(FlowElement source, List runTaskKeyList, + Set hasSequenceFlow, List userTaskList) { + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + // 如果该节点为开始节点,且存在上级子节点,则顺着上级子节点继续迭代 + if (source instanceof StartEvent && source.getSubProcess() != null) { + userTaskList = iteratorFindChildUserTasks(source.getSubProcess(), runTaskKeyList, hasSequenceFlow, userTaskList); + } + + // 根据类型,获取出口连线 + List sequenceFlows = getElementOutgoingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 如果为用户任务类型,且任务节点的 Key 正在运行的任务中存在,添加 + if (sequenceFlow.getTargetFlowElement() instanceof UserTask && runTaskKeyList.contains((sequenceFlow.getTargetFlowElement()).getId())) { + userTaskList.add((UserTask) sequenceFlow.getTargetFlowElement()); + continue; + } + // 如果节点为子流程节点情况,则从节点中的第一个节点开始获取 + if (sequenceFlow.getTargetFlowElement() instanceof SubProcess) { + List childUserTaskList = iteratorFindChildUserTasks((FlowElement) (((SubProcess) sequenceFlow.getTargetFlowElement()).getFlowElements().toArray()[0]), runTaskKeyList, hasSequenceFlow, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + continue; + } + } + // 继续迭代 + userTaskList = iteratorFindChildUserTasks(sequenceFlow.getTargetFlowElement(), runTaskKeyList, hasSequenceFlow, userTaskList); + } + return userTaskList; + } + +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/FlowableUtils.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/FlowableUtils.java new file mode 100644 index 0000000000..2a0fc38ac5 --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/util/FlowableUtils.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.framework.flowable.core.util; + +import org.flowable.common.engine.impl.identity.Authentication; + +/** + * Flowable 相关的工具方法 + * + * @author 芋道源码 + */ +public class FlowableUtils { + + // ========== User 相关的工具方法 ========== + + public static void setAuthenticatedUserId(Long userId) { + Authentication.setAuthenticatedUserId(String.valueOf(userId)); + } + + public static void clearAuthenticatedUserId() { + Authentication.setAuthenticatedUserId(null); + } + + // ========== Execution 相关的工具方法 ========== + + public static String formatCollectionVariable(String activityId) { + return activityId + "_assignees"; + } + + public static String formatCollectionElementVariable(String activityId) { + return activityId + "_assignee"; + } + +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/web/FlowableWebFilter.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/web/FlowableWebFilter.java new file mode 100644 index 0000000000..d9845a394c --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/core/web/FlowableWebFilter.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.framework.flowable.core.web; + +import cn.iocoder.yudao.framework.flowable.core.util.FlowableUtils; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +/** + * flowable Web 过滤器,将 userId 设置到 {@link org.flowable.common.engine.impl.identity.Authentication} 中 + * + * @author jason + */ +public class FlowableWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + try { + // 设置工作流的用户 + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (userId != null) { + FlowableUtils.setAuthenticatedUserId(userId); + } + // 过滤 + chain.doFilter(request, response); + } finally { + // 清理 + FlowableUtils.clearAuthenticatedUserId(); + } + } +} diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/package-info.java b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/package-info.java new file mode 100644 index 0000000000..324d3de0ec --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/java/cn/iocoder/yudao/framework/flowable/package-info.java @@ -0,0 +1 @@ +package cn.iocoder.yudao.framework.flowable; diff --git a/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..1df61598ce --- /dev/null +++ b/yudao-module-bpm/yudao-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.iocoder.yudao.framework.flowable.config.YudaoFlowableConfiguration \ No newline at end of file diff --git a/yudao-module-crm/pom.xml b/yudao-module-crm/pom.xml new file mode 100644 index 0000000000..310a7df872 --- /dev/null +++ b/yudao-module-crm/pom.xml @@ -0,0 +1,24 @@ + + + + cn.iocoder.boot + yudao + ${revision} + + + yudao-module-crm-api + yudao-module-crm-biz + + 4.0.0 + yudao-module-crm + pom + + ${project.artifactId} + + crm 包下,客户关系管理(Customer Relationship Management)。 + 例如说:客户、联系人、商机、合同、回款等等 + + + diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/DictTypeConstants.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/DictTypeConstants.java new file mode 100644 index 0000000000..eee2b32d9c --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/DictTypeConstants.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.crm.enums; + +/** + * CRM 字典类型的枚举类 + * + * @author 芋道源码 + */ +public interface DictTypeConstants { + + String CRM_CUSTOMER_INDUSTRY = "crm_customer_industry"; // CRM 客户所属行业 + String CRM_CUSTOMER_LEVEL = "crm_customer_level"; // CRM 客户等级 + String CRM_CUSTOMER_SOURCE = "crm_customer_source"; // CRM 客户来源 + String CRM_AUDIT_STATUS = "crm_audit_status"; // CRM 审批状态 + String CRM_PRODUCT_UNIT = "crm_product_unit"; // CRM 产品单位 + String CRM_PRODUCT_STATUS = "crm_product_status"; // CRM 产品状态 + String CRM_FOLLOW_UP_TYPE = "crm_follow_up_type"; // CRM 跟进方式 + String CRM_RECEIVABLE_RETURN_TYPE = "crm_receivable_return_type"; // CRM 回款方式 + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java new file mode 100644 index 0000000000..a15a3211b4 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.crm.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * CRM 错误码枚举类 + *

+ * crm 系统,使用 1-020-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 合同管理 1-020-000-000 ========== + ErrorCode CONTRACT_NOT_EXISTS = new ErrorCode(1_020_000_000, "合同不存在"); + ErrorCode CONTRACT_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_020_000_001, "合同更新失败,原因:合同不是草稿状态"); + ErrorCode CONTRACT_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_000_002, "合同提交审核失败,原因:合同没处在未提交状态"); + ErrorCode CONTRACT_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_000_003, "更新合同审核状态失败,原因:合同不是审核中状态"); + ErrorCode CONTRACT_NO_EXISTS = new ErrorCode(1_020_000_004, "生成合同序列号重复,请重试"); + + // ========== 线索管理 1-020-001-000 ========== + ErrorCode CLUE_NOT_EXISTS = new ErrorCode(1_020_001_000, "线索不存在"); + ErrorCode CLUE_TRANSFORM_FAIL_ALREADY = new ErrorCode(1_020_001_001, "线索已经转化过了,请勿重复转化"); + + // ========== 商机管理 1-020-002-000 ========== + ErrorCode BUSINESS_NOT_EXISTS = new ErrorCode(1_020_002_000, "商机不存在"); + ErrorCode BUSINESS_DELETE_FAIL_CONTRACT_EXISTS = new ErrorCode(1_020_002_001, "商机已关联合同,不能删除"); + ErrorCode BUSINESS_UPDATE_STATUS_FAIL_END_STATUS = new ErrorCode(1_020_002_002, "更新商机状态失败,原因:已经是结束状态"); + ErrorCode BUSINESS_UPDATE_STATUS_FAIL_STATUS_EQUALS = new ErrorCode(1_020_002_003, "更新商机状态失败,原因:已经是该状态"); + + // ========== 联系人管理 1-020-003-000 ========== + ErrorCode CONTACT_NOT_EXISTS = new ErrorCode(1_020_003_000, "联系人不存在"); + ErrorCode CONTACT_DELETE_FAIL_CONTRACT_LINK_EXISTS = new ErrorCode(1_020_003_002, "联系人已关联合同,不能删除"); + ErrorCode CONTACT_UPDATE_OWNER_USER_FAIL = new ErrorCode(1_020_003_003, "更新联系人负责人失败"); + + // ========== 回款 1-020-004-000 ========== + ErrorCode RECEIVABLE_NOT_EXISTS = new ErrorCode(1_020_004_000, "回款不存在"); + ErrorCode RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED = new ErrorCode(1_020_004_001, "更新回款失败,原因:禁止编辑"); + ErrorCode RECEIVABLE_DELETE_FAIL = new ErrorCode(1_020_004_002, "删除回款失败,原因: 被回款计划所使用,不允许删除"); + ErrorCode RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_004_003, "回款提交审核失败,原因:回款没处在未提交状态"); + ErrorCode RECEIVABLE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_004_004, "更新回款审核状态失败,原因:回款不是审核中状态"); + ErrorCode RECEIVABLE_NO_EXISTS = new ErrorCode(1_020_004_005, "生成回款序列号重复,请重试"); + ErrorCode RECEIVABLE_CREATE_FAIL_CONTRACT_NOT_APPROVE = new ErrorCode(1_020_004_006, "创建回款失败,原因:合同不是审核通过状态"); + ErrorCode RECEIVABLE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT = new ErrorCode(1_020_004_007, "创建回款失败,原因:回款金额超出合同金额,目前剩余可退:{} 元"); + + // ========== 回款计划 1-020-005-000 ========== + ErrorCode RECEIVABLE_PLAN_NOT_EXISTS = new ErrorCode(1_020_005_000, "回款计划不存在"); + ErrorCode RECEIVABLE_PLAN_UPDATE_FAIL = new ErrorCode(1_020_006_000, "更想回款计划失败,原因:已经有对应的还款"); + ErrorCode RECEIVABLE_PLAN_EXISTS_RECEIVABLE = new ErrorCode(1_020_006_001, "回款计划已经有对应的回款,不能使用"); + + // ========== 客户管理 1_020_006_000 ========== + ErrorCode CUSTOMER_NOT_EXISTS = new ErrorCode(1_020_006_000, "客户不存在"); + ErrorCode CUSTOMER_OWNER_EXISTS = new ErrorCode(1_020_006_001, "客户【{}】已存在所属负责人"); + ErrorCode CUSTOMER_LOCKED = new ErrorCode(1_020_006_002, "客户【{}】状态已锁定"); + ErrorCode CUSTOMER_ALREADY_DEAL = new ErrorCode(1_020_006_003, "客户已交易"); + ErrorCode CUSTOMER_IN_POOL = new ErrorCode(1_020_006_004, "客户【{}】放入公海失败,原因:已经是公海客户"); + ErrorCode CUSTOMER_LOCKED_PUT_POOL_FAIL = new ErrorCode(1_020_006_005, "客户【{}】放入公海失败,原因:客户已锁定"); + ErrorCode CUSTOMER_UPDATE_OWNER_USER_FAIL = new ErrorCode(1_020_006_006, "更新客户【{}】负责人失败, 原因:系统异常"); + ErrorCode CUSTOMER_LOCK_FAIL_IS_LOCK = new ErrorCode(1_020_006_007, "锁定客户失败,它已经处于锁定状态"); + ErrorCode CUSTOMER_UNLOCK_FAIL_IS_UNLOCK = new ErrorCode(1_020_006_008, "解锁客户失败,它已经处于未锁定状态"); + ErrorCode CUSTOMER_LOCK_EXCEED_LIMIT = new ErrorCode(1_020_006_009, "锁定客户失败,超出锁定规则上限"); + ErrorCode CUSTOMER_OWNER_EXCEED_LIMIT = new ErrorCode(1_020_006_010, "操作失败,超出客户数拥有上限"); + ErrorCode CUSTOMER_DELETE_FAIL_HAVE_REFERENCE = new ErrorCode(1_020_006_011, "删除客户失败,有关联{}"); + ErrorCode CUSTOMER_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_020_006_012, "导入客户数据不能为空!"); + ErrorCode CUSTOMER_CREATE_NAME_NOT_NULL = new ErrorCode(1_020_006_013, "客户名称不能为空!"); + ErrorCode CUSTOMER_NAME_EXISTS = new ErrorCode(1_020_006_014, "已存在名为【{}】的客户!"); + ErrorCode CUSTOMER_UPDATE_DEAL_STATUS_FAIL = new ErrorCode(1_020_006_015, "更新客户的成交状态失败,原因:已经是该状态,无需更新"); + + // ========== 权限管理 1_020_007_000 ========== + ErrorCode CRM_PERMISSION_NOT_EXISTS = new ErrorCode(1_020_007_000, "数据权限不存在"); + ErrorCode CRM_PERMISSION_DENIED = new ErrorCode(1_020_007_001, "{}操作失败,原因:没有权限"); + ErrorCode CRM_PERMISSION_MODEL_TRANSFER_FAIL_OWNER_USER_EXISTS = new ErrorCode(1_020_007_003, "{}操作失败,原因:转移对象已经是该负责人"); + ErrorCode CRM_PERMISSION_DELETE_FAIL = new ErrorCode(1_020_007_004, "删除数据权限失败,原因:批量删除权限的时候,只能属于同一个 bizId 下"); + ErrorCode CRM_PERMISSION_DELETE_DENIED = new ErrorCode(1_020_007_006, "删除数据权限失败,原因:没有权限"); + ErrorCode CRM_PERMISSION_DELETE_SELF_PERMISSION_FAIL_EXIST_OWNER = new ErrorCode(1_020_007_007, "删除数据权限失败,原因:不能删除负责人"); + ErrorCode CRM_PERMISSION_CREATE_FAIL = new ErrorCode(1_020_007_008, "创建数据权限失败,原因:所加用户已有权限"); + + // ========== 产品 1_020_008_000 ========== + ErrorCode PRODUCT_NOT_EXISTS = new ErrorCode(1_020_008_000, "产品不存在"); + ErrorCode PRODUCT_NO_EXISTS = new ErrorCode(1_020_008_001, "产品编号已存在"); + ErrorCode PRODUCT_NOT_ENABLE = new ErrorCode(1_020_008_002, "产品【{}】已禁用"); + + // ========== 产品分类 1_020_009_000 ========== + ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_020_009_000, "产品分类不存在"); + ErrorCode PRODUCT_CATEGORY_EXISTS = new ErrorCode(1_020_009_001, "产品分类已存在"); + ErrorCode PRODUCT_CATEGORY_USED = new ErrorCode(1_020_009_002, "产品分类已关联产品"); + ErrorCode PRODUCT_CATEGORY_PARENT_NOT_EXISTS = new ErrorCode(1_020_009_003, "父分类不存在"); + ErrorCode PRODUCT_CATEGORY_PARENT_NOT_FIRST_LEVEL = new ErrorCode(1_020_009_004, "父分类不能是二级分类"); + ErrorCode product_CATEGORY_EXISTS_CHILDREN = new ErrorCode(1_020_009_005, "存在子分类,无法删除"); + + // ========== 商机状态 1_020_010_000 ========== + ErrorCode BUSINESS_STATUS_TYPE_NOT_EXISTS = new ErrorCode(1_020_010_000, "商机状态组不存在"); + ErrorCode BUSINESS_STATUS_TYPE_NAME_EXISTS = new ErrorCode(1_020_010_001, "商机状态组的名称已存在"); + ErrorCode BUSINESS_STATUS_UPDATE_FAIL_USED = new ErrorCode(1_020_010_002, "已经被使用的商机状态组,无法进行更新"); + ErrorCode BUSINESS_STATUS_DELETE_FAIL_USED = new ErrorCode(1_020_010_002, "已经被使用的商机状态组,无法进行删除"); + ErrorCode BUSINESS_STATUS_NOT_EXISTS = new ErrorCode(1_020_010_003, "商机状态不存在"); + + // ========== 客户公海规则设置 1_020_012_000 ========== + ErrorCode CUSTOMER_LIMIT_CONFIG_NOT_EXISTS = new ErrorCode(1_020_012_001, "客户限制配置不存在"); + + // ========== 跟进记录 1_020_013_000 ========== + ErrorCode FOLLOW_UP_RECORD_NOT_EXISTS = new ErrorCode(1_020_013_000, "跟进记录不存在"); + ErrorCode FOLLOW_UP_RECORD_DELETE_DENIED = new ErrorCode(1_020_013_001, "删除跟进记录失败,原因:没有权限"); + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java new file mode 100644 index 0000000000..c2c835b7f1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java @@ -0,0 +1,163 @@ +package cn.iocoder.yudao.module.crm.enums; + +/** + * CRM 操作日志枚举 + * 目的:统一管理,也减少 Service 里各种“复杂”字符串 + * + * @author HUIHUI + */ +public interface LogRecordConstants { + + // ======================= CRM_CLUE 线索 ======================= + + String CRM_CLUE_TYPE = "CRM 线索"; + String CRM_CLUE_CREATE_SUB_TYPE = "创建线索"; + String CRM_CLUE_CREATE_SUCCESS = "创建了线索{{#clue.name}}"; + String CRM_CLUE_UPDATE_SUB_TYPE = "更新线索"; + String CRM_CLUE_UPDATE_SUCCESS = "更新了线索【{{#clueName}}】: {_DIFF{#updateReq}}"; + String CRM_CLUE_DELETE_SUB_TYPE = "删除线索"; + String CRM_CLUE_DELETE_SUCCESS = "删除了线索【{{#clueName}}】"; + String CRM_CLUE_TRANSFER_SUB_TYPE = "转移线索"; + String CRM_CLUE_TRANSFER_SUCCESS = "将线索【{{#clue.name}}】的负责人从【{getAdminUserById{#clue.ownerUserId}}】变更为了【{getAdminUserById{#reqVO.newOwnerUserId}}】"; + String CRM_CLUE_TRANSLATE_SUB_TYPE = "线索转化为客户"; + String CRM_CLUE_TRANSLATE_SUCCESS = "将线索【{{#clueName}}】转化为客户"; + String CRM_CLUE_FOLLOW_UP_SUB_TYPE = "线索跟进"; + String CRM_CLUE_FOLLOW_UP_SUCCESS = "线索跟进【{{#clueName}}】"; + + // ======================= CRM_CUSTOMER 客户 ======================= + + String CRM_CUSTOMER_TYPE = "CRM 客户"; + String CRM_CUSTOMER_CREATE_SUB_TYPE = "创建客户"; + String CRM_CUSTOMER_CREATE_SUCCESS = "创建了客户{{#customer.name}}"; + String CRM_CUSTOMER_UPDATE_SUB_TYPE = "更新客户"; + String CRM_CUSTOMER_UPDATE_SUCCESS = "更新了客户【{{#customerName}}】: {_DIFF{#updateReqVO}}"; + String CRM_CUSTOMER_DELETE_SUB_TYPE = "删除客户"; + String CRM_CUSTOMER_DELETE_SUCCESS = "删除了客户【{{#customerName}}】"; + String CRM_CUSTOMER_TRANSFER_SUB_TYPE = "转移客户"; + String CRM_CUSTOMER_TRANSFER_SUCCESS = "将客户【{{#customer.name}}】的负责人从【{getAdminUserById{#customer.ownerUserId}}】变更为了【{getAdminUserById{#reqVO.newOwnerUserId}}】"; + String CRM_CUSTOMER_LOCK_SUB_TYPE = "{{#customer.lockStatus ? '解锁客户' : '锁定客户'}}"; + String CRM_CUSTOMER_LOCK_SUCCESS = "{{#customer.lockStatus ? '将客户【' + #customer.name + '】解锁' : '将客户【' + #customer.name + '】锁定'}}"; + String CRM_CUSTOMER_POOL_SUB_TYPE = "客户放入公海"; + String CRM_CUSTOMER_POOL_SUCCESS = "将客户【{{#customerName}}】放入了公海"; + String CRM_CUSTOMER_RECEIVE_SUB_TYPE = "{{#ownerUserName != null ? '分配客户' : '领取客户'}}"; + String CRM_CUSTOMER_RECEIVE_SUCCESS = "{{#ownerUserName != null ? '将客户【' + #customer.name + '】分配给【' + #ownerUserName + '】' : '领取客户【' + #customer.name + '】'}}"; + String CRM_CUSTOMER_IMPORT_SUB_TYPE = "{{#isUpdate ? '导入并更新客户' : '导入客户'}}"; + String CRM_CUSTOMER_IMPORT_SUCCESS = "{{#isUpdate ? '导入并更新了客户【'+ #customer.name +'】' : '导入了客户【'+ #customer.name +'】'}}"; + String CRM_CUSTOMER_UPDATE_DEAL_STATUS_SUB_TYPE = "更新客户成交状态"; + String CRM_CUSTOMER_UPDATE_DEAL_STATUS_SUCCESS = "更新了客户【{{#customerName}}】的成交状态为【{{#dealStatus ? '已成交' : '未成交'}}】"; + String CRM_CUSTOMER_FOLLOW_UP_SUB_TYPE = "客户跟进"; + String CRM_CUSTOMER_FOLLOW_UP_SUCCESS = "客户跟进【{{#customerName}}】"; + + // ======================= CRM_CUSTOMER_LIMIT_CONFIG 客户限制配置 ======================= + + String CRM_CUSTOMER_LIMIT_CONFIG_TYPE = "CRM 客户限制配置"; + String CRM_CUSTOMER_LIMIT_CONFIG_CREATE_SUB_TYPE = "创建客户限制配置"; + String CRM_CUSTOMER_LIMIT_CONFIG_CREATE_SUCCESS = "创建了【{{#limitType}}】类型的客户限制配置"; + String CRM_CUSTOMER_LIMIT_CONFIG_UPDATE_SUB_TYPE = "更新客户限制配置"; + String CRM_CUSTOMER_LIMIT_CONFIG_UPDATE_SUCCESS = "更新了客户限制配置: {_DIFF{#updateReqVO}}"; + String CRM_CUSTOMER_LIMIT_CONFIG_DELETE_SUB_TYPE = "删除客户限制配置"; + String CRM_CUSTOMER_LIMIT_CONFIG_DELETE_SUCCESS = "删除了【{{#limitType}}】类型的客户限制配置"; + + // ======================= CRM_CUSTOMER_POOL_CONFIG 客户公海规则 ======================= + + String CRM_CUSTOMER_POOL_CONFIG_TYPE = "CRM 客户公海规则"; + String CRM_CUSTOMER_POOL_CONFIG_SUB_TYPE = "{{#isPoolConfigUpdate ? '更新客户公海规则' : '创建客户公海规则'}}"; + String CRM_CUSTOMER_POOL_CONFIG_SUCCESS = "{{#isPoolConfigUpdate ? '更新了客户公海规则' : '创建了客户公海规则'}}"; + + // ======================= CRM_CONTACT 联系人 ======================= + + String CRM_CONTACT_TYPE = "CRM 联系人"; + String CRM_CONTACT_CREATE_SUB_TYPE = "创建联系人"; + String CRM_CONTACT_CREATE_SUCCESS = "创建了联系人{{#contact.name}}"; + String CRM_CONTACT_UPDATE_SUB_TYPE = "更新联系人"; + String CRM_CONTACT_UPDATE_SUCCESS = "更新了联系人【{{#contactName}}】: {_DIFF{#updateReqVO}}"; + String CRM_CONTACT_DELETE_SUB_TYPE = "删除联系人"; + String CRM_CONTACT_DELETE_SUCCESS = "删除了联系人【{{#contactName}}】"; + String CRM_CONTACT_TRANSFER_SUB_TYPE = "转移联系人"; + String CRM_CONTACT_TRANSFER_SUCCESS = "将联系人【{{#contact.name}}】的负责人从【{getAdminUserById{#contact.ownerUserId}}】变更为了【{getAdminUserById{#reqVO.newOwnerUserId}}】"; + String CRM_CONTACT_FOLLOW_UP_SUB_TYPE = "联系人跟进"; + String CRM_CONTACT_FOLLOW_UP_SUCCESS = "联系人跟进【{{#contactName}}】"; + String CRM_CONTACT_UPDATE_OWNER_USER_SUB_TYPE = "更新联系人负责人"; + String CRM_CONTACT_UPDATE_OWNER_USER_SUCCESS = "将联系人【{{#contact.name}}】的负责人从【{getAdminUserById{#contact.ownerUserId}}】变更为了【{getAdminUserById{#ownerUserId}}】"; + + // ======================= CRM_BUSINESS 商机 ======================= + + String CRM_BUSINESS_TYPE = "CRM 商机"; + String CRM_BUSINESS_CREATE_SUB_TYPE = "创建商机"; + String CRM_BUSINESS_CREATE_SUCCESS = "创建了商机{{#business.name}}"; + String CRM_BUSINESS_UPDATE_SUB_TYPE = "更新商机"; + String CRM_BUSINESS_UPDATE_SUCCESS = "更新了商机【{{#businessName}}】: {_DIFF{#updateReqVO}}"; + String CRM_BUSINESS_DELETE_SUB_TYPE = "删除商机"; + String CRM_BUSINESS_DELETE_SUCCESS = "删除了商机【{{#businessName}}】"; + String CRM_BUSINESS_TRANSFER_SUB_TYPE = "转移商机"; + String CRM_BUSINESS_TRANSFER_SUCCESS = "将商机【{{#business.name}}】的负责人从【{getAdminUserById{#business.ownerUserId}}】变更为了【{getAdminUserById{#reqVO.newOwnerUserId}}】"; + String CRM_BUSINESS_FOLLOW_UP_SUB_TYPE = "商机跟进"; + String CRM_BUSINESS_FOLLOW_UP_SUCCESS = "商机跟进【{{#businessName}}】"; + String CRM_BUSINESS_UPDATE_STATUS_SUB_TYPE = "更新商机状态"; + String CRM_BUSINESS_UPDATE_STATUS_SUCCESS = "更新了商机【{{#businessName}}】的状态从【{{#oldStatusName}}】变更为了【{{#newStatusName}}】"; + + // ======================= CRM_CONTRACT_CONFIG 合同配置 ======================= + + String CRM_CONTRACT_CONFIG_TYPE = "CRM 合同配置"; + String CRM_CONTRACT_CONFIG_SUB_TYPE = "{{#isPoolConfigUpdate ? '更新合同配置' : '创建合同配置'}}"; + String CRM_CONTRACT_CONFIG_SUCCESS = "{{#isPoolConfigUpdate ? '更新了合同配置' : '创建了合同配置'}}"; + + // ======================= CRM_CONTRACT 合同 ======================= + + String CRM_CONTRACT_TYPE = "CRM 合同"; + String CRM_CONTRACT_CREATE_SUB_TYPE = "创建合同"; + String CRM_CONTRACT_CREATE_SUCCESS = "创建了合同{{#contract.name}}"; + String CRM_CONTRACT_UPDATE_SUB_TYPE = "更新合同"; + String CRM_CONTRACT_UPDATE_SUCCESS = "更新了合同【{{#contractName}}】: {_DIFF{#updateReqVO}}"; + String CRM_CONTRACT_DELETE_SUB_TYPE = "删除合同"; + String CRM_CONTRACT_DELETE_SUCCESS = "删除了合同【{{#contractName}}】"; + String CRM_CONTRACT_TRANSFER_SUB_TYPE = "转移合同"; + String CRM_CONTRACT_TRANSFER_SUCCESS = "将合同【{{#contract.name}}】的负责人从【{getAdminUserById{#contract.ownerUserId}}】变更为了【{getAdminUserById{#reqVO.newOwnerUserId}}】"; + String CRM_CONTRACT_SUBMIT_SUB_TYPE = "提交合同审批"; + String CRM_CONTRACT_SUBMIT_SUCCESS = "提交合同【{{#contractName}}】审批成功"; + String CRM_CONTRACT_FOLLOW_UP_SUB_TYPE = "合同跟进"; + String CRM_CONTRACT_FOLLOW_UP_SUCCESS = "合同跟进【{{#contractName}}】"; + + // ======================= CRM_PRODUCT 产品 ======================= + + String CRM_PRODUCT_TYPE = "CRM 产品"; + String CRM_PRODUCT_CREATE_SUB_TYPE = "创建产品"; + String CRM_PRODUCT_CREATE_SUCCESS = "创建了产品【{{#createReqVO.name}}】"; + String CRM_PRODUCT_UPDATE_SUB_TYPE = "更新产品"; + String CRM_PRODUCT_UPDATE_SUCCESS = "更新了产品【{{#updateReqVO.name}}】: {_DIFF{#updateReqVO}}"; + String CRM_PRODUCT_DELETE_SUB_TYPE = "删除产品"; + String CRM_PRODUCT_DELETE_SUCCESS = "删除了产品【{{#product.name}}】"; + + // ======================= CRM_PRODUCT_CATEGORY 产品分类 ======================= + + String CRM_PRODUCT_CATEGORY_TYPE = "CRM 产品分类"; + String CRM_PRODUCT_CATEGORY_CREATE_SUB_TYPE = "创建产品分类"; + String CRM_PRODUCT_CATEGORY_CREATE_SUCCESS = "创建了产品分类【{{#createReqVO.name}}】"; + String CRM_PRODUCT_CATEGORY_UPDATE_SUB_TYPE = "更新产品分类"; + String CRM_PRODUCT_CATEGORY_UPDATE_SUCCESS = "更新了产品分类【{{#updateReqVO.name}}】: {_DIFF{#updateReqVO}}"; + String CRM_PRODUCT_CATEGORY_DELETE_SUB_TYPE = "删除产品分类"; + String CRM_PRODUCT_CATEGORY_DELETE_SUCCESS = "删除了产品分类【{{#productCategory.name}}】"; + + // ======================= CRM_RECEIVABLE 回款 ======================= + + String CRM_RECEIVABLE_TYPE = "CRM 回款"; + String CRM_RECEIVABLE_CREATE_SUB_TYPE = "创建回款"; + String CRM_RECEIVABLE_CREATE_SUCCESS = "创建了合同【{getContractById{#receivable.contractId}}】的第【{{#receivable.period}}】期回款"; + String CRM_RECEIVABLE_UPDATE_SUB_TYPE = "更新回款"; + String CRM_RECEIVABLE_UPDATE_SUCCESS = "更新了合同【{getContractById{#receivable.contractId}}】的第【{{#receivable.period}}】期回款: {_DIFF{#updateReqVO}}"; + String CRM_RECEIVABLE_DELETE_SUB_TYPE = "删除回款"; + String CRM_RECEIVABLE_DELETE_SUCCESS = "删除了合同【{getContractById{#receivable.contractId}}】的第【{{#receivable.period}}】期回款"; + String CRM_RECEIVABLE_SUBMIT_SUB_TYPE = "提交回款审批"; + String CRM_RECEIVABLE_SUBMIT_SUCCESS = "提交编号为【{{#receivableNo}}】的回款审批成功"; + + // ======================= CRM_RECEIVABLE_PLAN 回款计划 ======================= + + String CRM_RECEIVABLE_PLAN_TYPE = "CRM 回款计划"; + String CRM_RECEIVABLE_PLAN_CREATE_SUB_TYPE = "创建回款计划"; + String CRM_RECEIVABLE_PLAN_CREATE_SUCCESS = "创建了合同【{getContractById{#receivablePlan.contractId}}】的第【{{#receivablePlan.period}}】期回款计划"; + String CRM_RECEIVABLE_PLAN_UPDATE_SUB_TYPE = "更新回款计划"; + String CRM_RECEIVABLE_PLAN_UPDATE_SUCCESS = "更新了合同【{getContractById{#receivablePlan.contractId}}】的第【{{#receivablePlan.period}}】期回款计划: {_DIFF{#updateReqVO}}"; + String CRM_RECEIVABLE_PLAN_DELETE_SUB_TYPE = "删除回款计划"; + String CRM_RECEIVABLE_PLAN_DELETE_SUCCESS = "删除了合同【{getContractById{#receivablePlan.contractId}}】的第【{{#receivablePlan.period}}】期回款计划"; + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/business/CrmBusinessEndStatusEnum.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/business/CrmBusinessEndStatusEnum.java new file mode 100644 index 0000000000..4736c01b77 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/business/CrmBusinessEndStatusEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.crm.enums.business; + +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * 商机的结束状态枚举 + * + * @author lzxhqs + */ +@RequiredArgsConstructor +@Getter +public enum CrmBusinessEndStatusEnum implements IntArrayValuable { + + WIN(1, "赢单"), + LOSE(2, "输单"), + INVALID(3, "无效"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CrmBusinessEndStatusEnum::getStatus).toArray(); + + /** + * 场景类型 + */ + private final Integer status; + /** + * 场景名称 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + + public static CrmBusinessEndStatusEnum fromStatus(Integer status) { + return Arrays.stream(values()) + .filter(value -> value.getStatus().equals(status)) + .findFirst() + .orElse(null); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmBizTypeEnum.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmBizTypeEnum.java new file mode 100644 index 0000000000..8402ad2886 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/common/CrmBizTypeEnum.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.crm.enums.common; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * CRM 业务类型枚举 + * + * @author HUIHUI + */ +@RequiredArgsConstructor +@Getter +public enum CrmBizTypeEnum implements IntArrayValuable { + + CRM_CLUE(1, "线索"), + CRM_CUSTOMER(2, "客户"), + CRM_CONTACT(3, "联系人"), + CRM_BUSINESS(4, "商机"), + CRM_CONTRACT(5, "合同"), + CRM_PRODUCT(6, "产品"), + CRM_RECEIVABLE(7, "回款"), + CRM_RECEIVABLE_PLAN(8, "回款计划") + ; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CrmBizTypeEnum::getType).toArray(); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + public static String getNameByType(Integer type) { + CrmBizTypeEnum typeEnum = CollUtil.findOne(CollUtil.newArrayList(CrmBizTypeEnum.values()), + item -> ObjUtil.equal(item.type, type)); + return typeEnum == null ? null : typeEnum.getName(); + } + + @Override + public int[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/permission/CrmPermissionLevelEnum.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/permission/CrmPermissionLevelEnum.java new file mode 100644 index 0000000000..7b44fe962e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/permission/CrmPermissionLevelEnum.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.crm.enums.permission; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * CRM 数据权限级别枚举 + * + * OWNER > WRITE > READ + * + * @author HUIHUI + */ +@Getter +@AllArgsConstructor +public enum CrmPermissionLevelEnum implements IntArrayValuable { + + OWNER(1, "负责人"), + READ(2, "只读"), + WRITE(3, "读写"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CrmPermissionLevelEnum::getLevel).toArray(); + + /** + * 级别 + */ + private final Integer level; + /** + * 级别名称 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + + public static boolean isOwner(Integer level) { + return ObjUtil.equal(OWNER.level, level); + } + + public static boolean isRead(Integer level) { + return ObjUtil.equal(READ.level, level); + } + + public static boolean isWrite(Integer level) { + return ObjUtil.equal(WRITE.level, level); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmReceivableReturnTypeEnum.java b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmReceivableReturnTypeEnum.java new file mode 100644 index 0000000000..3c01fe95c1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-api/src/main/java/cn/iocoder/yudao/module/crm/enums/receivable/CrmReceivableReturnTypeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.crm.enums.receivable; + +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * CRM 回款方式枚举 + * + * @author HUIHUI + */ +@Getter +@AllArgsConstructor +public enum CrmReceivableReturnTypeEnum implements IntArrayValuable { + + CHECK(1, "支票"), + CASH(2, "现金"), + POSTAL_REMITTANCE(3, "邮政汇款"), + TELEGRAPHIC_TRANSFER(4, "电汇"), + ONLINE_TRANSFER(5, "网上转账"), + ALIPAY(6, "支付宝"), + WECHAT_PAY(7, "微信支付"), + OTHER(8, "其它"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CrmReceivableReturnTypeEnum::getType).toArray(); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/pom.xml b/yudao-module-crm/yudao-module-crm-biz/pom.xml new file mode 100644 index 0000000000..04e048da79 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/pom.xml @@ -0,0 +1,79 @@ + + + + cn.iocoder.boot + yudao-module-crm + ${revision} + + 4.0.0 + yudao-module-crm-biz + + ${project.artifactId} + + crm 包下,客户关系管理(Customer Relationship Management)。 + 例如说:客户、联系人、商机、合同、回款等等 + + + + + cn.iocoder.boot + yudao-module-system-api + ${revision} + + + cn.iocoder.boot + yudao-module-crm-api + ${revision} + + + cn.iocoder.boot + yudao-module-bpm-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-ip + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessController.java new file mode 100644 index 0000000000..8cd44bd8cb --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessController.java @@ -0,0 +1,222 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.*; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessProductDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusTypeDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessStatusService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.product.CrmProductService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CUSTOMER_NOT_EXISTS; + +@Tag(name = "管理后台 - CRM 商机") +@RestController +@RequestMapping("/crm/business") +@Validated +public class CrmBusinessController { + + @Resource + private CrmBusinessService businessService; + @Resource + private CrmCustomerService customerService; + @Resource + private CrmBusinessStatusService businessStatusTypeService; + @Resource + private CrmBusinessStatusService businessStatusService; + @Resource + private CrmProductService productService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建商机") + @PreAuthorize("@ss.hasPermission('crm:business:create')") + public CommonResult createBusiness(@Valid @RequestBody CrmBusinessSaveReqVO createReqVO) { + return success(businessService.createBusiness(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新商机") + @PreAuthorize("@ss.hasPermission('crm:business:update')") + public CommonResult updateBusiness(@Valid @RequestBody CrmBusinessSaveReqVO updateReqVO) { + businessService.updateBusiness(updateReqVO); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "更新商机状态") + @PreAuthorize("@ss.hasPermission('crm:business:update')") + public CommonResult updateBusinessStatus(@Valid @RequestBody CrmBusinessUpdateStatusReqVO updateStatusReqVO) { + businessService.updateBusinessStatus(updateStatusReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除商机") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:business:delete')") + public CommonResult deleteBusiness(@RequestParam("id") Long id) { + businessService.deleteBusiness(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得商机") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:business:query')") + public CommonResult getBusiness(@RequestParam("id") Long id) { + CrmBusinessDO business = businessService.getBusiness(id); + return success(buildBusinessDetail(business)); + } + + private CrmBusinessRespVO buildBusinessDetail(CrmBusinessDO business) { + if (business == null) { + return null; + } + CrmBusinessRespVO businessVO = buildBusinessDetailList(Collections.singletonList(business)).get(0); + // 拼接产品项 + List businessProducts = businessService.getBusinessProductListByBusinessId(businessVO.getId()); + Map productMap = productService.getProductMap( + convertSet(businessProducts, CrmBusinessProductDO::getProductId)); + businessVO.setProducts(BeanUtils.toBean(businessProducts, CrmBusinessRespVO.Product.class, businessProductVO -> + MapUtils.findAndThen(productMap, businessProductVO.getProductId(), + product -> businessProductVO.setProductName(product.getName()) + .setProductNo(product.getNo()).setProductUnit(product.getUnit())))); + return businessVO; + } + + @GetMapping("/simple-all-list") + @Operation(summary = "获得联系人的精简列表") + @PreAuthorize("@ss.hasPermission('crm:contact:query')") + public CommonResult> getSimpleContactList() { + CrmBusinessPageReqVO reqVO = new CrmBusinessPageReqVO(); + reqVO.setPageSize(PAGE_SIZE_NONE); // 不分页 + PageResult pageResult = businessService.getBusinessPage(reqVO, getLoginUserId()); + return success(convertList(pageResult.getList(), business -> // 只返回 id、name 字段 + new CrmBusinessRespVO().setId(business.getId()).setName(business.getName()) + .setCustomerId(business.getCustomerId()))); + } + + @GetMapping("/page") + @Operation(summary = "获得商机分页") + @PreAuthorize("@ss.hasPermission('crm:business:query')") + public CommonResult> getBusinessPage(@Valid CrmBusinessPageReqVO pageVO) { + PageResult pageResult = businessService.getBusinessPage(pageVO, getLoginUserId()); + return success(new PageResult<>(buildBusinessDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-customer") + @Operation(summary = "获得商机分页,基于指定客户") + public CommonResult> getBusinessPageByCustomer(@Valid CrmBusinessPageReqVO pageReqVO) { + if (pageReqVO.getCustomerId() == null) { + throw exception(CUSTOMER_NOT_EXISTS); + } + PageResult pageResult = businessService.getBusinessPageByCustomerId(pageReqVO); + return success(new PageResult<>(buildBusinessDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-contact") + @Operation(summary = "获得联系人的商机分页") + @PreAuthorize("@ss.hasPermission('crm:business:query')") + public CommonResult> getBusinessContactPage(@Valid CrmBusinessPageReqVO pageReqVO) { + PageResult pageResult = businessService.getBusinessPageByContact(pageReqVO); + return success(new PageResult<>(buildBusinessDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出商机 Excel") + @PreAuthorize("@ss.hasPermission('crm:business:export')") + @OperateLog(type = EXPORT) + public void exportBusinessExcel(@Valid CrmBusinessPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageSize(PAGE_SIZE_NONE); + List list = businessService.getBusinessPage(exportReqVO, getLoginUserId()).getList(); + // 导出 Excel + ExcelUtils.write(response, "商机.xls", "数据", CrmBusinessRespVO.class, + buildBusinessDetailList(list)); + } + + private List buildBusinessDetailList(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + // 1.1 获取客户列表 + Map customerMap = customerService.getCustomerMap( + convertSet(list, CrmBusinessDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(list, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 1.3 获得商机状态组 + Map statusTypeMap = businessStatusTypeService.getBusinessStatusTypeMap( + convertSet(list, CrmBusinessDO::getStatusTypeId)); + Map statusMap = businessStatusService.getBusinessStatusMap( + convertSet(list, CrmBusinessDO::getStatusId)); + // 2. 拼接数据 + return BeanUtils.toBean(list, CrmBusinessRespVO.class, businessVO -> { + // 2.1 设置客户名称 + MapUtils.findAndThen(customerMap, businessVO.getCustomerId(), customer -> businessVO.setCustomerName(customer.getName())); + // 2.2 设置创建人、负责人名称 + MapUtils.findAndThen(userMap, NumberUtils.parseLong(businessVO.getCreator()), + user -> businessVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, businessVO.getOwnerUserId(), user -> { + businessVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> businessVO.setOwnerUserDeptName(dept.getName())); + }); + // 2.3 设置商机状态 + MapUtils.findAndThen(statusTypeMap, businessVO.getStatusTypeId(), statusType -> businessVO.setStatusTypeName(statusType.getName())); + MapUtils.findAndThen(statusMap, businessVO.getStatusId(), status -> businessVO.setStatusName( + businessService.getBusinessStatusName(businessVO.getEndStatus(), status))); + }); + } + + @PutMapping("/transfer") + @Operation(summary = "商机转移") + @PreAuthorize("@ss.hasPermission('crm:business:update')") + public CommonResult transferBusiness(@Valid @RequestBody CrmBusinessTransferReqVO reqVO) { + businessService.transferBusiness(reqVO, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessStatusController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessStatusController.java new file mode 100644 index 0000000000..db80306730 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/CrmBusinessStatusController.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.status.CrmBusinessStatusRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.status.CrmBusinessStatusSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusTypeDO; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessStatusService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - CRM 商机状态") +@RestController +@RequestMapping("/crm/business-status") +@Validated +public class CrmBusinessStatusController { + + @Resource + private CrmBusinessStatusService businessStatusTypeService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建商机状态") + @PreAuthorize("@ss.hasPermission('crm:business-status:create')") + public CommonResult createBusinessStatus(@Valid @RequestBody CrmBusinessStatusSaveReqVO createReqVO) { + return success(businessStatusTypeService.createBusinessStatus(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新商机状态") + @PreAuthorize("@ss.hasPermission('crm:business-status:update')") + public CommonResult updateBusinessStatus(@Valid @RequestBody CrmBusinessStatusSaveReqVO updateReqVO) { + businessStatusTypeService.updateBusinessStatus(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除商机状态") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:business-status:delete')") + public CommonResult deleteBusinessStatusType(@RequestParam("id") Long id) { + businessStatusTypeService.deleteBusinessStatusType(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得商机状态") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:business-status:query')") + public CommonResult getBusinessStatusType(@RequestParam("id") Long id) { + CrmBusinessStatusTypeDO statusType = businessStatusTypeService.getBusinessStatusType(id); + if (statusType == null) { + return success(null); + } + List statuses = businessStatusTypeService.getBusinessStatusListByTypeId(id); + return success(BeanUtils.toBean(statusType, CrmBusinessStatusRespVO.class, + statusTypeVO -> statusTypeVO.setStatuses(BeanUtils.toBean(statuses, CrmBusinessStatusRespVO.Status.class)))); + } + + @GetMapping("/page") + @Operation(summary = "获得商机状态分页") + @PreAuthorize("@ss.hasPermission('crm:business-status:query')") + public CommonResult> getBusinessStatusPage(@Valid PageParam pageReqVO) { + // 1. 查询数据 + PageResult pageResult = businessStatusTypeService.getBusinessStatusTypePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2. 拼接数据 + Map userMap = adminUserApi.getUserMap( + convertSet(pageResult.getList(), statusType -> Long.parseLong(statusType.getCreator()))); + Map deptMap = deptApi.getDeptMap( + convertSetByFlatMap(pageResult.getList(), CrmBusinessStatusTypeDO::getDeptIds, Collection::stream)); + return success(BeanUtils.toBean(pageResult, CrmBusinessStatusRespVO.class, statusTypeVO -> { + statusTypeVO.setCreator(userMap.get(NumberUtils.parseLong(statusTypeVO.getCreator())).getNickname()); + statusTypeVO.setDeptNames(convertList(statusTypeVO.getDeptIds(), + deptId -> deptMap.containsKey(deptId) ? deptMap.get(deptId).getName() : null)); + })); + } + + @GetMapping("/type-simple-list") + @Operation(summary = "获得商机状态组列表") + public CommonResult> getBusinessStatusTypeSimpleList() { + List list = businessStatusTypeService.getBusinessStatusTypeList(); + // 过滤掉部门不匹配的 + Long deptId = adminUserApi.getUser(getLoginUserId()).getDeptId(); + list.removeIf(statusType -> CollUtil.isNotEmpty(statusType.getDeptIds()) && !statusType.getDeptIds().contains(deptId)); + return success(BeanUtils.toBean(list, CrmBusinessStatusRespVO.class)); + } + + @GetMapping("/status-simple-list") + @Operation(summary = "获得商机状态列表") + @Parameter(name = "typeId", description = "商机状态组", required = true, example = "1024") + public CommonResult> getBusinessStatusSimpleList(@RequestParam("typeId") Long typeId) { + List list = businessStatusTypeService.getBusinessStatusListByTypeId(typeId); + return success(BeanUtils.toBean(list, CrmBusinessStatusRespVO.Status.class)); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java new file mode 100644 index 0000000000..49cdcb80be --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessRespVO.java @@ -0,0 +1,144 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business.vo.business; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - CRM 商机 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmBusinessRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "32129") + @ExcelProperty("编号") + private Long id; + + @Schema(description = "商机名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @ExcelProperty("商机名称") + private String name; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10299") + private Long customerId; + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @ExcelProperty("客户名称") + private String customerName; + + @Schema(description = "跟进状态", requiredMode = Schema.RequiredMode.REQUIRED, example ="true") + @ExcelProperty("跟进状态") + private Boolean followUpStatus; + + @Schema(description = "最后跟进时间") + @ExcelProperty("最后跟进时间") + private LocalDateTime contactLastTime; + + @Schema(description = "下次联系时间") + @ExcelProperty("下次联系时间") + private LocalDateTime contactNextTime; + + @Schema(description = "负责人的用户编号", example = "25682") + @ExcelProperty("负责人的用户编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714") + private Long statusTypeId; + @Schema(description = "商机状组名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "进行中") + @ExcelProperty("商机状态组") + private String statusTypeName; + + @Schema(description = "商机状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "30320") + private Long statusId; + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "跟进中") + @ExcelProperty("商机状态") + private String statusName; + + @Schema + @ExcelProperty("结束状态") + private Integer endStatus; + + @ExcelProperty("结束时的备注") + private String endRemark; + + @Schema(description = "预计成交日期") + @ExcelProperty("预计成交日期") + private LocalDateTime dealTime; + + @Schema(description = "产品总金额", example = "12025") + @ExcelProperty("产品总金额") + private BigDecimal totalProductPrice; + + @Schema(description = "整单折扣") + @ExcelProperty("整单折扣") + private BigDecimal discountPercent; + + @Schema(description = "商机总金额", example = "12371") + @ExcelProperty("商机总金额") + private BigDecimal totalPrice; + + @Schema(description = "备注", example = "随便") + @ExcelProperty("备注") + private String remark; + + @Schema(description = "创建人", example = "1024") + @ExcelProperty("创建人") + private String creator; + @Schema(description = "创建人名字", example = "芋道源码") + @ExcelProperty("创建人名字") + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + + @Schema(description = "产品列表") + private List products; + + @Schema(description = "产品列表") + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Product { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "888") + private Long id; + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + private Long productId; + @Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private String productName; + @Schema(description = "产品条码", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + private String productNo; + @Schema(description = "产品单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private Integer productUnit; + + @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal productPrice; + + @Schema(description = "商机价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal businessPrice; + + @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + private BigDecimal count; + + @Schema(description = "总计价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal totalPrice; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java new file mode 100644 index 0000000000..9275e0e032 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessSaveReqVO.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business.vo.business; + +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.SysAdminUserParseFunction; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - CRM 商机创建/更新 Request VO") +@Data +public class CrmBusinessSaveReqVO { + + @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED, example = "32129") + private Long id; + + @Schema(description = "商机名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @DiffLogField(name = "商机名称") + @NotNull(message = "商机名称不能为空") + private String name; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10299") + @DiffLogField(name = "客户", function = CrmCustomerParseFunction.NAME) + @NotNull(message = "客户不能为空") + private Long customerId; + + @Schema(description = "下次联系时间") + @DiffLogField(name = "下次联系时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime contactNextTime; + + @Schema(description = "负责人用户编号", example = "14334") + @NotNull(message = "负责人不能为空") + @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME) + private Long ownerUserId; + + @Schema(description = "商机状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "25714") + @DiffLogField(name = "商机状态组") + @NotNull(message = "商机状态组不能为空") + private Long statusTypeId; + + @Schema(description = "预计成交日期") + @DiffLogField(name = "预计成交日期") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime dealTime; + + @Schema(description = "整单折扣", requiredMode = Schema.RequiredMode.REQUIRED, example = "55.00") + @DiffLogField(name = "整单折扣") + @NotNull(message = "整单折扣不能为空") + private BigDecimal discountPercent; + + @Schema(description = "备注", example = "随便") + @DiffLogField(name = "备注") + private String remark; + + @Schema(description = "联系人编号", example = "110") + private Long contactId; // 使用场景,在【联系人详情】添加商机时,如果需要关联两者,需要传递 contactId 字段 + + @Schema(description = "产品列表") + private List products; + + @Schema(description = "产品列表") + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Product { + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + @NotNull(message = "产品编号不能为空") + private Long productId; + + @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + @NotNull(message = "产品单价不能为空") + private BigDecimal productPrice; + + @Schema(description = "商机价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + @NotNull(message = "商机价格不能为空") + private BigDecimal businessPrice; + + @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + @NotNull(message = "产品数量不能为空") + private Integer count; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessUpdateStatusReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessUpdateStatusReqVO.java new file mode 100644 index 0000000000..3fe7d9f47f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/business/CrmBusinessUpdateStatusReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business.vo.business; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.business.CrmBusinessEndStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - CRM 商机更新状态 Request VO") +@Data +public class CrmBusinessUpdateStatusReqVO { + + @Schema(description = "商机编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "32129") + @NotNull(message = "商机编号不能为空") + private Long id; + + @Schema(description = "状态编号", example = "1") + private Long statusId; + + @Schema(description = "结束状态", example = "1") + @InEnum(value = CrmBusinessEndStatusEnum.class) + private Integer endStatus; + + @AssertTrue(message = "变更状态不正确") + public boolean isStatusValid() { + return statusId != null || endStatus != null; + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusRespVO.java new file mode 100644 index 0000000000..a2ee1dfe5a --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 商机状态 Response VO") +@Data +public class CrmBusinessStatusRespVO { + + @Schema(description = "状态组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2934") + private Long id; + + @Schema(description = "状态组名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private String name; + + @Schema(description = "使用的部门编号", requiredMode = Schema.RequiredMode.REQUIRED) + private List deptIds; + @Schema(description = "使用的部门名称", requiredMode = Schema.RequiredMode.REQUIRED) + private List deptNames; + + @Schema(description = "创建人", requiredMode = Schema.RequiredMode.REQUIRED) + private String creator; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "状态集合", requiredMode = Schema.RequiredMode.REQUIRED) + private List statuses; + + @Data + public static class Status { + + @Schema(description = "状态编号", example = "23899") + private Long id; + + @Schema(description = "状态名", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + private String name; + + @Schema(description = "赢单率", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private BigDecimal percent; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusSaveReqVO.java new file mode 100644 index 0000000000..42d6620e90 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/business/vo/status/CrmBusinessStatusSaveReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.crm.controller.admin.business.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.List; + +@Schema(description = "管理后台 - 商机状态组新增/修改 Request VO") +@Data +public class CrmBusinessStatusSaveReqVO { + + @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED, example = "2934") + private Long id; + + @Schema(description = "状态类型名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @NotEmpty(message = "状态类型名不能为空") + private String name; + + @Schema(description = "使用的部门编号") + private List deptIds; + + @Schema(description = "商机状态集合", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "商机状态集合不能为空") + @Valid + private List statuses; + + @Data + public static class Status { + + @Schema(description = "状态编号", example = "23899") + private Long id; + + @Schema(description = "状态名", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + @NotEmpty(message = "状态名不能为空") + private String name; + + @Schema(description = "赢单率", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + @NotNull(message = "赢单率不能为空") + private BigDecimal percent; + + @Schema(description = "排序", hidden = true, example = "1") + private Integer sort; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/CrmClueController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/CrmClueController.java new file mode 100644 index 0000000000..eeae08b985 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/CrmClueController.java @@ -0,0 +1,173 @@ +package cn.iocoder.yudao.module.crm.controller.admin.clue; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmCluePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.clue.CrmClueDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.service.clue.CrmClueService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertListByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static java.util.Collections.singletonList; + +@Tag(name = "管理后台 - 线索") +@RestController +@RequestMapping("/crm/clue") +@Validated +public class CrmClueController { + + @Resource + private CrmClueService clueService; + @Resource + private CrmCustomerService customerService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建线索") + @PreAuthorize("@ss.hasPermission('crm:clue:create')") + public CommonResult createClue(@Valid @RequestBody CrmClueSaveReqVO createReqVO) { + return success(clueService.createClue(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新线索") + @PreAuthorize("@ss.hasPermission('crm:clue:update')") + public CommonResult updateClue(@Valid @RequestBody CrmClueSaveReqVO updateReqVO) { + clueService.updateClue(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除线索") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:clue:delete')") + public CommonResult deleteClue(@RequestParam("id") Long id) { + clueService.deleteClue(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得线索") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:clue:query')") + public CommonResult getClue(@RequestParam("id") Long id) { + CrmClueDO clue = clueService.getClue(id); + return success(buildClueDetail(clue)); + } + + private CrmClueRespVO buildClueDetail(CrmClueDO clue) { + if (clue == null) { + return null; + } + return buildClueDetailList(singletonList(clue)).get(0); + } + + @GetMapping("/page") + @Operation(summary = "获得线索分页") + @PreAuthorize("@ss.hasPermission('crm:clue:query')") + public CommonResult> getCluePage(@Valid CrmCluePageReqVO pageVO) { + PageResult pageResult = clueService.getCluePage(pageVO, getLoginUserId()); + return success(new PageResult<>(buildClueDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出线索 Excel") + @PreAuthorize("@ss.hasPermission('crm:clue:export')") + @OperateLog(type = EXPORT) + public void exportClueExcel(@Valid CrmCluePageReqVO pageReqVO, HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PAGE_SIZE_NONE); + List list = clueService.getCluePage(pageReqVO, getLoginUserId()).getList(); + // 导出 Excel + ExcelUtils.write(response, "线索.xls", "数据", CrmClueRespVO.class, buildClueDetailList(list)); + } + + private List buildClueDetailList(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + // 1.1 获取客户列表 + Map customerMap = customerService.getCustomerMap( + convertSet(list, CrmClueDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(list, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 2. 转换成 VO + return BeanUtils.toBean(list, CrmClueRespVO.class, clueVO -> { + clueVO.setAreaName(AreaUtils.format(clueVO.getAreaId())); + // 2.1 设置客户名称 + MapUtils.findAndThen(customerMap, clueVO.getCustomerId(), customer -> clueVO.setCustomerName(customer.getName())); + // 2.2 设置创建人、负责人名称 + MapUtils.findAndThen(userMap, NumberUtils.parseLong(clueVO.getCreator()), + user -> clueVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, clueVO.getOwnerUserId(), user -> { + clueVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> clueVO.setOwnerUserDeptName(dept.getName())); + }); + }); + } + + @PutMapping("/transfer") + @Operation(summary = "线索转移") + @PreAuthorize("@ss.hasPermission('crm:clue:update')") + public CommonResult transferClue(@Valid @RequestBody CrmClueTransferReqVO reqVO) { + clueService.transferClue(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/transform") + @Operation(summary = "线索转化为客户") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:clue:update')") + public CommonResult transformClue(@RequestParam("id") Long id) { + clueService.transformClue(id, getLoginUserId()); + return success(Boolean.TRUE); + } + + @GetMapping("/follow-count") + @Operation(summary = "获得分配给我的、待跟进的线索数量") + @PreAuthorize("@ss.hasPermission('crm:clue:query')") + public CommonResult getFollowClueCount() { + return success(clueService.getFollowClueCount(getLoginUserId())); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmCluePageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmCluePageReqVO.java new file mode 100644 index 0000000000..a63d946e9d --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmCluePageReqVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.crm.controller.admin.clue.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - 线索分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmCluePageReqVO extends PageParam { + + @Schema(description = "线索名称", example = "线索xxx") + private String name; + + @Schema(description = "转化状态", example = "2048") + private Boolean transformStatus; + + @Schema(description = "电话", example = "18000000000") + private String telephone; + + @Schema(description = "手机号", example = "18000000000") + private String mobile; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "是否为公海数据", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean pool; // null 则表示为不是公海数据 + + @Schema(description = "所属行业", example = "1") + private Integer industryId; + + @Schema(description = "客户等级", example = "1") + private Integer level; + + @Schema(description = "客户来源", example = "1") + private Integer source; + + @Schema(description = "跟进状态", example = "true") + private Boolean followUpStatus; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueRespVO.java new file mode 100644 index 0000000000..56e5c25612 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueRespVO.java @@ -0,0 +1,129 @@ +package cn.iocoder.yudao.module.crm.controller.admin.clue.vo; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.module.infra.enums.DictTypeConstants; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 线索 Response VO") +@Data +@ToString(callSuper = true) +@ExcelIgnoreUnannotated +public class CrmClueRespVO { + + @Schema(description = "编号,主键自增", requiredMode = Schema.RequiredMode.REQUIRED, example = "10969") + @ExcelProperty("编号") + private Long id; + + @Schema(description = "线索名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "线索xxx") + @ExcelProperty("线索名称") + private String name; + + @Schema(description = "跟进状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @ExcelProperty(value = "跟进状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean followUpStatus; + + @Schema(description = "最后跟进时间") + @ExcelProperty("最后跟进时间") + private LocalDateTime contactLastTime; + + @Schema(description = "最后跟进内容", example = "吃饭、睡觉、打逗逗") + @ExcelProperty("最后跟进内容") + private String contactLastContent; + + @Schema(description = "下次联系时间", example = "2023-10-18 01:00:00") + @ExcelProperty("下次联系时间") + private LocalDateTime contactNextTime; + + @Schema(description = "负责人编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "转化状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @ExcelProperty(value = "转化状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean transformStatus; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "520") + private Long customerId; + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户名称") + @ExcelProperty("客户名称") + private String customerName; + + @Schema(description = "手机号", example = "18000000000") + @ExcelProperty("手机号") + private String mobile; + + @Schema(description = "电话", example = "18000000000") + @ExcelProperty("电话") + private String telephone; + + @Schema(description = "QQ", example = "25682") + @ExcelProperty("QQ") + private String qq; + + @Schema(description = "wechat", example = "25682") + @ExcelProperty("wechat") + private String wechat; + + @Schema(description = "email", example = "25682") + @ExcelProperty("email") + private String email; + + @Schema(description = "地区编号", example = "1024") + @ExcelProperty("地区编号") + private Integer areaId; + @Schema(description = "地区名称", example = "北京市") + @ExcelProperty("地区名称") + private String areaName; + @Schema(description = "详细地址", example = "北京市成华大道") + @ExcelProperty("详细地址") + private String detailAddress; + + @Schema(description = "所属行业", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "所属行业", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_INDUSTRY) + private Integer industryId; + + @Schema(description = "客户等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "客户等级", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_LEVEL) + private Integer level; + + @Schema(description = "客户来源", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "客户来源", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_SOURCE) + private Integer source; + + @Schema(description = "备注", example = "随便") + @ExcelProperty("备注") + private String remark; + + @Schema(description = "创建人", example = "1024") + @ExcelProperty("创建人") + private String creator; + @Schema(description = "创建人名字", example = "芋道源码") + @ExcelProperty("创建人名字") + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueSaveReqVO.java new file mode 100644 index 0000000000..aff2ad1eb8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/clue/vo/CrmClueSaveReqVO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.crm.controller.admin.clue.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.common.validation.Mobile; +import cn.iocoder.yudao.framework.common.validation.Telephone; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.module.crm.enums.customer.CrmCustomerLevelEnum; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerIndustryParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerLevelParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerSourceParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.SysAreaParseFunction; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_INDUSTRY; + +@Schema(description = "管理后台 - CRM 线索创建/更新 Request VO") +@Data +public class CrmClueSaveReqVO { + + @Schema(description = "编号", example = "10969") + private Long id; + + @Schema(description = "线索名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "线索xxx") + @DiffLogField(name = "线索名称") + @NotEmpty(message = "线索名称不能为空") + private String name; + + @Schema(description = "最后跟进时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @DiffLogField(name = "最后跟进时间") + private LocalDateTime contactLastTime; + + @Schema(description = "下次联系时间", example = "2023-10-18 01:00:00") + @DiffLogField(name = "下次联系时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime contactNextTime; + + @Schema(description = "负责人编号", example = "2048") + @NotNull(message = "负责人编号不能为空") + private Long ownerUserId; + + @Schema(description = "手机号", example = "18000000000") + @DiffLogField(name = "手机号") + @Mobile + private String mobile; + + @Schema(description = "电话", example = "18000000000") + @DiffLogField(name = "电话") + @Telephone + private String telephone; + + @Schema(description = "QQ", example = "123456789") + @DiffLogField(name = "QQ") + @Size(max = 20, message = "QQ长度不能超过 20 个字符") + private String qq; + + @Schema(description = "微信", example = "123456789") + @DiffLogField(name = "微信") + @Size(max = 255, message = "微信长度不能超过 255 个字符") + private String wechat; + + @Schema(description = "邮箱", example = "123456789@qq.com") + @DiffLogField(name = "邮箱") + @Email(message = "邮箱格式不正确") + @Size(max = 255, message = "邮箱长度不能超过 255 个字符") + private String email; + + @Schema(description = "地区编号", example = "20158") + @DiffLogField(name = "地区编号", function = SysAreaParseFunction.NAME) + private Integer areaId; + + @Schema(description = "详细地址", example = "北京市海淀区") + @DiffLogField(name = "详细地址") + private String detailAddress; + + @Schema(description = "所属行业", example = "1") + @DiffLogField(name = "所属行业", function = CrmCustomerIndustryParseFunction.NAME) + @DictFormat(CRM_CUSTOMER_INDUSTRY) + private Integer industryId; + + @Schema(description = "客户等级", example = "2") + @DiffLogField(name = "客户等级", function = CrmCustomerLevelParseFunction.NAME) + @InEnum(CrmCustomerLevelEnum.class) + private Integer level; + + @Schema(description = "客户来源", example = "3") + @DiffLogField(name = "客户来源", function = CrmCustomerSourceParseFunction.NAME) + private Integer source; + + @Schema(description = "客户描述", example = "任意文字") + @DiffLogField(name = "客户描述") + @Size(max = 4096, message = "客户描述长度不能超过 4096 个字符") + private String description; + + @Schema(description = "备注", example = "随便") + @DiffLogField(name = "备注") + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/CrmContactController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/CrmContactController.java new file mode 100644 index 0000000000..adf58a921f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/CrmContactController.java @@ -0,0 +1,227 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.*; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static java.util.Collections.singletonList; + +@Tag(name = "管理后台 - CRM 联系人") +@RestController +@RequestMapping("/crm/contact") +@Validated +@Slf4j +public class CrmContactController { + + @Resource + private CrmContactService contactService; + @Resource + private CrmCustomerService customerService; + @Resource + private CrmContactBusinessService contactBusinessLinkService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建联系人") + @PreAuthorize("@ss.hasPermission('crm:contact:create')") + public CommonResult createContact(@Valid @RequestBody CrmContactSaveReqVO createReqVO) { + return success(contactService.createContact(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新联系人") + @OperateLog(enable = false) + @PreAuthorize("@ss.hasPermission('crm:contact:update')") + public CommonResult updateContact(@Valid @RequestBody CrmContactSaveReqVO updateReqVO) { + contactService.updateContact(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除联系人") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:contact:delete')") + public CommonResult deleteContact(@RequestParam("id") Long id) { + contactService.deleteContact(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得联系人") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:contact:query')") + public CommonResult getContact(@RequestParam("id") Long id) { + CrmContactDO contact = contactService.getContact(id); + return success(buildContactDetail(contact)); + } + + private CrmContactRespVO buildContactDetail(CrmContactDO contact) { + if (contact == null) { + return null; + } + return buildContactDetailList(singletonList(contact)).get(0); + } + + @GetMapping("/simple-all-list") + @Operation(summary = "获得联系人的精简列表") + @PreAuthorize("@ss.hasPermission('crm:contact:query')") + public CommonResult> getSimpleContactList() { + List list = contactService.getContactList(getLoginUserId()); + return success(convertList(list, contact -> // 只返回 id、name 字段 + new CrmContactRespVO().setId(contact.getId()).setName(contact.getName()) + .setCustomerId(contact.getCustomerId()))); + } + + @GetMapping("/page") + @Operation(summary = "获得联系人分页") + @PreAuthorize("@ss.hasPermission('crm:contact:query')") + public CommonResult> getContactPage(@Valid CrmContactPageReqVO pageVO) { + PageResult pageResult = contactService.getContactPage(pageVO, getLoginUserId()); + return success(new PageResult<>(buildContactDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-customer") + @Operation(summary = "获得联系人分页,基于指定客户") + public CommonResult> getContactPageByCustomer(@Valid CrmContactPageReqVO pageVO) { + Assert.notNull(pageVO.getCustomerId(), "客户编号不能为空"); + PageResult pageResult = contactService.getContactPageByCustomerId(pageVO); + return success(new PageResult<>(buildContactDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-business") + @Operation(summary = "获得联系人分页,基于指定商机") + public CommonResult> getContactPageByBusiness(@Valid CrmContactPageReqVO pageVO) { + Assert.notNull(pageVO.getBusinessId(), "商机编号不能为空"); + PageResult pageResult = contactService.getContactPageByBusinessId(pageVO); + return success(new PageResult<>(buildContactDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出联系人 Excel") + @PreAuthorize("@ss.hasPermission('crm:contact:export')") + @OperateLog(type = EXPORT) + public void exportContactExcel(@Valid CrmContactPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageNo(PAGE_SIZE_NONE); + List list = contactService.getContactPage(exportReqVO, getLoginUserId()).getList(); + ExcelUtils.write(response, "联系人.xls", "数据", CrmContactRespVO.class, buildContactDetailList(list)); + } + + private List buildContactDetailList(List contactList) { + if (CollUtil.isEmpty(contactList)) { + return Collections.emptyList(); + } + // 1.1 获取客户列表 + Map customerMap = customerService.getCustomerMap( + convertSet(contactList, CrmContactDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(contactList, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 1.3 直属上级 Map + Map parentContactMap = contactService.getContactMap( + convertSet(contactList, CrmContactDO::getParentId)); + // 2. 转换成 VO + return BeanUtils.toBean(contactList, CrmContactRespVO.class, contactVO -> { + contactVO.setAreaName(AreaUtils.format(contactVO.getAreaId())); + // 2.1 设置客户名称 + MapUtils.findAndThen(customerMap, contactVO.getCustomerId(), customer -> contactVO.setCustomerName(customer.getName())); + // 2.2 设置创建人、负责人名称 + MapUtils.findAndThen(userMap, NumberUtils.parseLong(contactVO.getCreator()), + user -> contactVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, contactVO.getOwnerUserId(), user -> { + contactVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> contactVO.setOwnerUserDeptName(dept.getName())); + }); + // 2.3 设置直属上级名称 + findAndThen(parentContactMap, contactVO.getParentId(), contact -> contactVO.setParentName(contact.getName())); + }); + } + + @PutMapping("/transfer") + @Operation(summary = "联系人转移") + @PreAuthorize("@ss.hasPermission('crm:contact:update')") + public CommonResult transferContact(@Valid @RequestBody CrmContactTransferReqVO reqVO) { + contactService.transferContact(reqVO, getLoginUserId()); + return success(true); + } + + // ================== 关联/取关商机 =================== + + @PostMapping("/create-business-list") + @Operation(summary = "创建联系人与商机的关联") + @PreAuthorize("@ss.hasPermission('crm:contact:create-business')") + public CommonResult createContactBusinessList(@Valid @RequestBody CrmContactBusinessReqVO createReqVO) { + contactBusinessLinkService.createContactBusinessList(createReqVO); + return success(true); + } + + + @PostMapping("/create-business-list2") + @Operation(summary = "创建联系人与商机的关联") + @PreAuthorize("@ss.hasPermission('crm:contact:create-business')") + public CommonResult createContactBusinessList2(@Valid @RequestBody CrmContactBusiness2ReqVO createReqVO) { + contactBusinessLinkService.createContactBusinessList2(createReqVO); + return success(true); + } + + @DeleteMapping("/delete-business-list") + @Operation(summary = "删除联系人与联系人的关联") + @PreAuthorize("@ss.hasPermission('crm:contact:delete-business')") + public CommonResult deleteContactBusinessList(@Valid @RequestBody CrmContactBusinessReqVO deleteReqVO) { + contactBusinessLinkService.deleteContactBusinessList(deleteReqVO); + return success(true); + } + + @DeleteMapping("/delete-business-list2") + @Operation(summary = "删除联系人与联系人的关联") + @PreAuthorize("@ss.hasPermission('crm:contact:delete-business')") + public CommonResult deleteContactBusinessList(@Valid @RequestBody CrmContactBusiness2ReqVO deleteReqVO) { + contactBusinessLinkService.deleteContactBusinessList2(deleteReqVO); + return success(true); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusiness2ReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusiness2ReqVO.java new file mode 100644 index 0000000000..edc07bf962 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusiness2ReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Schema(description = "管理后台 - CRM 联系人商机 Request VO") // 【商机关联联系人】用于关联,取消关联的操作 +@Data +public class CrmContactBusiness2ReqVO { + + @Schema(description = "商机编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "7638") + @NotNull(message="商机不能为空") + private Long businessId; + + @Schema(description = "联系人编号数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "20878") + @NotEmpty(message="联系人数组不能为空") + private List contactIds; + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusinessReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusinessReqVO.java new file mode 100644 index 0000000000..aaac18e43e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactBusinessReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Schema(description = "管理后台 - CRM 联系人商机 Request VO") // 【联系人关联商机】用于关联,取消关联的操作 +@Data +public class CrmContactBusinessReqVO { + + @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20878") + @NotNull(message="联系人不能为空") + private Long contactId; + + @Schema(description = "商机编号数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "7638") + @NotEmpty(message="商机不能为空") + private List businessIds; + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactPageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactPageReqVO.java new file mode 100644 index 0000000000..6698a3855e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactPageReqVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - CRM 联系人分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmContactPageReqVO extends PageParam { + + @Schema(description = "姓名", example = "芋艿") + private String name; + + @Schema(description = "客户编号", example = "10795") + private Long customerId; + + @Schema(description = "手机号", example = "13898273941") + private String mobile; + + @Schema(description = "电话", example = "021-383773") + private String telephone; + + @Schema(description = "电子邮箱", example = "111@22.com") + private String email; + + @Schema(description = "QQ", example = "3882872") + private Long qq; + + @Schema(description = "微信", example = "zzZ98373") + private String wechat; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "商机编号", example = "10430") + private Long businessId; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactRespVO.java new file mode 100644 index 0000000000..b2b1e83848 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactRespVO.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact.vo; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.module.infra.enums.DictTypeConstants; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 联系人 Response VO") +@Data +@ToString(callSuper = true) +@ExcelIgnoreUnannotated +public class CrmContactRespVO { + + @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED, example = "3167") + private Long id; + + @Schema(description = "联系人姓名", example = "芋艿") + @ExcelProperty(value = "联系人姓名", order = 1) + private String name; + + @Schema(description = "客户编号", example = "10795") + private Long customerId; + @ExcelProperty(value = "客户名称", order = 2) + @Schema(description = "客户名字", example = "test") + private String customerName; + + @Schema(description = "最后跟进时间") + @ExcelProperty(value = "最后跟进时间", order = 6) + private LocalDateTime contactLastTime; + + @Schema(description = "最后跟进内容") + @ExcelProperty(value = "最后跟进内容", order = 6) + private String contactLastContent; + + @Schema(description = "下次联系时间") + @ExcelProperty(value = "下次联系时间", order = 6) + private LocalDateTime contactNextTime; + + @Schema(description = "负责人编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "手机号", example = "1387171766") + @ExcelProperty(value = "手机号", order = 4) + private String mobile; + + @Schema(description = "电话", example = "021-0029922") + @ExcelProperty(value = "电话", order = 4) + private String telephone; + + @Schema(description = "电子邮箱", example = "1111@22.com") + @ExcelProperty(value = "邮箱", order = 4) + private String email; + + @Schema(description = "QQ", example = "197272662") + @ExcelProperty(value = "QQ", order = 4) + private Long qq; + + @Schema(description = "微信", example = "zzz3883") + @ExcelProperty(value = "微信", order = 4) + private String wechat; + + @Schema(description = "地区编号", example = "20158") + private Integer areaId; + @Schema(description = "地区名", example = "上海上海市浦东新区") + @ExcelProperty(value = "地区", order = 5) + private String areaName; + + @Schema(description = "地址") + @ExcelProperty(value = "地址", order = 5) + private String detailAddress; + + @Schema(description = "性别") + @ExcelProperty(value = "性别", converter = DictConvert.class, order = 3) + @DictFormat(cn.iocoder.yudao.module.system.enums.DictTypeConstants.USER_SEX) + private Integer sex; + + @Schema(description = "是否关键决策人") + @ExcelProperty(value = "是否关键决策人", converter = DictConvert.class, order = 3) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean master; + + @Schema(description = "职位") + @ExcelProperty(value = "职位", order = 3) + private String post; + + @Schema(description = "直属上级", example = "23457") + private Long parentId; + @Schema(description = "直属上级名", example = "芋头") + @ExcelProperty(value = "直属上级", order = 4) + private String parentName; + + @Schema(description = "备注", example = "你说的对") + @ExcelProperty(value = "备注", order = 6) + private String remark; + + @Schema(description = "创建人", example = "25682") + private String creator; + @Schema(description = "创建人名字", example = "test") + @ExcelProperty(value = "创建人", order = 8) + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactSaveReqVO.java new file mode 100644 index 0000000000..f16af86bfa --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contact/vo/CrmContactSaveReqVO.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contact.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import cn.iocoder.yudao.framework.common.validation.Telephone; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.*; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; + +@Schema(description = "管理后台 - CRM 联系人创建/更新 Request VO") +@Data +public class CrmContactSaveReqVO { + + @Schema(description = "主键", example = "3167") + private Long id; + + @Schema(description = "姓名", example = "芋艿") + @NotNull(message = "姓名不能为空") + @DiffLogField(name = "姓名") + private String name; + + @Schema(description = "客户编号", example = "10795") + @NotNull(message = "客户编号不能为空") + @DiffLogField(name = "客户", function = CrmCustomerParseFunction.NAME) + private Long customerId; + + @Schema(description = "下次联系时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + @DiffLogField(name = "下次联系时间") + private LocalDateTime contactNextTime; + + @Schema(description = "负责人用户编号", example = "14334") + @NotNull(message = "负责人不能为空") + @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME) + private Long ownerUserId; + + @Schema(description = "手机号", example = "1387171766") + @Mobile + @DiffLogField(name = "手机号") + private String mobile; + + @Schema(description = "电话", example = "021-0029922") + @Telephone + @DiffLogField(name = "电话") + private String telephone; + + @Schema(description = "QQ", example = "197272662") + @DiffLogField(name = "QQ") + private Long qq; + + @Schema(description = "微信", example = "zzz3883") + @DiffLogField(name = "微信") + private String wechat; + + @Schema(description = "电子邮箱", example = "1111@22.com") + @DiffLogField(name = "邮箱") + @Email + private String email; + + @Schema(description = "地区编号", example = "20158") + @DiffLogField(name = "所在地", function = SysAreaParseFunction.NAME) + private Integer areaId; + + @Schema(description = "地址") + @DiffLogField(name = "地址") + private String detailAddress; + + @Schema(description = "性别") + @DiffLogField(name = "性别", function = SysSexParseFunction.NAME) + private Integer sex; + + @Schema(description = "是否关键决策人") + @DiffLogField(name = "关键决策人", function = SysBooleanParseFunction.NAME) + private Boolean master; + + @Schema(description = "职位") + @DiffLogField(name = "职位") + private String post; + + @Schema(description = "直属上级", example = "23457") + @DiffLogField(name = "直属上级", function = CrmContactParseFunction.NAME) + private Long parentId; + + @Schema(description = "备注", example = "你说的对") + @DiffLogField(name = "备注") + private String remark; + + @Schema(description = "关联商机 ID", example = "122233") + private Long businessId; // 注意:该字段用于在【商机】详情界面「新建联系人」时,自动进行关联 + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractConfigController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractConfigController.java new file mode 100644 index 0000000000..369260fcbc --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractConfigController.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config.CrmContractConfigRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config.CrmContractConfigSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - CRM 合同配置") +@RestController +@RequestMapping("/crm/contract-config") +@Validated +public class CrmContractConfigController { + + @Resource + private CrmContractConfigService contractConfigService; + + @GetMapping("/get") + @Operation(summary = "获取合同配置") + @PreAuthorize("@ss.hasPermission('crm:contract-config:query')") + public CommonResult getCustomerPoolConfig() { + CrmContractConfigDO config = contractConfigService.getContractConfig(); + return success(BeanUtils.toBean(config, CrmContractConfigRespVO.class)); + } + + @PutMapping("/save") + @Operation(summary = "更新合同配置") + @PreAuthorize("@ss.hasPermission('crm:contract-config:update')") + public CommonResult saveCustomerPoolConfig(@Valid @RequestBody CrmContractConfigSaveReqVO updateReqVO) { + contractConfigService.saveContractConfig(updateReqVO); + return success(true); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java new file mode 100644 index 0000000000..5939fef4b4 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java @@ -0,0 +1,256 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractProductDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.product.CrmProductService; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static java.util.Collections.singletonList; + +@Tag(name = "管理后台 - CRM 合同") +@RestController +@RequestMapping("/crm/contract") +@Validated +public class CrmContractController { + + @Resource + private CrmContractService contractService; + @Resource + private CrmCustomerService customerService; + @Resource + private CrmContactService contactService; + @Resource + private CrmBusinessService businessService; + @Resource + private CrmProductService productService; + @Resource + private CrmReceivableService receivableService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建合同") + @PreAuthorize("@ss.hasPermission('crm:contract:create')") + public CommonResult createContract(@Valid @RequestBody CrmContractSaveReqVO createReqVO) { + return success(contractService.createContract(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新合同") + @PreAuthorize("@ss.hasPermission('crm:contract:update')") + public CommonResult updateContract(@Valid @RequestBody CrmContractSaveReqVO updateReqVO) { + contractService.updateContract(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除合同") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:contract:delete')") + public CommonResult deleteContract(@RequestParam("id") Long id) { + contractService.deleteContract(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得合同") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:contract:query')") + public CommonResult getContract(@RequestParam("id") Long id) { + CrmContractDO contract = contractService.getContract(id); + return success(buildContractDetail(contract)); + } + + private CrmContractRespVO buildContractDetail(CrmContractDO contract) { + if (contract == null) { + return null; + } + CrmContractRespVO contractVO = buildContractDetailList(singletonList(contract)).get(0); + // 拼接产品项 + List businessProducts = contractService.getContractProductListByContractId(contractVO.getId()); + Map productMap = productService.getProductMap( + convertSet(businessProducts, CrmContractProductDO::getProductId)); + contractVO.setProducts(BeanUtils.toBean(businessProducts, CrmContractRespVO.Product.class, businessProductVO -> + MapUtils.findAndThen(productMap, businessProductVO.getProductId(), + product -> businessProductVO.setProductName(product.getName()) + .setProductNo(product.getNo()).setProductUnit(product.getUnit())))); + return contractVO; + } + + @GetMapping("/page") + @Operation(summary = "获得合同分页") + @PreAuthorize("@ss.hasPermission('crm:contract:query')") + public CommonResult> getContractPage(@Valid CrmContractPageReqVO pageVO) { + PageResult pageResult = contractService.getContractPage(pageVO, getLoginUserId()); + return success(BeanUtils.toBean(pageResult, CrmContractRespVO.class).setList(buildContractDetailList(pageResult.getList()))); + } + + @GetMapping("/page-by-customer") + @Operation(summary = "获得合同分页,基于指定客户") + public CommonResult> getContractPageByCustomer(@Valid CrmContractPageReqVO pageVO) { + Assert.notNull(pageVO.getCustomerId(), "客户编号不能为空"); + PageResult pageResult = contractService.getContractPageByCustomerId(pageVO); + return success(BeanUtils.toBean(pageResult, CrmContractRespVO.class).setList(buildContractDetailList(pageResult.getList()))); + } + + @GetMapping("/page-by-business") + @Operation(summary = "获得合同分页,基于指定商机") + public CommonResult> getContractPageByBusiness(@Valid CrmContractPageReqVO pageVO) { + Assert.notNull(pageVO.getBusinessId(), "商机编号不能为空"); + PageResult pageResult = contractService.getContractPageByBusinessId(pageVO); + return success(BeanUtils.toBean(pageResult, CrmContractRespVO.class).setList(buildContractDetailList(pageResult.getList()))); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出合同 Excel") + @PreAuthorize("@ss.hasPermission('crm:contract:export')") + @OperateLog(type = EXPORT) + public void exportContractExcel(@Valid CrmContractPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + PageResult pageResult = contractService.getContractPage(exportReqVO, getLoginUserId()); + // 导出 Excel + ExcelUtils.write(response, "合同.xls", "数据", CrmContractRespVO.class, + BeanUtils.toBean(pageResult.getList(), CrmContractRespVO.class)); + } + + @PutMapping("/transfer") + @Operation(summary = "合同转移") + @PreAuthorize("@ss.hasPermission('crm:contract:update')") + public CommonResult transferContract(@Valid @RequestBody CrmContractTransferReqVO reqVO) { + contractService.transferContract(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交合同审批") + @PreAuthorize("@ss.hasPermission('crm:contract:update')") + public CommonResult submitContract(@RequestParam("id") Long id) { + contractService.submitContract(id, getLoginUserId()); + return success(true); + } + + private List buildContractDetailList(List contractList) { + if (CollUtil.isEmpty(contractList)) { + return Collections.emptyList(); + } + // 1.1 获取客户列表 + Map customerMap = customerService.getCustomerMap( + convertSet(contractList, CrmContractDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(contractList, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 1.3 获取联系人 + Map contactMap = convertMap(contactService.getContactList(convertSet(contractList, + CrmContractDO::getSignContactId)), CrmContactDO::getId); + // 1.4 获取商机 + Map businessMap = businessService.getBusinessMap( + convertSet(contractList, CrmContractDO::getBusinessId)); + // 1.5 获得已回款金额 + Map receivablePriceMap = receivableService.getReceivablePriceMapByContractId( + convertSet(contractList, CrmContractDO::getId)); + // 2. 拼接数据 + return BeanUtils.toBean(contractList, CrmContractRespVO.class, contractVO -> { + // 2.1 设置客户信息 + findAndThen(customerMap, contractVO.getCustomerId(), customer -> contractVO.setCustomerName(customer.getName())); + // 2.2 设置用户信息 + findAndThen(userMap, Long.parseLong(contractVO.getCreator()), user -> contractVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, contractVO.getOwnerUserId(), user -> { + contractVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> contractVO.setOwnerUserDeptName(dept.getName())); + }); + findAndThen(userMap, contractVO.getSignUserId(), user -> contractVO.setSignUserName(user.getNickname())); + // 2.3 设置联系人信息 + findAndThen(contactMap, contractVO.getSignContactId(), contact -> contractVO.setSignContactName(contact.getName())); + // 2.4 设置商机信息 + findAndThen(businessMap, contractVO.getBusinessId(), business -> contractVO.setBusinessName(business.getName())); + // 2.5 设置已回款金额 + contractVO.setTotalReceivablePrice(receivablePriceMap.getOrDefault(contractVO.getId(), BigDecimal.ZERO)); + }); + } + + @GetMapping("/audit-count") + @Operation(summary = "获得待审核合同数量") + @PreAuthorize("@ss.hasPermission('crm:contract:query')") + public CommonResult getAuditContractCount() { + return success(contractService.getAuditContractCount(getLoginUserId())); + } + + @GetMapping("/remind-count") + @Operation(summary = "获得即将到期(提醒)的合同数量") + @PreAuthorize("@ss.hasPermission('crm:contract:query')") + public CommonResult getRemindContractCount() { + return success(contractService.getRemindContractCount(getLoginUserId())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得合同精简列表", description = "只包含的合同,主要用于前端的下拉选项") + @Parameter(name = "customerId", description = "客户编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:contract:query')") + public CommonResult> getContractSimpleList(@RequestParam("customerId") Long customerId) { + CrmContractPageReqVO pageReqVO = new CrmContractPageReqVO().setCustomerId(customerId); + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); // 不分页 + PageResult pageResult = contractService.getContractPageByCustomerId(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(Collections.emptyList()); + } + // 拼接数据 + Map receivablePriceMap = receivableService.getReceivablePriceMapByContractId( + convertSet(pageResult.getList(), CrmContractDO::getId)); + return success(convertList(pageResult.getList(), contract -> new CrmContractRespVO() // 只返回 id、name 等精简字段 + .setId(contract.getId()).setName(contract.getName()).setAuditStatus(contract.getAuditStatus()) + .setTotalPrice(contract.getTotalPrice()) + .setTotalReceivablePrice(receivablePriceMap.getOrDefault(contract.getId(), BigDecimal.ZERO)))); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigRespVO.java new file mode 100644 index 0000000000..c39cf92692 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - CRM 合同配置 Response VO") +@Data +public class CrmContractConfigRespVO { + + @Schema(description = "是否开启提前提醒", example = "true") + private Boolean notifyEnabled; + + @Schema(description = "提前提醒天数", example = "2") + private Integer notifyDays; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigSaveReqVO.java new file mode 100644 index 0000000000..2eb79d8e0a --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/config/CrmContractConfigSaveReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config; + +import cn.hutool.core.util.BooleanUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.AssertTrue; +import java.util.Objects; + +@Schema(description = "管理后台 - CRM 合同配置 Request VO") +@Data +public class CrmContractConfigSaveReqVO { + + @Schema(description = "是否开启提前提醒", example = "true") + @DiffLogField(name = "是否开启提前提醒") + private Boolean notifyEnabled; + + @Schema(description = "提前提醒天数", example = "2") + @DiffLogField(name = "提前提醒天数") + private Integer notifyDays; + + @AssertTrue(message = "提前提醒天数不能为空") + @JsonIgnore + public boolean isNotifyDaysValid() { + if (!BooleanUtil.isTrue(getNotifyEnabled())) { + return true; + } + return Objects.nonNull(getNotifyDays()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractPageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractPageReqVO.java new file mode 100644 index 0000000000..74f8008b8f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractPageReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - CRM 合同分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmContractPageReqVO extends PageParam { + + /** + * 过期类型 - 即将过期 + */ + public static final Integer EXPIRY_TYPE_ABOUT_TO_EXPIRE = 1; + /** + * 过期类型 - 已过期 + */ + public static final Integer EXPIRY_TYPE_EXPIRED = 2; + + @Schema(description = "合同编号", example = "XYZ008") + private String no; + + @Schema(description = "合同名称", example = "王五") + private String name; + + @Schema(description = "客户编号", example = "18336") + private Long customerId; + + @Schema(description = "商机编号", example = "10864") + private Long businessId; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "审批状态", example = "20") + @InEnum(CrmAuditStatusEnum.class) + private Integer auditStatus; + + @Schema(description = "过期类型", example = "1") + private Integer expiryType; // 过期类型,为 null 时则表示全部 + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java new file mode 100644 index 0000000000..a01bc110b9 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractRespVO.java @@ -0,0 +1,162 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - CRM 合同 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmContractRespVO { + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + @ExcelProperty("合同编号") + private Long id; + + @Schema(description = "合同名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + @ExcelProperty("合同名称") + private String name; + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20230101") + @ExcelProperty("合同编号") + private String no; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336") + @ExcelProperty("客户编号") + private Long customerId; + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336") + @ExcelProperty("客户名称") + private String customerName; + + @Schema(description = "商机编号", example = "10864") + @ExcelProperty("商机编号") + private Long businessId; + @Schema(description = "商机名称", example = "10864") + @ExcelProperty("商机名称") + private String businessName; + + @Schema(description = "最后跟进时间") + @ExcelProperty("最后跟进时间") + private LocalDateTime contactLastTime; + + @Schema(description = "负责人的用户编号", example = "25682") + @ExcelProperty("负责人的用户编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "工作流编号", example = "1043") + @ExcelProperty("工作流编号") + private String processInstanceId; + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @ExcelProperty("审批状态") + private Integer auditStatus; + + @Schema(description = "下单日期", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("下单日期") + private LocalDateTime orderDate; + + @Schema(description = "开始时间") + @ExcelProperty("开始时间") + private LocalDateTime startTime; + + @Schema(description = "结束时间") + @ExcelProperty("结束时间") + private LocalDateTime endTime; + + @Schema(description = "产品总金额", example = "19510") + @ExcelProperty("产品总金额") + private BigDecimal totalProductPrice; + + @Schema(description = "整单折扣") + @ExcelProperty("整单折扣") + private BigDecimal discountPercent; + + @Schema(description = "合同金额", example = "5617") + @ExcelProperty("合同金额") + private BigDecimal totalPrice; + + @Schema(description = "已回款金额", example = "5617") + @ExcelProperty("已回款金额") + private BigDecimal totalReceivablePrice; + + @Schema(description = "客户签约人编号", example = "18546") + private Long signContactId; + @Schema(description = "客户签约人", example = "小豆") + @ExcelProperty("客户签约人") + private String signContactName; + + @Schema(description = "公司签约人", example = "14036") + private Long signUserId; + @Schema(description = "公司签约人", example = "小明") + @ExcelProperty("公司签约人") + private String signUserName; + + @Schema(description = "备注", example = "你猜") + @ExcelProperty("备注") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "创建人", example = "25682") + @ExcelProperty("创建人") + private String creator; + + @Schema(description = "创建人名字", example = "test") + @ExcelProperty("创建人名字") + private String creatorName; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + + @Schema(description = "产品列表") + private List products; + + @Schema(description = "产品列表") + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Product { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "888") + private Long id; + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + private Long productId; + @Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private String productName; + @Schema(description = "产品条码", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + private String productNo; + @Schema(description = "产品单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private Integer productUnit; + + @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal productPrice; + + @Schema(description = "合同价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal contractPrice; + + @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + private BigDecimal count; + + @Schema(description = "总计价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + private BigDecimal totalPrice; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java new file mode 100644 index 0000000000..9e06296f0f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract; + +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmBusinessParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmContactParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.SysAdminUserParseFunction; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - CRM 合同创建/更新 Request VO") +@Data +public class CrmContractSaveReqVO { + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + private Long id; + + @Schema(description = "合同名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + @DiffLogField(name = "合同名称") + @NotNull(message = "合同名称不能为空") + private String name; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336") + @DiffLogField(name = "客户", function = CrmCustomerParseFunction.NAME) + @NotNull(message = "客户编号不能为空") + private Long customerId; + + @Schema(description = "商机编号", example = "10864") + @DiffLogField(name = "商机", function = CrmBusinessParseFunction.NAME) + private Long businessId; + + @Schema(description = "负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17144") + @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME) + @NotNull(message = "负责人不能为空") + private Long ownerUserId; + + @Schema(description = "下单日期", requiredMode = Schema.RequiredMode.REQUIRED) + @DiffLogField(name = "下单日期") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @NotNull(message = "下单日期不能为空") + private LocalDateTime orderDate; + + @Schema(description = "开始时间") + @DiffLogField(name = "开始时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "结束时间") + @DiffLogField(name = "结束时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "整单折扣", requiredMode = Schema.RequiredMode.REQUIRED, example = "55.00") + @DiffLogField(name = "整单折扣") + @NotNull(message = "整单折扣不能为空") + private BigDecimal discountPercent; + + @Schema(description = "合同金额", example = "5617") + @DiffLogField(name = "合同金额") + private BigDecimal totalPrice; + + @Schema(description = "客户签约人编号", example = "18546") + @DiffLogField(name = "客户签约人", function = CrmContactParseFunction.NAME) + private Long signContactId; + + @Schema(description = "公司签约人", example = "14036") + @DiffLogField(name = "公司签约人", function = SysAdminUserParseFunction.NAME) + private Long signUserId; + + @Schema(description = "备注", example = "你猜") + @DiffLogField(name = "备注") + private String remark; + + @Schema(description = "产品列表") + private List products; + + @Schema(description = "产品列表") + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Product { + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "20529") + @NotNull(message = "产品编号不能为空") + private Long productId; + + @Schema(description = "产品单价", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + @NotNull(message = "产品单价不能为空") + private BigDecimal productPrice; + + @Schema(description = "合同价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "123.00") + @NotNull(message = "合同价格不能为空") + private BigDecimal contractPrice; + + @Schema(description = "产品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + @NotNull(message = "产品数量不能为空") + private Integer count; + + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractTransferReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractTransferReqVO.java new file mode 100644 index 0000000000..57c9fe65a2 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractTransferReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - CRM 合同转移 Request VO") +@Data +public class CrmContractTransferReqVO { + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + @NotNull(message = "联系人编号不能为空") + private Long id; + + @Schema(description = "新负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + @NotNull(message = "新负责人的用户编号不能为空") + private Long newOwnerUserId; + + @Schema(description = "老负责人加入团队后的权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @InEnum(value = CrmPermissionLevelEnum.class) + private Integer oldOwnerPermissionLevel; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerController.java new file mode 100644 index 0000000000..d661f6366e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerController.java @@ -0,0 +1,342 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.enums.ExcelColumn; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.ip.core.Area; +import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.*; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerPoolConfigDO; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerPoolConfigService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.*; +import static java.util.Collections.singletonList; + +@Tag(name = "管理后台 - CRM 客户") +@RestController +@RequestMapping("/crm/customer") +@Validated +public class CrmCustomerController { + + @Resource + private CrmCustomerService customerService; + @Resource + private CrmCustomerPoolConfigService customerPoolConfigService; + + @Resource + private DeptApi deptApi; + @Resource + private AdminUserApi adminUserApi; + @Resource + private DictDataApi dictDataApi; + + @PostMapping("/create") + @Operation(summary = "创建客户") + @PreAuthorize("@ss.hasPermission('crm:customer:create')") + public CommonResult createCustomer(@Valid @RequestBody CrmCustomerSaveReqVO createReqVO) { + return success(customerService.createCustomer(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新客户") + @PreAuthorize("@ss.hasPermission('crm:customer:update')") + public CommonResult updateCustomer(@Valid @RequestBody CrmCustomerSaveReqVO updateReqVO) { + customerService.updateCustomer(updateReqVO); + return success(true); + } + + @PutMapping("/update-deal-status") + @Operation(summary = "更新客户的成交状态") + @Parameters({ + @Parameter(name = "id", description = "客户编号", required = true), + @Parameter(name = "dealStatus", description = "成交状态", required = true) + }) + public CommonResult updateCustomerDealStatus(@RequestParam("id") Long id, + @RequestParam("dealStatus") Boolean dealStatus) { + customerService.updateCustomerDealStatus(id, dealStatus); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除客户") + @Parameter(name = "id", description = "客户编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:customer:delete')") + public CommonResult deleteCustomer(@RequestParam("id") Long id) { + customerService.deleteCustomer(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得客户") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult getCustomer(@RequestParam("id") Long id) { + // 1. 获取客户 + CrmCustomerDO customer = customerService.getCustomer(id); + // 2. 拼接数据 + return success(buildCustomerDetail(customer)); + } + + public CrmCustomerRespVO buildCustomerDetail(CrmCustomerDO customer) { + if (customer == null) { + return null; + } + return buildCustomerDetailList(singletonList(customer)).get(0); + } + + @GetMapping("/page") + @Operation(summary = "获得客户分页") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult> getCustomerPage(@Valid CrmCustomerPageReqVO pageVO) { + // 1. 查询客户分页 + PageResult pageResult = customerService.getCustomerPage(pageVO, getLoginUserId()); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 2. 拼接数据 + return success(new PageResult<>(buildCustomerDetailList(pageResult.getList()), pageResult.getTotal())); + } + + public List buildCustomerDetailList(List list) { + if (CollUtil.isEmpty(list)) { + return java.util.Collections.emptyList(); + } + // 1.1 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(list, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 1.2 获取距离进入公海的时间 + Map poolDayMap = getPoolDayMap(list); + // 2. 转换成 VO + return BeanUtils.toBean(list, CrmCustomerRespVO.class, customerVO -> { + customerVO.setAreaName(AreaUtils.format(customerVO.getAreaId())); + // 2.1 设置创建人、负责人名称 + MapUtils.findAndThen(userMap, NumberUtils.parseLong(customerVO.getCreator()), + user -> customerVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, customerVO.getOwnerUserId(), user -> { + customerVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> customerVO.setOwnerUserDeptName(dept.getName())); + }); + // 2.2 设置距离进入公海的时间 + if (customerVO.getOwnerUserId() != null) { + customerVO.setPoolDay(poolDayMap.get(customerVO.getId())); + } + }); + } + + @GetMapping("/put-pool-remind-page") + @Operation(summary = "获得待进入公海客户分页") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult> getPutPoolRemindCustomerPage(@Valid CrmCustomerPageReqVO pageVO) { + // 1. 查询客户分页 + PageResult pageResult = customerService.getPutPoolRemindCustomerPage(pageVO, getLoginUserId()); + // 2. 拼接数据 + return success(new PageResult<>(buildCustomerDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/put-pool-remind-count") + @Operation(summary = "获得待进入公海客户数量") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult getPutPoolRemindCustomerCount() { + return success(customerService.getPutPoolRemindCustomerCount(getLoginUserId())); + } + + @GetMapping("/today-contact-count") + @Operation(summary = "获得今日需联系客户数量") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult getTodayContactCustomerCount() { + return success(customerService.getTodayContactCustomerCount(getLoginUserId())); + } + + @GetMapping("/follow-count") + @Operation(summary = "获得分配给我、待跟进的线索数量的客户数量") + @PreAuthorize("@ss.hasPermission('crm:customer:query')") + public CommonResult getFollowCustomerCount() { + return success(customerService.getFollowCustomerCount(getLoginUserId())); + } + + /** + * 获取距离进入公海的时间 Map + * + * @param list 客户列表 + * @return key 客户编号, value 距离进入公海的时间 + */ + private Map getPoolDayMap(List list) { + CrmCustomerPoolConfigDO poolConfig = customerPoolConfigService.getCustomerPoolConfig(); + if (poolConfig == null || !poolConfig.getEnabled()) { + return MapUtil.empty(); + } + list = CollectionUtils.filterList(list, customer -> { + // 特殊:如果没负责人,则说明已经在公海,不用计算 + if (customer.getOwnerUserId() == null) { + return false; + } + // 已成交 or 已锁定,不进入公海 + return !customer.getDealStatus() && !customer.getLockStatus(); + }); + return convertMap(list, CrmCustomerDO::getId, customer -> { + // 1.1 未成交放入公海天数 + long dealExpireDay = poolConfig.getDealExpireDays() - LocalDateTimeUtils.between(customer.getOwnerTime()); + // 1.2 未跟进放入公海天数 + LocalDateTime lastTime = customer.getOwnerTime(); + if (customer.getContactLastTime() != null && customer.getContactLastTime().isAfter(lastTime)) { + lastTime = customer.getContactLastTime(); + } + long contactExpireDay = poolConfig.getContactExpireDays() - LocalDateTimeUtils.between(lastTime); + // 2. 返回最小的天数 + long poolDay = Math.min(dealExpireDay, contactExpireDay); + return poolDay > 0 ? poolDay : 0; + }); + } + + @GetMapping(value = "/simple-list") + @Operation(summary = "获取客户精简信息列表", description = "只包含有读权限的客户,主要用于前端的下拉选项") + public CommonResult> getCustomerSimpleList() { + CrmCustomerPageReqVO reqVO = new CrmCustomerPageReqVO(); + reqVO.setPageSize(PAGE_SIZE_NONE); // 不分页 + List list = customerService.getCustomerPage(reqVO, getLoginUserId()).getList(); + return success(convertList(list, customer -> // 只返回 id、name 精简字段 + new CrmCustomerRespVO().setId(customer.getId()).setName(customer.getName()))); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出客户 Excel") + @PreAuthorize("@ss.hasPermission('crm:customer:export')") + @OperateLog(type = EXPORT) + public void exportCustomerExcel(@Valid CrmCustomerPageReqVO pageVO, + HttpServletResponse response) throws IOException { + pageVO.setPageSize(PAGE_SIZE_NONE); // 不分页 + List list = customerService.getCustomerPage(pageVO, getLoginUserId()).getList(); + // 导出 Excel + ExcelUtils.write(response, "客户.xls", "数据", CrmCustomerRespVO.class, + buildCustomerDetailList(list)); + } + + @GetMapping("/get-import-template") + @Operation(summary = "获得导入客户模板") + public void importTemplate(HttpServletResponse response) throws IOException { + // 手动创建导出 demo + List list = Arrays.asList( + CrmCustomerImportExcelVO.builder().name("芋道").industryId(1).level(1).source(1) + .mobile("15601691300").telephone("").qq("").wechat("").email("yunai@iocoder.cn") + .areaId(null).detailAddress("").remark("").build(), + CrmCustomerImportExcelVO.builder().name("源码").industryId(1).level(1).source(1) + .mobile("15601691300").telephone("").qq("").wechat("").email("yunai@iocoder.cn") + .areaId(null).detailAddress("").remark("").build() + ); + // 输出 + ExcelUtils.write(response, "客户导入模板.xls", "客户列表", CrmCustomerImportExcelVO.class, list, builderSelectMap()); + } + + private List>> builderSelectMap() { + List>> selectMap = new ArrayList<>(); + // 获取地区下拉数据 + // TODO @puhui999:嘿嘿,这里改成省份、城市、区域,三个选项,难度大么? + Area area = AreaUtils.getArea(Area.ID_CHINA); + selectMap.add(new KeyValue<>(ExcelColumn.G, AreaUtils.getAreaNodePathList(area.getChildren()))); + // 获取客户所属行业 + List customerIndustries = dictDataApi.getDictDataLabelList(CRM_CUSTOMER_INDUSTRY); + selectMap.add(new KeyValue<>(ExcelColumn.I, customerIndustries)); + // 获取客户等级 + List customerLevels = dictDataApi.getDictDataLabelList(CRM_CUSTOMER_LEVEL); + selectMap.add(new KeyValue<>(ExcelColumn.J, customerLevels)); + // 获取客户来源 + List customerSources = dictDataApi.getDictDataLabelList(CRM_CUSTOMER_SOURCE); + selectMap.add(new KeyValue<>(ExcelColumn.K, customerSources)); + return selectMap; + } + + @PostMapping("/import") + @Operation(summary = "导入客户") + @PreAuthorize("@ss.hasPermission('system:customer:import')") + public CommonResult importExcel(@Valid @RequestBody CrmCustomerImportReqVO importReqVO) + throws Exception { + List list = ExcelUtils.read(importReqVO.getFile(), CrmCustomerImportExcelVO.class); + return success(customerService.importCustomerList(list, importReqVO)); + } + + @PutMapping("/transfer") + @Operation(summary = "转移客户") + @PreAuthorize("@ss.hasPermission('crm:customer:update')") + public CommonResult transferCustomer(@Valid @RequestBody CrmCustomerTransferReqVO reqVO) { + customerService.transferCustomer(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/lock") + @Operation(summary = "锁定/解锁客户") + @PreAuthorize("@ss.hasPermission('crm:customer:update')") + public CommonResult lockCustomer(@Valid @RequestBody CrmCustomerLockReqVO lockReqVO) { + customerService.lockCustomer(lockReqVO, getLoginUserId()); + return success(true); + } + + // ==================== 公海相关操作 ==================== + + @PutMapping("/put-pool") + @Operation(summary = "数据放入公海") + @Parameter(name = "id", description = "客户编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:customer:update')") + public CommonResult putCustomerPool(@RequestParam("id") Long id) { + customerService.putCustomerPool(id); + return success(true); + } + + @PutMapping("/receive") + @Operation(summary = "领取公海客户") + @Parameter(name = "ids", description = "编号数组", required = true, example = "1,2,3") + @PreAuthorize("@ss.hasPermission('crm:customer:receive')") + public CommonResult receiveCustomer(@RequestParam(value = "ids") List ids) { + customerService.receiveCustomer(ids, getLoginUserId(), Boolean.TRUE); + return success(true); + } + + @PutMapping("/distribute") + @Operation(summary = "分配公海给对应负责人") + @PreAuthorize("@ss.hasPermission('crm:customer:distribute')") + public CommonResult distributeCustomer(@Valid @RequestBody CrmCustomerDistributeReqVO distributeReqVO) { + customerService.receiveCustomer(distributeReqVO.getIds(), distributeReqVO.getOwnerUserId(), Boolean.FALSE); + return success(true); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerLimitConfigController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerLimitConfigController.java new file mode 100644 index 0000000000..82a326c054 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/CrmCustomerLimitConfigController.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.limitconfig.CrmCustomerLimitConfigPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.limitconfig.CrmCustomerLimitConfigRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.limitconfig.CrmCustomerLimitConfigSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerLimitConfigDO; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerLimitConfigService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.Collection; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - CRM 客户限制配置") +@RestController +@RequestMapping("/crm/customer-limit-config") +@Validated +public class CrmCustomerLimitConfigController { + + @Resource + private CrmCustomerLimitConfigService customerLimitConfigService; + + @Resource + private DeptApi deptApi; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建客户限制配置") + @PreAuthorize("@ss.hasPermission('crm:customer-limit-config:create')") + public CommonResult createCustomerLimitConfig(@Valid @RequestBody CrmCustomerLimitConfigSaveReqVO createReqVO) { + return success(customerLimitConfigService.createCustomerLimitConfig(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新客户限制配置") + @PreAuthorize("@ss.hasPermission('crm:customer-limit-config:update')") + public CommonResult updateCustomerLimitConfig(@Valid @RequestBody CrmCustomerLimitConfigSaveReqVO updateReqVO) { + customerLimitConfigService.updateCustomerLimitConfig(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除客户限制配置") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:customer-limit-config:delete')") + public CommonResult deleteCustomerLimitConfig(@RequestParam("id") Long id) { + customerLimitConfigService.deleteCustomerLimitConfig(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得客户限制配置") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:customer-limit-config:query')") + public CommonResult getCustomerLimitConfig(@RequestParam("id") Long id) { + CrmCustomerLimitConfigDO limitConfig = customerLimitConfigService.getCustomerLimitConfig(id); + // 拼接数据 + Map userMap = adminUserApi.getUserMap(limitConfig.getUserIds()); + Map deptMap = deptApi.getDeptMap(limitConfig.getDeptIds()); + return success(BeanUtils.toBean(limitConfig, CrmCustomerLimitConfigRespVO.class, configVO -> { + configVO.setUsers(CollectionUtils.convertList(configVO.getUserIds(), userMap::get)); + configVO.setDepts(CollectionUtils.convertList(configVO.getDeptIds(), deptMap::get)); + })); + } + + @GetMapping("/page") + @Operation(summary = "获得客户限制配置分页") + @PreAuthorize("@ss.hasPermission('crm:customer-limit-config:query')") + public CommonResult> getCustomerLimitConfigPage(@Valid CrmCustomerLimitConfigPageReqVO pageVO) { + PageResult pageResult = customerLimitConfigService.getCustomerLimitConfigPage(pageVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 拼接数据 + Map userMap = adminUserApi.getUserMap( + convertSetByFlatMap(pageResult.getList(), CrmCustomerLimitConfigDO::getUserIds, Collection::stream)); + Map deptMap = deptApi.getDeptMap( + convertSetByFlatMap(pageResult.getList(), CrmCustomerLimitConfigDO::getDeptIds, Collection::stream)); + return success(BeanUtils.toBean(pageResult, CrmCustomerLimitConfigRespVO.class, configVO -> { + configVO.setUsers(CollectionUtils.convertList(configVO.getUserIds(), userMap::get)); + configVO.setDepts(CollectionUtils.convertList(configVO.getDeptIds(), deptMap::get)); + })); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerDistributeReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerDistributeReqVO.java new file mode 100644 index 0000000000..4f8b0d4e86 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerDistributeReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Schema(description = "管理后台 - CRM 客户分配公海给对应负责人 Request VO") +@Data +public class CrmCustomerDistributeReqVO { + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1024]") + @NotEmpty(message = "客户编号不能为空") + private List ids; + + @Schema(description = "负责人", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "负责人不能为空") + private Long ownerUserId; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportExcelVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportExcelVO.java new file mode 100644 index 0000000000..2c39472fd6 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportExcelVO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.AreaConvert; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.*; + +/** + * 客户 Excel 导入 VO + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Accessors(chain = false) // 设置 chain = false,避免用户导入有问题 +public class CrmCustomerImportExcelVO { + + @ExcelProperty("客户名称") + private String name; + + @ExcelProperty("手机") + private String mobile; + + @ExcelProperty("电话") + private String telephone; + + @ExcelProperty("QQ") + private String qq; + + @ExcelProperty("微信") + private String wechat; + + @ExcelProperty("邮箱") + private String email; + + @ExcelProperty(value = "地区", converter = AreaConvert.class) + private Integer areaId; + + @ExcelProperty("详细地址") + private String detailAddress; + + @ExcelProperty(value = "所属行业", converter = DictConvert.class) + @DictFormat(CRM_CUSTOMER_INDUSTRY) + private Integer industryId; + + @ExcelProperty(value = "客户等级", converter = DictConvert.class) + @DictFormat(CRM_CUSTOMER_LEVEL) + private Integer level; + + @ExcelProperty(value = "客户来源", converter = DictConvert.class) + @DictFormat(CRM_CUSTOMER_SOURCE) + private Integer source; + + @ExcelProperty("备注") + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportReqVO.java new file mode 100644 index 0000000000..bbf7163935 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 客户导入 Request VO") +@Data +@Builder +public class CrmCustomerImportReqVO { + + @Schema(description = "Excel 文件", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "Excel 文件不能为空") + private MultipartFile file; + + @Schema(description = "是否支持更新", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "是否支持更新不能为空") + private Boolean updateSupport; + + @Schema(description = "负责人", example = "1") + private Long ownerUserId; // 为 null 则客户进入公海 + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportRespVO.java new file mode 100644 index 0000000000..dda5bc5d11 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerImportRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 客户导入 Response VO") +@Data +@Builder +public class CrmCustomerImportRespVO { + + @Schema(description = "创建成功的客户名数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List createCustomerNames; + + @Schema(description = "更新成功的客户名数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List updateCustomerNames; + + @Schema(description = "导入失败的客户集合,key 为客户名,value 为失败原因", requiredMode = Schema.RequiredMode.REQUIRED) + private Map failureCustomerNames; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerLockReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerLockReqVO.java new file mode 100644 index 0000000000..10bf2e10af --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerLockReqVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - CRM 客户锁定/解锁 Request VO") +@Data +public class CrmCustomerLockReqVO { + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + private Long id; + + @Schema(description = "客户锁定状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Boolean lockStatus; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerPageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerPageReqVO.java new file mode 100644 index 0000000000..73af5d6b13 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerPageReqVO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - CRM 客户分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmCustomerPageReqVO extends PageParam { + + /** + * 联系状态 - 今日需联系 + */ + public static final int CONTACT_TODAY = 1; + /** + * 联系状态 - 已逾期 + */ + public static final int CONTACT_EXPIRED = 2; + /** + * 联系状态 - 已联系 + */ + public static final int CONTACT_ALREADY = 3; + + @Schema(description = "客户名称", example = "赵六") + private String name; + + @Schema(description = "手机", example = "18000000000") + private String mobile; + + @Schema(description = "所属行业", example = "1") + private Integer industryId; + + @Schema(description = "客户等级", example = "1") + private Integer level; + + @Schema(description = "客户来源", example = "1") + private Integer source; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "是否为公海数据", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean pool; // null 则表示为不是公海数据 + + @Schema(description = "联系状态", example = "1") + private Integer contactStatus; // backlog 查询条件 + + @Schema(description = "跟进状态", example = "true") + private Boolean followUpStatus; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerRespVO.java new file mode 100644 index 0000000000..236129918c --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerRespVO.java @@ -0,0 +1,130 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.module.infra.enums.DictTypeConstants; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 客户 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmCustomerRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty("编号") + private Long id; + + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty("客户名称") + private String name; + + @Schema(description = "跟进状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "跟进状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean followUpStatus; + + @Schema(description = "最后跟进时间") + @ExcelProperty("最后跟进时间") + private LocalDateTime contactLastTime; + + @Schema(description = "最后跟进内容", example = "吃饭、睡觉、打逗逗") + @ExcelProperty("最后跟进内容") + private String contactLastContent; + + @Schema(description = "下次联系时间") + @ExcelProperty("下次联系时间") + private LocalDateTime contactNextTime; + + @Schema(description = "负责人的用户编号", example = "25682") + @ExcelProperty("负责人的用户编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "锁定状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "锁定状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean lockStatus; + + @Schema(description = "成交状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "成交状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BOOLEAN_STRING) + private Boolean dealStatus; + + @Schema(description = "手机", example = "25682") + @ExcelProperty("手机") + private String mobile; + + @Schema(description = "电话", example = "25682") + @ExcelProperty("电话") + private String telephone; + + @Schema(description = "QQ", example = "25682") + @ExcelProperty("QQ") + private String qq; + + @Schema(description = "wechat", example = "25682") + @ExcelProperty("wechat") + private String wechat; + + @Schema(description = "email", example = "25682") + @ExcelProperty("email") + private String email; + + @Schema(description = "地区编号", example = "1024") + @ExcelProperty("地区编号") + private Integer areaId; + @Schema(description = "地区名称", example = "北京市") + @ExcelProperty("地区名称") + private String areaName; + @Schema(description = "详细地址", example = "北京市成华大道") + @ExcelProperty("详细地址") + private String detailAddress; + + @Schema(description = "所属行业", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "所属行业", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_INDUSTRY) + private Integer industryId; + + @Schema(description = "客户等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "客户等级", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_LEVEL) + private Integer level; + + @Schema(description = "客户来源", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @ExcelProperty(value = "客户来源", converter = DictConvert.class) + @DictFormat(cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_SOURCE) + private Integer source; + + @Schema(description = "负责人的用户编号", example = "25682") + @ExcelProperty("备注") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + + @Schema(description = "创建人", example = "1024") + @ExcelProperty("创建人") + private String creator; + @Schema(description = "创建人名字", example = "芋道源码") + @ExcelProperty("创建人名字") + private String creatorName; + + @Schema(description = "距离加入公海时间", example = "1") + private Long poolDay; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerSaveReqVO.java new file mode 100644 index 0000000000..daedb78300 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerSaveReqVO.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.common.validation.Mobile; +import cn.iocoder.yudao.framework.common.validation.Telephone; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.module.crm.enums.customer.CrmCustomerLevelEnum; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerIndustryParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerLevelParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmCustomerSourceParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.SysAreaParseFunction; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_INDUSTRY; + +@Schema(description = "管理后台 - CRM 客户新增/修改 Request VO") +@Data +public class CrmCustomerSaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + private Long id; + + @Schema(description = "客户名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + @DiffLogField(name = "客户名称") + @NotEmpty(message = "客户名称不能为空") + private String name; + + @Schema(description = "下次联系时间") + @DiffLogField(name = "下次联系时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime contactNextTime; + + @Schema(description = "负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + @NotNull(message = "负责人的用户编号不能为空") + private Long ownerUserId; + + @Schema(description = "手机", example = "18000000000") + @DiffLogField(name = "手机") + @Mobile + private String mobile; + + @Schema(description = "电话", example = "18000000000") + @DiffLogField(name = "电话") + @Telephone + private String telephone; + + @Schema(description = "QQ", example = "123456789") + @DiffLogField(name = "QQ") + @Size(max = 20, message = "QQ长度不能超过 20 个字符") + private String qq; + + @Schema(description = "微信", example = "123456789") + @DiffLogField(name = "微信") + @Size(max = 255, message = "微信长度不能超过 255 个字符") + private String wechat; + + @Schema(description = "邮箱", example = "123456789@qq.com") + @DiffLogField(name = "邮箱") + @Email(message = "邮箱格式不正确") + @Size(max = 255, message = "邮箱长度不能超过 255 个字符") + private String email; + + @Schema(description = "地区编号", example = "20158") + @DiffLogField(name = "地区编号", function = SysAreaParseFunction.NAME) + private Integer areaId; + + @Schema(description = "详细地址", example = "北京市海淀区") + @DiffLogField(name = "详细地址") + private String detailAddress; + + @Schema(description = "所属行业", example = "1") + @DiffLogField(name = "所属行业", function = CrmCustomerIndustryParseFunction.NAME) + @DictFormat(CRM_CUSTOMER_INDUSTRY) + private Integer industryId; + + @Schema(description = "客户等级", example = "2") + @DiffLogField(name = "客户等级", function = CrmCustomerLevelParseFunction.NAME) + @InEnum(CrmCustomerLevelEnum.class) + private Integer level; + + @Schema(description = "客户来源", example = "3") + @DiffLogField(name = "客户来源", function = CrmCustomerSourceParseFunction.NAME) + private Integer source; + + @Schema(description = "备注", example = "随便") + @DiffLogField(name = "备注") + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerTransferReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerTransferReqVO.java new file mode 100644 index 0000000000..fb6bed0798 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/customer/vo/customer/CrmCustomerTransferReqVO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer; + +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - CRM 客户转移 Request VO") +@Data +public class CrmCustomerTransferReqVO { + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + @NotNull(message = "客户编号不能为空") + private Long id; + + /** + * 新负责人的用户编号 + */ + @Schema(description = "新负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10430") + @NotNull(message = "新负责人的用户编号不能为空") + private Long newOwnerUserId; + + /** + * 老负责人加入团队后的权限级别。如果 null 说明移除 + * + * 关联 {@link CrmPermissionLevelEnum} + */ + @Schema(description = "老负责人加入团队后的权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer oldOwnerPermissionLevel; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/CrmFollowUpRecordController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/CrmFollowUpRecordController.java new file mode 100644 index 0000000000..c36c572e51 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/CrmFollowUpRecordController.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.crm.controller.admin.followup; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.followup.vo.CrmFollowUpRecordPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.followup.vo.CrmFollowUpRecordRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.followup.vo.CrmFollowUpRecordSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.followup.CrmFollowUpRecordDO; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.followup.CrmFollowUpRecordService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.ArrayList; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + + +@Tag(name = "管理后台 - 跟进记录") +@RestController +@RequestMapping("/crm/follow-up-record") +@Validated +public class CrmFollowUpRecordController { + + @Resource + private CrmFollowUpRecordService followUpRecordService; + @Resource + private CrmContactService contactService; + @Resource + private CrmBusinessService businessService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建跟进记录") + @PreAuthorize("@ss.hasPermission('crm:follow-up-record:create')") + public CommonResult createFollowUpRecord(@Valid @RequestBody CrmFollowUpRecordSaveReqVO createReqVO) { + return success(followUpRecordService.createFollowUpRecord(createReqVO)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除跟进记录") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:follow-up-record:delete')") + public CommonResult deleteFollowUpRecord(@RequestParam("id") Long id) { + followUpRecordService.deleteFollowUpRecord(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得跟进记录") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:follow-up-record:query')") + public CommonResult getFollowUpRecord(@RequestParam("id") Long id) { + CrmFollowUpRecordDO followUpRecord = followUpRecordService.getFollowUpRecord(id); + return success(BeanUtils.toBean(followUpRecord, CrmFollowUpRecordRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得跟进记录分页") + @PreAuthorize("@ss.hasPermission('crm:follow-up-record:query')") + public CommonResult> getFollowUpRecordPage(@Valid CrmFollowUpRecordPageReqVO pageReqVO) { + PageResult pageResult = followUpRecordService.getFollowUpRecordPage(pageReqVO); + // 1.1 查询联系人和商机 + Map contactMap = contactService.getContactMap( + convertSetByFlatMap(pageResult.getList(), item -> item.getContactIds().stream())); + Map businessMap = businessService.getBusinessMap( + convertSetByFlatMap(pageResult.getList(), item -> item.getBusinessIds().stream())); + // 1.2 查询用户 + Map userMap = adminUserApi.getUserMap( + convertSet(pageResult.getList(), item -> Long.valueOf(item.getCreator()))); + // 2. 拼接数据 + PageResult voPageResult = BeanUtils.toBean(pageResult, CrmFollowUpRecordRespVO.class, record -> { + // 2.1 设置联系人和商机信息 + record.setBusinesses(new ArrayList<>()).setContacts(new ArrayList<>()); + record.getContactIds().forEach(id -> MapUtils.findAndThen(contactMap, id, contact -> + record.getContacts().add(new CrmBusinessRespVO().setId(contact.getId()).setName(contact.getName())))); + record.getContactIds().forEach(id -> MapUtils.findAndThen(businessMap, id, business -> + record.getBusinesses().add(new CrmBusinessRespVO().setId(business.getId()).setName(business.getName())))); + // 2.2 设置用户信息 + MapUtils.findAndThen(userMap, Long.valueOf(record.getCreator()), user -> record.setCreatorName(user.getNickname())); + }); + return success(voPageResult); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/vo/CrmFollowUpRecordRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/vo/CrmFollowUpRecordRespVO.java new file mode 100644 index 0000000000..1ce10b73e3 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/followup/vo/CrmFollowUpRecordRespVO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.crm.controller.admin.followup.vo; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessRespVO; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_FOLLOW_UP_TYPE; + +@Schema(description = "管理后台 - 跟进记录 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmFollowUpRecordRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "28800") + private Long id; + + @Schema(description = "数据类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer bizType; + + @Schema(description = "数据编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5564") + private Long bizId; + + @Schema(description = "跟进类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @DictFormat(CRM_FOLLOW_UP_TYPE) + private Integer type; + + @Schema(description = "跟进内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "下次联系时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime nextTime; + + @Schema(description = "关联的商机编号数组") + private List businessIds; + @Schema(description = "关联的商机数组") + private List businesses; + + @Schema(description = "关联的联系人编号数组") + private List contactIds; + @Schema(description = "关联的联系人名称数组") + private List contacts; + + @Schema(description = "图片") + private List picUrls; + @Schema(description = "附件") + private List fileUrls; + + @Schema(description = "创建人", example = "1024") + @ExcelProperty("创建人") + private String creator; + @Schema(description = "创建人名字", example = "芋道源码") + @ExcelProperty("创建人名字") + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java new file mode 100644 index 0000000000..fa49076f8d --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/CrmOperateLogController.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.crm.controller.admin.operatelog; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo.CrmOperateLogPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo.CrmOperateLogRespVO; +import cn.iocoder.yudao.module.crm.enums.LogRecordConstants; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.system.api.logger.OperateLogApi; +import cn.iocoder.yudao.module.system.api.logger.dto.OperateLogV2PageReqDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + +@Tag(name = "管理后台 - CRM 操作日志") +@RestController +@RequestMapping("/crm/operate-log") +@Validated +public class CrmOperateLogController { + + @Resource + private OperateLogApi operateLogApi; + + /** + * {@link CrmBizTypeEnum} 与 {@link LogRecordConstants} 的映射关系 + */ + private static final Map BIZ_TYPE_MAP = new HashMap<>(); + + static { + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_CLUE.getType(), CRM_CLUE_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_CUSTOMER.getType(), CRM_CUSTOMER_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_CONTACT.getType(), CRM_CONTACT_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_BUSINESS.getType(), CRM_BUSINESS_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_CONTRACT.getType(), CRM_CONTRACT_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_PRODUCT.getType(), CRM_PRODUCT_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_RECEIVABLE.getType(), CRM_RECEIVABLE_TYPE); + BIZ_TYPE_MAP.put(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), CRM_RECEIVABLE_PLAN_TYPE); + } + + @GetMapping("/page") + @Operation(summary = "获得操作日志") + @PreAuthorize("@ss.hasPermission('crm:operate-log:query')") + public CommonResult> getCustomerOperateLog(@Valid CrmOperateLogPageReqVO pageReqVO) { + OperateLogV2PageReqDTO reqDTO = new OperateLogV2PageReqDTO(); + reqDTO.setPageSize(PAGE_SIZE_NONE); // 默认不分页,需要分页需注释 + reqDTO.setBizType(BIZ_TYPE_MAP.get(pageReqVO.getBizType())).setBizId(pageReqVO.getBizId()); + return success(BeanUtils.toBean(operateLogApi.getOperateLogPage(reqDTO), CrmOperateLogRespVO.class)); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/vo/CrmOperateLogRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/vo/CrmOperateLogRespVO.java new file mode 100644 index 0000000000..8e458a8a0f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/operatelog/vo/CrmOperateLogRespVO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.crm.controller.admin.operatelog.vo; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 操作日志 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmOperateLogRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + private Long id; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + private String userName; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer userType; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + private String type; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "修改客户") + private String subType; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13563") + private Long bizId; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "将什么从什么改为了什么") + private String action; + + @Schema(description = "编号", example = "{orderId: 1}") + private String extra; + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-01-01") + private LocalDateTime createTime; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/permission/CrmPermissionController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/permission/CrmPermissionController.java new file mode 100644 index 0000000000..51d0a0a571 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/permission/CrmPermissionController.java @@ -0,0 +1,135 @@ +package cn.iocoder.yudao.module.crm.controller.admin.permission; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.permission.vo.CrmPermissionCreateReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.permission.vo.CrmPermissionRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.permission.vo.CrmPermissionUpdateReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.permission.CrmPermissionDO; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.PostApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.google.common.collect.Multimaps; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - CRM 数据权限") +@RestController +@RequestMapping("/crm/permission") +@Validated +public class CrmPermissionController { + + @Resource + private CrmPermissionService permissionService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + @Resource + private PostApi postApi; + + @PostMapping("/create") + @Operation(summary = "创建数据权限") + @PreAuthorize("@ss.hasPermission('crm:permission:create')") + @CrmPermission(bizTypeValue = "#reqVO.bizType", bizId = "#reqVO.bizId", level = CrmPermissionLevelEnum.OWNER) + public CommonResult addPermission(@Valid @RequestBody CrmPermissionCreateReqVO reqVO) { + permissionService.createPermission(BeanUtils.toBean(reqVO, CrmPermissionCreateReqBO.class)); + return success(true); + } + + @PutMapping("/update") + @Operation(summary = "编辑数据权限") + @PreAuthorize("@ss.hasPermission('crm:permission:update')") + @CrmPermission(bizTypeValue = "#updateReqVO.bizType", bizId = "#updateReqVO.bizId" + , level = CrmPermissionLevelEnum.OWNER) + public CommonResult updatePermission(@Valid @RequestBody CrmPermissionUpdateReqVO updateReqVO) { + permissionService.updatePermission(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除数据权限") + @Parameter(name = "ids", description = "数据权限编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:permission:delete')") + public CommonResult deletePermission(@RequestParam("ids") Collection ids) { + permissionService.deletePermissionBatch(ids, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete-self") + @Operation(summary = "删除自己的数据权限") + @Parameter(name = "id", description = "数据权限编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:permission:delete')") + public CommonResult deleteSelfPermission(@RequestParam("id") Long id) { + permissionService.deleteSelfPermission(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得数据权限列表") + @Parameters({ + @Parameter(name = "bizType", description = "CRM 类型", required = true, example = "2"), + @Parameter(name = "bizId", description = "CRM 类型数据编号", required = true, example = "1024") + }) + @PreAuthorize("@ss.hasPermission('crm:permission:query')") + public CommonResult> getPermissionList(@RequestParam("bizType") Integer bizType, + @RequestParam("bizId") Long bizId) { + List permissions = permissionService.getPermissionListByBiz(bizType, bizId); + if (CollUtil.isEmpty(permissions)) { + return success(Collections.emptyList()); + } + + // 查询相关数据 + Map userMap = adminUserApi.getUserMap( + convertSet(permissions, CrmPermissionDO::getUserId)); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + Map postMap = postApi.getPostMap( + convertSetByFlatMap(userMap.values(), AdminUserRespDTO::getPostIds, + item -> item != null ? item.stream() : Stream.empty())); + // 拼接数据 + return success(CollectionUtils.convertList(BeanUtils.toBean(permissions, CrmPermissionRespVO.class), item -> { + findAndThen(userMap, item.getUserId(), user -> { + item.setNickname(user.getNickname()); + findAndThen(deptMap, user.getDeptId(), deptRespDTO -> item.setDeptName(deptRespDTO.getName())); + if (CollUtil.isEmpty(user.getPostIds())) { + item.setPostNames(Collections.emptySet()); + return; + } + List postList = MapUtils.getList(Multimaps.forMap(postMap), user.getPostIds()); + item.setPostNames(CollectionUtils.convertSet(postList, PostRespDTO::getName)); + }); + return item; + })); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/CrmProductController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/CrmProductController.java new file mode 100644 index 0000000000..f6f17da4ce --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/CrmProductController.java @@ -0,0 +1,145 @@ +package cn.iocoder.yudao.module.crm.controller.admin.product; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductCategoryDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import cn.iocoder.yudao.module.crm.service.product.CrmProductCategoryService; +import cn.iocoder.yudao.module.crm.service.product.CrmProductService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static java.util.Collections.singletonList; + +@Tag(name = "管理后台 - CRM 产品") +@RestController +@RequestMapping("/crm/product") +@Validated +public class CrmProductController { + + @Resource + private CrmProductService productService; + @Resource + private CrmProductCategoryService productCategoryService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建产品") + @PreAuthorize("@ss.hasPermission('crm:product:create')") + public CommonResult createProduct(@Valid @RequestBody CrmProductSaveReqVO createReqVO) { + return success(productService.createProduct(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新产品") + @PreAuthorize("@ss.hasPermission('crm:product:update')") + public CommonResult updateProduct(@Valid @RequestBody CrmProductSaveReqVO updateReqVO) { + productService.updateProduct(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除产品") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:product:delete')") + public CommonResult deleteProduct(@RequestParam("id") Long id) { + productService.deleteProduct(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得产品") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:product:query')") + public CommonResult getProduct(@RequestParam("id") Long id) { + CrmProductDO product = productService.getProduct(id); + return success(buildProductDetail(product)); + } + + private CrmProductRespVO buildProductDetail(CrmProductDO product) { + if (product == null) { + return null; + } + return buildProductDetailList(singletonList(product)).get(0); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得产品精简列表", description = "只包含被开启的产品,主要用于前端的下拉选项") + public CommonResult> getProductSimpleList() { + List list = productService.getProductListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, product -> new CrmProductRespVO().setId(product.getId()).setName(product.getName()) + .setUnit(product.getUnit()).setNo(product.getNo()).setPrice(product.getPrice()))); + } + + @GetMapping("/page") + @Operation(summary = "获得产品分页") + @PreAuthorize("@ss.hasPermission('crm:product:query')") + public CommonResult> getProductPage(@Valid CrmProductPageReqVO pageVO) { + PageResult pageResult = productService.getProductPage(pageVO); + return success(new PageResult<>(buildProductDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出产品 Excel") + @PreAuthorize("@ss.hasPermission('crm:product:export')") + @OperateLog(type = EXPORT) + public void exportProductExcel(@Valid CrmProductPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = productService.getProductPage(exportReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "产品.xls", "数据", CrmProductRespVO.class, + buildProductDetailList(list)); + } + + private List buildProductDetailList(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + // 1.1 获得用户信息 + Map userMap = adminUserApi.getUserMap( + convertSetByFlatMap(list, user -> Stream.of(Long.valueOf(user.getCreator()), user.getOwnerUserId()))); + // 1.2 获得分类信息 + Map categoryMap = productCategoryService.getProductCategoryMap( + convertSet(list, CrmProductDO::getCategoryId)); + // 2. 拼接数据 + return BeanUtils.toBean(list, CrmProductRespVO.class, productVO -> { + // 2.1 设置用户信息 + MapUtils.findAndThen(userMap, productVO.getOwnerUserId(), user -> productVO.setOwnerUserName(user.getNickname())); + MapUtils.findAndThen(userMap, Long.valueOf(productVO.getCreator()), user -> productVO.setCreatorName(user.getNickname())); + // 2.2 设置分类名称 + MapUtils.findAndThen(categoryMap, productVO.getCategoryId(), category -> productVO.setCategoryName(category.getName())); + }); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductRespVO.java new file mode 100644 index 0000000000..1f659aa77f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductRespVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.crm.controller.admin.product.vo.product; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 产品 Response VO") +@Data +@ExcelIgnoreUnannotated +public class CrmProductRespVO { + + @Schema(description = "产品编号", example = "20529") + @ExcelProperty("产品编号") + private Long id; + + @Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "好产品") + @ExcelProperty("产品名称") + private String name; + + @Schema(description = "产品编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "12306") + @ExcelProperty("产品编码") + private String no; + + @Schema(description = "单位", example = "2") + @ExcelProperty(value = "单位", converter = DictConvert.class) + @DictFormat(DictTypeConstants.CRM_PRODUCT_UNIT) + private Integer unit; + + @Schema(description = "价格, 单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + @ExcelProperty("价格,单位:分") + private BigDecimal price; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "上架") + @ExcelProperty(value = "单位", converter = DictConvert.class) + @DictFormat(DictTypeConstants.CRM_PRODUCT_STATUS) + private Integer status; + + @Schema(description = "产品分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long categoryId; + @Schema(description = "产品分类名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "衣服") + @ExcelProperty("产品分类") + private String categoryName; + + @Schema(description = "产品描述", example = "你说的对") + @ExcelProperty("产品描述") + private String description; + + @Schema(description = "负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31926") + private Long ownerUserId; + @Schema(description = "负责人的用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道源码") + @ExcelProperty("负责人") + private String ownerUserName; + + @Schema(description = "创建人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private String creator; + @Schema(description = "创建人名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道源码") + @ExcelProperty("创建人") + private String creatorName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductSaveReqVO.java new file mode 100644 index 0000000000..1c1a660677 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/product/vo/product/CrmProductSaveReqVO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.crm.controller.admin.product.vo.product; + +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmProductStatusParseFunction; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.CrmProductUnitParseFunction; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; + +@Schema(description = "管理后台 - CRM 产品创建/修改 Request VO") +@Data +public class CrmProductSaveReqVO { + + @Schema(description = "产品编号", example = "20529") + private Long id; + + @Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "好产品") + @NotNull(message = "产品名称不能为空") + @DiffLogField(name = "产品名称") + private String name; + + @Schema(description = "产品编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "12306") + @NotNull(message = "产品编码不能为空") + @DiffLogField(name = "产品编码") + private String no; + + @Schema(description = "单位", example = "2") + @DiffLogField(name = "单位", function = CrmProductUnitParseFunction.NAME) + private Integer unit; + + @Schema(description = "价格", requiredMode = Schema.RequiredMode.REQUIRED, example = "8911") + @NotNull(message = "价格不能为空") + @DiffLogField(name = "价格") + private BigDecimal price; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "上架") + @NotNull(message = "状态不能为空") + @DiffLogField(name = "状态", function = CrmProductStatusParseFunction.NAME) + private Integer status; + + @Schema(description = "产品分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "产品分类编号不能为空") + @DiffLogField(name = "产品分类编号") + private Long categoryId; + + @Schema(description = "产品描述", example = "你说的对") + @DiffLogField(name = "产品描述") + private String description; + + @Schema(description = "负责人的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31926") + @NotNull(message = "负责人的用户编号不能为空") + private Long ownerUserId; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java new file mode 100644 index 0000000000..af1e08c037 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java @@ -0,0 +1,182 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - CRM 回款") +@RestController +@RequestMapping("/crm/receivable") +@Validated +public class CrmReceivableController { + + @Resource + private CrmReceivableService receivableService; + @Resource + private CrmContractService contractService; + @Resource + private CrmCustomerService customerService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建回款") + @PreAuthorize("@ss.hasPermission('crm:receivable:create')") + public CommonResult createReceivable(@Valid @RequestBody CrmReceivableSaveReqVO createReqVO) { + return success(receivableService.createReceivable(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新回款") + @PreAuthorize("@ss.hasPermission('crm:receivable:update')") + public CommonResult updateReceivable(@Valid @RequestBody CrmReceivableSaveReqVO updateReqVO) { + receivableService.updateReceivable(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除回款") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:receivable:delete')") + public CommonResult deleteReceivable(@RequestParam("id") Long id) { + receivableService.deleteReceivable(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得回款") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:receivable:query')") + public CommonResult getReceivable(@RequestParam("id") Long id) { + CrmReceivableDO receivable = receivableService.getReceivable(id); + return success(buildReceivableDetail(receivable)); + } + + private CrmReceivableRespVO buildReceivableDetail(CrmReceivableDO receivable) { + if (receivable == null) { + return null; + } + return buildReceivableDetailList(Collections.singletonList(receivable)).get(0); + } + + @GetMapping("/page") + @Operation(summary = "获得回款分页") + @PreAuthorize("@ss.hasPermission('crm:receivable:query')") + public CommonResult> getReceivablePage(@Valid CrmReceivablePageReqVO pageReqVO) { + PageResult pageResult = receivableService.getReceivablePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildReceivableDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-customer") + @Operation(summary = "获得回款分页,基于指定客户") + public CommonResult> getReceivablePageByCustomer(@Valid CrmReceivablePageReqVO pageReqVO) { + Assert.notNull(pageReqVO.getCustomerId(), "客户编号不能为空"); + PageResult pageResult = receivableService.getReceivablePageByCustomerId(pageReqVO); + return success(new PageResult<>(buildReceivableDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出回款 Excel") + @PreAuthorize("@ss.hasPermission('crm:receivable:export')") + @OperateLog(type = EXPORT) + public void exportReceivableExcel(@Valid CrmReceivablePageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageSize(PAGE_SIZE_NONE); + List list = receivableService.getReceivablePage(exportReqVO, getLoginUserId()).getList(); + // 导出 Excel + ExcelUtils.write(response, "回款.xls", "数据", CrmReceivableRespVO.class, + buildReceivableDetailList(list)); + } + + private List buildReceivableDetailList(List receivableList) { + if (CollUtil.isEmpty(receivableList)) { + return Collections.emptyList(); + } + // 1.1 获取客户列表 + Map customerMap = customerService.getCustomerMap( + convertSet(receivableList, CrmReceivableDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(receivableList, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 1.3 获得合同列表 + Map contractMap = contractService.getContractMap( + convertSet(receivableList, CrmReceivableDO::getContractId)); + // 2. 拼接结果 + return BeanUtils.toBean(receivableList, CrmReceivableRespVO.class, (receivableVO) -> { + // 2.1 拼接客户名称 + findAndThen(customerMap, receivableVO.getCustomerId(), customer -> receivableVO.setCustomerName(customer.getName())); + // 2.2 拼接负责人、创建人名称 + MapUtils.findAndThen(userMap, NumberUtils.parseLong(receivableVO.getCreator()), + user -> receivableVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, receivableVO.getOwnerUserId(), user -> { + receivableVO.setOwnerUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> receivableVO.setOwnerUserDeptName(dept.getName())); + }); + // 2.3 拼接合同信息 + findAndThen(contractMap, receivableVO.getContractId(), contract -> + receivableVO.setContract(BeanUtils.toBean(contract, CrmContractRespVO.class))); + }); + } + + @PutMapping("/submit") + @Operation(summary = "提交回款审批") + @PreAuthorize("@ss.hasPermission('crm:receivable:update')") + public CommonResult submitContract(@RequestParam("id") Long id) { + receivableService.submitReceivable(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/audit-count") + @Operation(summary = "获得待审核回款数量") + @PreAuthorize("@ss.hasPermission('crm:receivable:query')") + public CommonResult getAuditReceivableCount() { + return success(receivableService.getAuditReceivableCount(getLoginUserId())); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivablePlanController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivablePlanController.java new file mode 100644 index 0000000000..5c9f852309 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivablePlanController.java @@ -0,0 +1,190 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableRespVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivablePlanService; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - CRM 回款计划") +@RestController +@RequestMapping("/crm/receivable-plan") +@Validated +public class CrmReceivablePlanController { + + @Resource + private CrmReceivablePlanService receivablePlanService; + @Resource + private CrmReceivableService receivableService; + @Resource + private CrmContractService contractService; + @Resource + private CrmCustomerService customerService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建回款计划") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:create')") + public CommonResult createReceivablePlan(@Valid @RequestBody CrmReceivablePlanSaveReqVO createReqVO) { + return success(receivablePlanService.createReceivablePlan(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新回款计划") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:update')") + public CommonResult updateReceivablePlan(@Valid @RequestBody CrmReceivablePlanSaveReqVO updateReqVO) { + receivablePlanService.updateReceivablePlan(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除回款计划") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:delete')") + public CommonResult deleteReceivablePlan(@RequestParam("id") Long id) { + receivablePlanService.deleteReceivablePlan(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得回款计划") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:query')") + public CommonResult getReceivablePlan(@RequestParam("id") Long id) { + CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(id); + return success(buildReceivablePlanDetail(receivablePlan)); + } + + private CrmReceivablePlanRespVO buildReceivablePlanDetail(CrmReceivablePlanDO receivablePlan) { + if (receivablePlan == null) { + return null; + } + return buildReceivableDetailList(Collections.singletonList(receivablePlan)).get(0); + } + + @GetMapping("/page") + @Operation(summary = "获得回款计划分页") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:query')") + public CommonResult> getReceivablePlanPage(@Valid CrmReceivablePlanPageReqVO pageReqVO) { + PageResult pageResult = receivablePlanService.getReceivablePlanPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildReceivableDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/page-by-customer") + @Operation(summary = "获得回款计划分页,基于指定客户") + public CommonResult> getReceivablePlanPageByCustomer(@Valid CrmReceivablePlanPageReqVO pageReqVO) { + Assert.notNull(pageReqVO.getCustomerId(), "客户编号不能为空"); + PageResult pageResult = receivablePlanService.getReceivablePlanPageByCustomerId(pageReqVO); + return success(new PageResult<>(buildReceivableDetailList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出回款计划 Excel") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:export')") + @OperateLog(type = EXPORT) + public void exportReceivablePlanExcel(@Valid CrmReceivablePlanPageReqVO exportReqVO, + HttpServletResponse response) throws IOException { + exportReqVO.setPageSize(PAGE_SIZE_NONE); + List list = receivablePlanService.getReceivablePlanPage(exportReqVO, getLoginUserId()).getList(); + // 导出 Excel + ExcelUtils.write(response, "回款计划.xls", "数据", CrmReceivablePlanRespVO.class, + buildReceivableDetailList(list)); + } + + private List buildReceivableDetailList(List receivablePlanList) { + if (CollUtil.isEmpty(receivablePlanList)) { + return Collections.emptyList(); + } + // 1.1 获取客户 Map + Map customerMap = customerService.getCustomerMap( + convertSet(receivablePlanList, CrmReceivablePlanDO::getCustomerId)); + // 1.2 获取创建人、负责人列表 + Map userMap = adminUserApi.getUserMap(convertListByFlatMap(receivablePlanList, + contact -> Stream.of(NumberUtils.parseLong(contact.getCreator()), contact.getOwnerUserId()))); + // 1.3 获得合同 Map + Map contractMap = contractService.getContractMap( + convertSet(receivablePlanList, CrmReceivablePlanDO::getContractId)); + // 1.4 获得回款 Map + Map receivableMap = receivableService.getReceivableMap( + convertSet(receivablePlanList, CrmReceivablePlanDO::getReceivableId)); + // 2. 拼接数据 + return BeanUtils.toBean(receivablePlanList, CrmReceivablePlanRespVO.class, (receivablePlanVO) -> { + // 2.1 拼接客户信息 + findAndThen(customerMap, receivablePlanVO.getCustomerId(), customer -> receivablePlanVO.setCustomerName(customer.getName())); + // 2.2 拼接用户信息 + findAndThen(userMap, receivablePlanVO.getOwnerUserId(), user -> receivablePlanVO.setOwnerUserName(user.getNickname())); + findAndThen(userMap, Long.parseLong(receivablePlanVO.getCreator()), user -> receivablePlanVO.setCreatorName(user.getNickname())); + // 2.3 拼接合同信息 + findAndThen(contractMap, receivablePlanVO.getContractId(), contract -> receivablePlanVO.setContractNo(contract.getNo())); + // 2.4 拼接回款信息 + receivablePlanVO.setReceivable(BeanUtils.toBean(receivableMap.get(receivablePlanVO.getReceivableId()), CrmReceivableRespVO.class)); + }); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得回款计划精简列表", description = "获得回款计划精简列表,主要用于前端的下拉选项") + @Parameters({ + @Parameter(name = "customerId", description = "客户编号", required = true), + @Parameter(name = "contractId", description = "合同编号", required = true) + }) + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:query')") + public CommonResult> getReceivablePlanSimpleList(@RequestParam("customerId") Long customerId, + @RequestParam("contractId") Long contractId) { + CrmReceivablePlanPageReqVO pageReqVO = new CrmReceivablePlanPageReqVO().setCustomerId(customerId).setContractId(contractId); + pageReqVO.setPageNo(PAGE_SIZE_NONE); + PageResult pageResult = receivablePlanService.getReceivablePlanPageByCustomerId(pageReqVO); + return success(convertList(pageResult.getList(), receivablePlan -> new CrmReceivablePlanRespVO() // 只返回 id、period 等信息 + .setId(receivablePlan.getId()).setPeriod(receivablePlan.getPeriod()).setReceivableId(receivablePlan.getReceivableId()) + .setPrice(receivablePlan.getPrice()).setReturnType(receivablePlan.getReturnType()))); + } + + @GetMapping("/remind-count") + @Operation(summary = "获得待回款提醒数量") + @PreAuthorize("@ss.hasPermission('crm:receivable-plan:query')") + public CommonResult getReceivablePlanRemindCount() { + return success(receivablePlanService.getReceivablePlanRemindCount(getLoginUserId())); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanPageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanPageReqVO.java new file mode 100644 index 0000000000..b730128ce8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanPageReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - CRM 回款计划分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmReceivablePlanPageReqVO extends PageParam { + + /** + * 提醒类型 - 待回款 + */ + public final static Integer REMIND_TYPE_NEEDED = 1; + /** + * 提醒类型 - 已逾期 + */ + public final static Integer REMIND_TYPE_EXPIRED = 2; + /** + * 提醒类型 - 已回款 + */ + public final static Integer REMIND_TYPE_RECEIVED = 3; + + @Schema(description = "客户编号", example = "18026") + private Long customerId; + + @Schema(description = "合同编号", example = "H3473") + private String contractNo; + + @Schema(description = "合同编号", example = "3473") + private Long contractId; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "提醒类型", example = "1") + private Integer remindType; // 提醒类型,为 null 时则表示全部 + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanRespVO.java new file mode 100644 index 0000000000..1c58766e1e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanRespVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan; + +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableRespVO; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +// TODO @puhui999:缺导出 +@Schema(description = "管理后台 - CRM 回款计划 Response VO") +@Data +public class CrmReceivablePlanRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long id; + + @Schema(description = "期数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer period; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long customerId; + @Schema(description = "客户名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "test") + private String customerName; + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long contractId; + @Schema(description = "合同编号", example = "Q110") + private String contractNo; + + @Schema(description = "负责人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long ownerUserId; + @Schema(description = "负责人", example = "test") + private String ownerUserName; + + @Schema(description = "计划回款日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02") + private LocalDateTime returnTime; + + @Schema(description = "计划回款方式", example = "1") + private Integer returnType; + + @Schema(description = "计划回款金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000") + private BigDecimal price; + + @Schema(description = "回款编号", example = "19852") + private Long receivableId; + @Schema(description = "回款信息") + private CrmReceivableRespVO receivable; + + @Schema(description = "提前几天提醒", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer remindDays; + + @Schema(description = "提醒日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02") + private LocalDateTime remindTime; + + @Schema(description = "备注", example = "备注") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + @ExcelProperty("更新时间") + private LocalDateTime updateTime; + + @Schema(description = "创建人", example = "1024") + @ExcelProperty("创建人") + private String creator; + @Schema(description = "创建人名字", example = "芋道源码") + @ExcelProperty("创建人名字") + private String creatorName; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanSaveReqVO.java new file mode 100644 index 0000000000..cefb02a9e2 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/plan/CrmReceivablePlanSaveReqVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 回款计划新增/修改 Request VO") +@Data +public class CrmReceivablePlanSaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long id; + + @Schema(description = "客户编号", hidden = true, example = "2") + private Long customerId; // 该字段不通过前端传递,而是 contractId 查询出来设置进去 + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "合同编号不能为空") + private Long contractId; + + @Schema(description = "负责人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "负责人编号不能为空") + private Long ownerUserId; + + @Schema(description = "计划回款日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02") + @NotNull(message = "计划回款日期不能为空") + private LocalDateTime returnTime; + + @Schema(description = "回款方式", example = "1") + private Integer returnType; + + @Schema(description = "计划回款金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000") + @NotNull(message = "计划回款金额不能为空") + private BigDecimal price; + + @Schema(description = "提前几天提醒", example = "1") + private Integer remindDays; + + @Schema(description = "备注", example = "备注") + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivablePageReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivablePageReqVO.java new file mode 100644 index 0000000000..415816c5dc --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivablePageReqVO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - CRM 回款分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class CrmReceivablePageReqVO extends PageParam { + + @Schema(description = "回款编号") + private String no; + + @Schema(description = "回款计划编号", example = "31177") + private Long planId; + + @Schema(description = "客户编号", example = "4963") + private Long customerId; + + @Schema(description = "合同编号", example = "4963") + private Long contractId; + + @Schema(description = "场景类型", example = "1") + @InEnum(CrmSceneTypeEnum.class) + private Integer sceneType; // 场景类型,为 null 时则表示全部 + + @Schema(description = "审批状态", example = "20") + @InEnum(CrmAuditStatusEnum.class) + private Integer auditStatus; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java new file mode 100644 index 0000000000..a9fcb1b8b1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableRespVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable; + +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO; +import com.alibaba.excel.annotation.ExcelProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +// TODO 芋艿:导出的 VO,可以考虑使用 @Excel 注解,实现导出功能 +@Schema(description = "管理后台 - CRM 回款 Response VO") +@Data +public class CrmReceivableRespVO { + + @Schema(description = "编号", example = "25787") + private Long id; + + @Schema(description = "回款编号", example = "31177") + private String no; + + @Schema(description = "回款计划编号", example = "1024") + private Long planId; + + @Schema(description = "回款方式", example = "2") + private Integer returnType; + + @Schema(description = "回款金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000") + private BigDecimal price; + + @Schema(description = "计划回款日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02") + private LocalDateTime returnTime; + + @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long customerId; + @Schema(description = "客户名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "test") + private String customerName; + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long contractId; + @Schema(description = "合同信息") + private CrmContractRespVO contract; + + @Schema(description = "负责人的用户编号", example = "25682") + @ExcelProperty("负责人的用户编号") + private Long ownerUserId; + @Schema(description = "负责人名字", example = "25682") + @ExcelProperty("负责人名字") + private String ownerUserName; + @Schema(description = "负责人部门") + @ExcelProperty("负责人部门") + private String ownerUserDeptName; + + @Schema(description = "工作流编号", example = "1043") + @ExcelProperty("工作流编号") + private String processInstanceId; + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer auditStatus; + + @Schema(description = "备注", example = "备注") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + + @Schema(description = "创建人", example = "25682") + private String creator; + @Schema(description = "创建人名字", example = "test") + private String creatorName; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java new file mode 100644 index 0000000000..1064fc9248 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/vo/receivable/CrmReceivableSaveReqVO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableReturnTypeEnum; +import cn.iocoder.yudao.module.crm.framework.operatelog.core.*; +import com.mzt.logapi.starter.annotation.DiffLogField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - CRM 回款新增/修改 Request VO") +@Data +public class CrmReceivableSaveReqVO { + + @Schema(description = "编号", example = "25787") + private Long id; + + @Schema(description = "负责人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @DiffLogField(name = "负责人", function = SysAdminUserParseFunction.NAME) + @NotNull(message = "负责人编号不能为空") + private Long ownerUserId; + + @Schema(description = "客户编号", example = "2") + @DiffLogField(name = "客户", function = CrmCustomerParseFunction.NAME) + private Long customerId; // 该字段不通过前端传递,而是 contractId 查询出来设置进去 + + @Schema(description = "合同编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @DiffLogField(name = "合同", function = CrmContractParseFunction.NAME) + @NotNull(message = "合同编号不能为空") + private Long contractId; + + @Schema(description = "回款计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @DiffLogField(name = "合同", function = CrmReceivablePlanParseFunction.NAME) + private Long planId; + + @Schema(description = "回款方式", example = "2") + @DiffLogField(name = "回款方式", function = CrmReceivableReturnTypeParseFunction.NAME) + @InEnum(CrmReceivableReturnTypeEnum.class) + private Integer returnType; + + @Schema(description = "回款金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "9000") + @DiffLogField(name = "回款金额") + @NotNull(message = "回款金额不能为空") + private BigDecimal price; + + @Schema(description = "回款日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-02-02") + @NotNull(message = "回款日期不能为空") + @DiffLogField(name = "回款日期") + private LocalDateTime returnTime; + + @Schema(description = "备注", example = "备注") + @DiffLogField(name = "备注") + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.http b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.http new file mode 100644 index 0000000000..e878ba1a93 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.http @@ -0,0 +1,9 @@ +### 合同金额排行榜 +GET {{baseUrl}}/crm/statistics-rank/get-contract-price-rank?deptId=100×[0]=2022-12-12 00:00:00×[1]=2024-12-12 23:59:59 +Authorization: Bearer {{token}} +tenant-id: {{adminTenentId}} + +### 回款金额排行榜 +GET {{baseUrl}}/crm/statistics-rank/get-receivable-price-rank?deptId=100×[0]=2022-12-12 00:00:00×[1]=2024-12-12 23:59:59 +Authorization: Bearer {{token}} +tenant-id: {{adminTenentId}} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.java new file mode 100644 index 0000000000..d73d7637e6 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/CrmStatisticsRankController.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.crm.controller.admin.statistics; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRanKRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRankReqVO; +import cn.iocoder.yudao.module.crm.service.statistics.CrmStatisticsRankingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + + +@Tag(name = "管理后台 - CRM 排行榜统计") +@RestController +@RequestMapping("/crm/statistics-rank") +@Validated +public class CrmStatisticsRankController { + + @Resource + private CrmStatisticsRankingService rankingService; + + @GetMapping("/get-contract-price-rank") + @Operation(summary = "获得合同金额排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getContractPriceRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getContractPriceRank(rankingReqVO)); + } + + @GetMapping("/get-receivable-price-rank") + @Operation(summary = "获得回款金额排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getReceivablePriceRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getReceivablePriceRank(rankingReqVO)); + } + + @GetMapping("/get-contract-count-rank") + @Operation(summary = "获得签约合同数量排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getContractCountRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getContractCountRank(rankingReqVO)); + } + + @GetMapping("/get-product-sales-rank") + @Operation(summary = "获得产品销量排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getProductSalesRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getProductSalesRank(rankingReqVO)); + } + + @GetMapping("/get-customer-count-rank") + @Operation(summary = "获得新增客户数排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getCustomerCountRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getCustomerCountRank(rankingReqVO)); + } + + @GetMapping("/get-contacts-count-rank") + @Operation(summary = "获得新增联系人数排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getContactsCountRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getContactsCountRank(rankingReqVO)); + } + + @GetMapping("/get-follow-count-rank") + @Operation(summary = "获得跟进次数排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getFollowCountRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getFollowCountRank(rankingReqVO)); + } + + @GetMapping("/get-follow-customer-count-rank") + @Operation(summary = "获得跟进客户数排行榜") + @PreAuthorize("@ss.hasPermission('crm:statistics-rank:query')") + public CommonResult> getFollowCustomerCountRank(@Valid CrmStatisticsRankReqVO rankingReqVO) { + return success(rankingService.getFollowCustomerCountRank(rankingReqVO)); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRanKRespVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRanKRespVO.java new file mode 100644 index 0000000000..d5c865fd34 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRanKRespVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.crm.controller.admin.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Schema(description = "管理后台 - CRM BI 排行榜统计 Response VO") +@Data +public class CrmStatisticsRanKRespVO { + + @Schema(description = "负责人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long ownerUserId; + + @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private String nickname; + + @Schema(description = "部门名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private String deptName; + + /** + * 数量是个特别“抽象”的概念,在不同排行下,代表不同含义 + * + * 1. 金额:合同金额排行、回款金额排行 + * 2. 个数:签约合同排行、产品销量排行、产品销量排行、新增客户数排行、新增联系人排行、跟进次数排行、跟进客户数排行 + */ + @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer count; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRankReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRankReqVO.java new file mode 100644 index 0000000000..029e3e7284 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/CrmStatisticsRankReqVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.crm.controller.admin.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - CRM 排行榜统计 Request VO") +@Data +public class CrmStatisticsRankReqVO { + + @Schema(description = "部门 id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "部门 id 不能为空") + private Long deptId; + + /** + * userIds 目前不用前端传递,目前是方便后端通过 deptId 读取编号后,设置回来 + * + * 后续,可能会支持选择部分用户进行查询 + */ + @Schema(description = "负责人用户 id 集合", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "2") + private List userIds; + + @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.REQUIRED) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @NotEmpty(message = "时间范围不能为空") + private LocalDateTime[] times; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessDO.java new file mode 100644 index 0000000000..a30ece21f8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessDO.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.business; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.business.CrmBusinessEndStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * CRM 商机 DO + * + * @author ljlleo + */ +@TableName("crm_business") +@KeySequence("crm_business_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmBusinessDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 商机名称 + */ + private String name; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + + /** + * 跟进状态 + */ + private Boolean followUpStatus; + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + /** + * 下次联系时间 + */ + private LocalDateTime contactNextTime; + + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + + /** + * 商机状态组编号 + * + * 关联 {@link CrmBusinessStatusTypeDO#getId()} + */ + private Long statusTypeId; + /** + * 商机状态编号 + * + * 关联 {@link CrmBusinessStatusDO#getId()} + */ + private Long statusId; + /** + * 结束状态 + * + * 枚举 {@link CrmBusinessEndStatusEnum} + */ + private Integer endStatus; + /** + * 结束时的备注 + */ + private String endRemark; + + /** + * 预计成交日期 + */ + private LocalDateTime dealTime; + /** + * 产品总金额,单位:元 + * + * productPrice = ∑({@link CrmBusinessProductDO#getTotalPrice()}) + */ + private BigDecimal totalProductPrice; + /** + * 整单折扣,百分比 + */ + private BigDecimal discountPercent; + /** + * 商机总金额,单位:元 + */ + private BigDecimal totalPrice; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessProductDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessProductDO.java new file mode 100644 index 0000000000..2f66fc34da --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessProductDO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.business; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * CRM 商机产品关联表 DO + * + * CrmBusinessDO : CrmBusinessProductDO = 1 : N + * + * @author lzxhqs + */ +@TableName("crm_business_product") +@KeySequence("crm_business_product_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmBusinessProductDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 商机编号 + * + * 关联 {@link CrmBusinessDO#getId()} + */ + private Long businessId; + /** + * 产品编号 + * + * 关联 {@link CrmProductDO#getId()} + */ + private Long productId; + /** + * 产品单价,单位:元 + * + * 冗余 {@link CrmProductDO#getPrice()} + */ + private BigDecimal productPrice; + /** + * 商机价格, 单位:元 + */ + private BigDecimal businessPrice; + /** + * 数量 + */ + private BigDecimal count; + /** + * 总计价格,单位:元 + * + * totalPrice = businessPrice * count + */ + private BigDecimal totalPrice; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusDO.java new file mode 100644 index 0000000000..4ec8bbe4a1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusDO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.business; + +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * CRM 商机状态 DO + * + * 注意,它是个配置表 + * + * @author ljlleo + */ +@TableName("crm_business_status") +@KeySequence("crm_business_status_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmBusinessStatusDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 状态类型编号 + * + * 关联 {@link CrmBusinessStatusTypeDO#getId()} + */ + private Long typeId; + /** + * 状态名 + */ + private String name; + /** + * 赢单率,百分比 + */ + private Integer percent; + /** + * 排序 + */ + private Integer sort; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusTypeDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusTypeDO.java new file mode 100644 index 0000000000..95b7a1f26f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/business/CrmBusinessStatusTypeDO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.business; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.util.List; + +/** + * CRM 商机状态组 DO + * + * 注意,它是个配置表 + * + * @author ljlleo + */ +@TableName(value = "crm_business_status_type", autoResultMap = true) +@KeySequence("crm_business_status_type_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmBusinessStatusTypeDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 状态类型名 + */ + private String name; + + /** + * 使用的部门编号 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List deptIds; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/clue/CrmClueDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/clue/CrmClueDO.java new file mode 100644 index 0000000000..3af6feec43 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/clue/CrmClueDO.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.clue; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * CRM 线索 DO + * + * @author Wanwan + */ +@TableName("crm_clue") +@KeySequence("crm_clue_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmClueDO extends BaseDO { + + /** + * 编号,主键自增 + */ + @TableId + private Long id; + /** + * 线索名称 + */ + private String name; + + /** + * 跟进状态 + */ + private Boolean followUpStatus; + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + /** + * 最后跟进内容 + */ + private String contactLastContent; + /** + * 下次联系时间 + */ + private LocalDateTime contactNextTime; + + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + + /** + * 转化状态 + * + * true 表示已转换,会更新 {@link #customerId} 字段 + */ + private Boolean transformStatus; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + + /** + * 手机号 + */ + private String mobile; + /** + * 电话 + */ + private String telephone; + /** + * QQ + */ + private String qq; + /** + * wechat + */ + private String wechat; + /** + * email + */ + private String email; + /** + * 所在地 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} 字段 + */ + private Integer areaId; + /** + * 详细地址 + */ + private String detailAddress; + /** + * 所属行业 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_INDUSTRY} + */ + private Integer industryId; + /** + * 客户等级 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_LEVEL} + */ + private Integer level; + /** + * 客户来源 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_SOURCE} + */ + private Integer source; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contact/CrmContactDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contact/CrmContactDO.java new file mode 100644 index 0000000000..5a891eb9c3 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contact/CrmContactDO.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.contact; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * CRM 联系人 DO + * + * @author 芋道源码 + */ +@TableName("crm_contact") +@KeySequence("crm_contact_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmContactDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 联系人姓名 + */ + private String name; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + /** + * 最后跟进内容 + */ + private String contactLastContent; + /** + * 下次联系时间 + */ + private LocalDateTime contactNextTime; + + /** + * 负责人用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + + /** + * 手机号 + */ + private String mobile; + /** + * 电话 + */ + private String telephone; + /** + * 电子邮箱 + */ + private String email; + /** + * QQ + */ + private Long qq; + /** + * 微信 + */ + private String wechat; + /** + * 所在地 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} 字段 + */ + private Integer areaId; + /** + * 详细地址 + */ + private String detailAddress; + /** + * 性别 + * + * 枚举 {@link cn.iocoder.yudao.module.system.enums.common.SexEnum} + */ + private Integer sex; + /** + * 是否关键决策人 + */ + private Boolean master; + /** + * 职位 + */ + private String post; + /** + * 直属上级 + * + * 关联 {@link CrmContactDO#id} + */ + private Long parentId; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractConfigDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractConfigDO.java new file mode 100644 index 0000000000..ab0c2d28e8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractConfigDO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.contract; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.*; +import lombok.*; + +@TableName("crm_contract_config") +@KeySequence("crm_contract_config_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmContractConfigDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 是否开启提前提醒 + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Boolean notifyEnabled; + /** + * 提前提醒天数 + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Integer notifyDays; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractDO.java new file mode 100644 index 0000000000..89f4fd98ac --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractDO.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.contract; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * CRM 合同 DO + * + * @author dhb52 + */ +@TableName("crm_contract") +@KeySequence("crm_contract_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmContractDO extends BaseDO { + + /** + * 合同编号 + */ + @TableId + private Long id; + /** + * 合同名称 + */ + private String name; + /** + * 合同编号 + */ + private String no; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + /** + * 商机编号,非必须 + * + * 关联 {@link CrmBusinessDO#getId()} + */ + private Long businessId; + + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + + /** + * 工作流编号 + * + * 关联 ProcessInstance 的 id 属性 + */ + private String processInstanceId; + /** + * 审批状态 + * + * 枚举 {@link CrmAuditStatusEnum} + */ + private Integer auditStatus; + + /** + * 下单日期 + */ + private LocalDateTime orderDate; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 结束时间 + */ + private LocalDateTime endTime; + /** + * 产品总金额,单位:元 + */ + private BigDecimal totalProductPrice; + /** + * 整单折扣 + */ + private BigDecimal discountPercent; + /** + * 合同总金额,单位:分 + */ + private BigDecimal totalPrice; + /** + * 客户签约人,非必须 + * + * 关联 {@link CrmContactDO#getId()} + */ + private Long signContactId; + /** + * 公司签约人,非必须 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long signUserId; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractProductDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractProductDO.java new file mode 100644 index 0000000000..6bb3e6bf0b --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/contract/CrmContractProductDO.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.contract; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * CRM 合同产品关联表 DO + * + * @author HUIHUI + */ +@TableName("crm_contract_product") +@KeySequence("crm_contract_product_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmContractProductDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 合同编号 + * + * 关联 {@link CrmContractDO#getId()} + */ + private Long contractId; + /** + * 产品编号 + * + * 关联 {@link CrmProductDO#getId()} + */ + private Long productId; + /** + * 产品单价,单位:元 + */ + private BigDecimal productPrice; + /** + * 合同价格, 单位:元 + */ + private BigDecimal contractPrice; + /** + * 数量 + */ + private BigDecimal count; + /** + * 总计价格,单位:元 + * + * totalPrice = businessPrice * count + */ + private BigDecimal totalPrice; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/customer/CrmCustomerDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/customer/CrmCustomerDO.java new file mode 100644 index 0000000000..76d5111154 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/customer/CrmCustomerDO.java @@ -0,0 +1,127 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.customer; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * CRM 客户 DO + * + * @author Wanwan + */ +@TableName(value = "crm_customer") +@KeySequence("crm_customer_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmCustomerDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户名称 + */ + private String name; + + /** + * 跟进状态 + */ + private Boolean followUpStatus; + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + /** + * 最后跟进内容 + */ + private String contactLastContent; + /** + * 下次联系时间 + */ + private LocalDateTime contactNextTime; + + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + /** + * 成为负责人的时间 + */ + private LocalDateTime ownerTime; + + /** + * 锁定状态 + */ + private Boolean lockStatus; + /** + * 成交状态 + */ + private Boolean dealStatus; + + /** + * 手机 + */ + private String mobile; + /** + * 电话 + */ + private String telephone; + /** + * QQ + */ + private String qq; + /** + * wechat + */ + private String wechat; + /** + * email + */ + private String email; + /** + * 所在地 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} 字段 + */ + private Integer areaId; + /** + * 详细地址 + */ + private String detailAddress; + /** + * 所属行业 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_INDUSTRY} + */ + private Integer industryId; + /** + * 客户等级 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_LEVEL} + */ + private Integer level; + /** + * 客户来源 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_SOURCE} + */ + private Integer source; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/product/CrmProductDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/product/CrmProductDO.java new file mode 100644 index 0000000000..caeeb53d2b --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/product/CrmProductDO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.product; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import cn.iocoder.yudao.module.crm.enums.product.CrmProductStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * CRM 产品 DO + * + * @author ZanGe丶 + */ +@TableName("crm_product") +@KeySequence("crm_product_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmProductDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 产品名称 + */ + private String name; + /** + * 产品编码 + */ + private String no; + /** + * 单位 + * + * 字典 {@link DictTypeConstants#CRM_PRODUCT_UNIT} + */ + private Integer unit; + /** + * 价格,单位:元 + */ + private BigDecimal price; + /** + * 状态 + * + * 关联 {@link CrmProductStatusEnum} + */ + private Integer status; + /** + * 产品分类 ID + * + * 关联 {@link CrmProductCategoryDO#getId()} 字段 + */ + private Long categoryId; + /** + * 产品描述 + */ + private String description; + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java new file mode 100644 index 0000000000..269cac6cc2 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivableDO.java @@ -0,0 +1,94 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.receivable; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableReturnTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 回款 DO + * + * @author 赤焰 + */ +@TableName("crm_receivable") +@KeySequence("crm_receivable_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmReceivableDO extends BaseDO { + + /** + * ID + */ + @TableId + private Long id; + /** + * 回款编号 + */ + private String no; + /** + * 回款计划编号 + * + * 关联 {@link CrmReceivablePlanDO#getId()},非必须 + */ + private Long planId; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + /** + * 合同编号 + * + * 关联 {@link CrmContractDO#getId()} + */ + private Long contractId; + /** + * 负责人编号,关联 {@link AdminUserRespDTO#getId()} + */ + private Long ownerUserId; + + /** + * 回款日期 + */ + private LocalDateTime returnTime; + /** + * 回款方式,关联枚举{@link CrmReceivableReturnTypeEnum} + */ + private Integer returnType; + /** + * 计划回款金额,单位:元 + */ + private BigDecimal price; + /** + * 备注 + */ + private String remark; + + /** + * 工作流编号 + * + * 关联 ProcessInstance 的 id 属性 + */ + private String processInstanceId; + /** + * 审批状态 + * + * 枚举 {@link CrmAuditStatusEnum} + */ + private Integer auditStatus; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivablePlanDO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivablePlanDO.java new file mode 100644 index 0000000000..5ddefc5597 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/receivable/CrmReceivablePlanDO.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.crm.dal.dataobject.receivable; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableReturnTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * CRM 回款计划 DO + * + * @author 芋道源码 + */ +@TableName("crm_receivable_plan") +@KeySequence("crm_receivable_plan_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CrmReceivablePlanDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 期数 + */ + private Integer period; + /** + * 客户编号 + * + * 关联 {@link CrmCustomerDO#getId()} + */ + private Long customerId; + /** + * 合同编号 + * + * 关联 {@link CrmContractDO#getId()} + */ + private Long contractId; + + /** + * 负责人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + + /** + * 计划回款日期 + */ + private LocalDateTime returnTime; + /** + * 计划回款类型 + * + * 枚举 {@link CrmReceivableReturnTypeEnum} + */ + private Integer returnType; + /** + * 计划回款金额,单位:元 + */ + private BigDecimal price; + + /** + * 回款编号,关联 {@link CrmReceivableDO#getId()} + */ + private Long receivableId; + + /** + * 提前几天提醒 + */ + private Integer remindDays; + /** + * 提醒日期 + */ + private LocalDateTime remindTime; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessMapper.java new file mode 100644 index 0000000000..4718a8d7ae --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessMapper.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.business; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; + +/** + * 商机 Mapper + * + * @author ljlleo + */ +@Mapper +public interface CrmBusinessMapper extends BaseMapperX { + + default int updateOwnerUserIdById(Long id, Long ownerUserId) { + return update(new LambdaUpdateWrapper() + .eq(CrmBusinessDO::getId, id) + .set(CrmBusinessDO::getOwnerUserId, ownerUserId)); + } + + default PageResult selectPageByCustomerId(CrmBusinessPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(CrmBusinessDO::getCustomerId, pageReqVO.getCustomerId()) // 指定客户编号 + .likeIfPresent(CrmBusinessDO::getName, pageReqVO.getName()) + .orderByDesc(CrmBusinessDO::getId)); + } + + default PageResult selectPageByContactId(CrmBusinessPageReqVO pageReqVO, Collection businessIds) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .in(CrmBusinessDO::getId, businessIds) // 指定商机编号 + .likeIfPresent(CrmBusinessDO::getName, pageReqVO.getName()) + .orderByDesc(CrmBusinessDO::getId)); + } + + default PageResult selectPage(CrmBusinessPageReqVO pageReqVO, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_BUSINESS.getType(), + CrmBusinessDO::getId, userId, pageReqVO.getSceneType(), Boolean.FALSE); + // 拼接自身的查询条件 + query.selectAll(CrmBusinessDO.class) + .likeIfPresent(CrmBusinessDO::getName, pageReqVO.getName()) + .orderByDesc(CrmBusinessDO::getId); + return selectJoinPage(pageReqVO, CrmBusinessDO.class, query); + } + + default Long selectCountByStatusTypeId(Long statusTypeId) { + return selectCount(CrmBusinessDO::getStatusTypeId, statusTypeId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessProductMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessProductMapper.java new file mode 100644 index 0000000000..a91a0fd141 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessProductMapper.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.business; + + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessProductDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 商机产品 Mapper + * + * @author lzxhqs + */ +@Mapper +public interface CrmBusinessProductMapper extends BaseMapperX { + + default List selectListByBusinessId(Long businessId) { + return selectList(CrmBusinessProductDO::getBusinessId, businessId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusMapper.java new file mode 100644 index 0000000000..dfe7afcc30 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.business; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 商机状态 Mapper + * + * @author ljlleo + */ +@Mapper +public interface CrmBusinessStatusMapper extends BaseMapperX { + + default int deleteByTypeId(Long typeId) { + return delete(CrmBusinessStatusDO::getTypeId, typeId); + } + + default List selectListByTypeId(Long typeId) { + return selectList(CrmBusinessStatusDO::getTypeId, typeId); + } + + default CrmBusinessStatusDO selectByTypeIdAndId(Long statusTypeId, Long statusId) { + return selectOne(CrmBusinessStatusDO::getTypeId, statusTypeId, + CrmBusinessStatusDO::getId, statusId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusTypeMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusTypeMapper.java new file mode 100644 index 0000000000..3444e58a7c --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/business/CrmBusinessStatusTypeMapper.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.business; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusTypeDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 商机状态组 Mapper + * + * @author ljlleo + */ +@Mapper +public interface CrmBusinessStatusTypeMapper extends BaseMapperX { + + default PageResult selectPage(PageParam reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .orderByDesc(CrmBusinessStatusTypeDO::getId)); + } + + default CrmBusinessStatusTypeDO selectByName(String name) { + return selectOne(CrmBusinessStatusTypeDO::getName, name); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/clue/CrmClueMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/clue/CrmClueMapper.java new file mode 100644 index 0000000000..d0665c604d --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/clue/CrmClueMapper.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.clue; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmCluePageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.clue.CrmClueDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * 线索 Mapper + * + * @author Wanwan + */ +@Mapper +public interface CrmClueMapper extends BaseMapperX { + + default PageResult selectPage(CrmCluePageReqVO pageReqVO, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CLUE.getType(), + CrmClueDO::getId, userId, pageReqVO.getSceneType(), pageReqVO.getPool()); + // 拼接自身的查询条件 + query.selectAll(CrmClueDO.class) + .likeIfPresent(CrmClueDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmClueDO::getTransformStatus, pageReqVO.getTransformStatus()) + .likeIfPresent(CrmClueDO::getTelephone, pageReqVO.getTelephone()) + .likeIfPresent(CrmClueDO::getMobile, pageReqVO.getMobile()) + .eqIfPresent(CrmClueDO::getIndustryId, pageReqVO.getIndustryId()) + .eqIfPresent(CrmClueDO::getLevel, pageReqVO.getLevel()) + .eqIfPresent(CrmClueDO::getSource, pageReqVO.getSource()) + .eqIfPresent(CrmClueDO::getFollowUpStatus, pageReqVO.getFollowUpStatus()) + .orderByDesc(CrmClueDO::getId); + return selectJoinPage(pageReqVO, CrmClueDO.class, query); + } + + default List selectBatchIds(Collection ids, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CLUE.getType(), ids, userId); + query.selectAll(CrmClueDO.class).in(CrmClueDO::getId, ids).orderByDesc(CrmClueDO::getId); + // 拼接自身的查询条件 + return selectJoinList(CrmClueDO.class, query); + } + + default Long selectCountByFollow(Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CLUE.getType(), + CrmClueDO::getId, userId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 未跟进 + 未转化 + query.eq(CrmClueDO::getFollowUpStatus, false) + .eq(CrmClueDO::getTransformStatus, false); + return selectCount(query); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactBusinessMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactBusinessMapper.java new file mode 100644 index 0000000000..7bff0c2046 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactBusinessMapper.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.contact; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactBusinessDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * CRM 联系人商机关联 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface CrmContactBusinessMapper extends BaseMapperX { + + default CrmContactBusinessDO selectByContactIdAndBusinessId(Long contactId, Long businessId) { + return selectOne(CrmContactBusinessDO::getContactId, contactId, + CrmContactBusinessDO::getBusinessId, businessId); + } + + default void deleteByContactIdAndBusinessId(Long contactId, Collection businessIds) { + delete(new LambdaQueryWrapper() + .eq(CrmContactBusinessDO::getContactId, contactId) + .in(CrmContactBusinessDO::getBusinessId, businessIds)); + } + + default void deleteByBusinessIdAndContactId(Long businessId, List contactIds) { + delete(new LambdaQueryWrapper() + .eq(CrmContactBusinessDO::getBusinessId, businessId) + .in(CrmContactBusinessDO::getContactId, contactIds)); + } + + default List selectListByContactId(Long contactId) { + return selectList(CrmContactBusinessDO::getContactId, contactId); + } + + default List selectListByBusinessId(Long businessId) { + return selectList(CrmContactBusinessDO::getBusinessId, businessId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactMapper.java new file mode 100644 index 0000000000..4a77665ad7 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contact/CrmContactMapper.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.contact; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * CRM 联系人 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface CrmContactMapper extends BaseMapperX { + + default int updateOwnerUserIdByCustomerId(Long customerId, Long ownerUserId) { + return update(new LambdaUpdateWrapper() + .eq(CrmContactDO::getCustomerId, customerId) + .set(CrmContactDO::getOwnerUserId, ownerUserId)); + } + + default PageResult selectPageByCustomerId(CrmContactPageReqVO pageVO) { + return selectPage(pageVO, new LambdaQueryWrapperX() + .eq(CrmContactDO::getCustomerId, pageVO.getCustomerId()) // 指定客户编号 + .likeIfPresent(CrmContactDO::getName, pageVO.getName()) + .eqIfPresent(CrmContactDO::getMobile, pageVO.getMobile()) + .eqIfPresent(CrmContactDO::getTelephone, pageVO.getTelephone()) + .eqIfPresent(CrmContactDO::getEmail, pageVO.getEmail()) + .eqIfPresent(CrmContactDO::getQq, pageVO.getQq()) + .eqIfPresent(CrmContactDO::getWechat, pageVO.getWechat()) + .orderByDesc(CrmContactDO::getId)); + } + + default PageResult selectPageByBusinessId(CrmContactPageReqVO pageVO, Collection ids) { + return selectPage(pageVO, new LambdaQueryWrapperX() + .in(CrmContactDO::getId, ids) // 指定联系人编号 + .likeIfPresent(CrmContactDO::getName, pageVO.getName()) + .eqIfPresent(CrmContactDO::getMobile, pageVO.getMobile()) + .eqIfPresent(CrmContactDO::getTelephone, pageVO.getTelephone()) + .eqIfPresent(CrmContactDO::getEmail, pageVO.getEmail()) + .eqIfPresent(CrmContactDO::getQq, pageVO.getQq()) + .eqIfPresent(CrmContactDO::getWechat, pageVO.getWechat()) + .orderByDesc(CrmContactDO::getId)); + } + + default PageResult selectPage(CrmContactPageReqVO pageReqVO, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CONTACT.getType(), + CrmContactDO::getId, userId, pageReqVO.getSceneType(), Boolean.FALSE); + // 拼接自身的查询条件 + query.selectAll(CrmContactDO.class) + .likeIfPresent(CrmContactDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmContactDO::getMobile, pageReqVO.getMobile()) + .eqIfPresent(CrmContactDO::getTelephone, pageReqVO.getTelephone()) + .eqIfPresent(CrmContactDO::getEmail, pageReqVO.getEmail()) + .eqIfPresent(CrmContactDO::getQq, pageReqVO.getQq()) + .eqIfPresent(CrmContactDO::getWechat, pageReqVO.getWechat()) + .orderByDesc(CrmContactDO::getId); + return selectJoinPage(pageReqVO, CrmContactDO.class, query); + } + + default List selectListByCustomerId(Long customerId) { + return selectList(CrmContactDO::getCustomerId, customerId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractConfigMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractConfigMapper.java new file mode 100644 index 0000000000..64e6f918bc --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractConfigMapper.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.contract; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 合同配置 Mapper + * + * @author Wanwan + */ +@Mapper +public interface CrmContractConfigMapper extends BaseMapperX { + + default CrmContractConfigDO selectOne() { + return selectOne(new QueryWrapperX().limitN(1)); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractMapper.java new file mode 100644 index 0000000000..e06afb2571 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractMapper.java @@ -0,0 +1,120 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.contract; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * CRM 合同 Mapper + * + * @author dhb52 + */ +@Mapper +public interface CrmContractMapper extends BaseMapperX { + + default CrmContractDO selectByNo(String no) { + return selectOne(CrmContractDO::getNo, no); + } + + default PageResult selectPageByCustomerId(CrmContractPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(CrmContractDO::getCustomerId, pageReqVO.getCustomerId()) + .likeIfPresent(CrmContractDO::getNo, pageReqVO.getNo()) + .likeIfPresent(CrmContractDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmContractDO::getCustomerId, pageReqVO.getCustomerId()) + .eqIfPresent(CrmContractDO::getBusinessId, pageReqVO.getBusinessId()) + .orderByDesc(CrmContractDO::getId)); + } + + default PageResult selectPageByBusinessId(CrmContractPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(CrmContractDO::getBusinessId, pageReqVO.getBusinessId()) + .likeIfPresent(CrmContractDO::getNo, pageReqVO.getNo()) + .likeIfPresent(CrmContractDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmContractDO::getCustomerId, pageReqVO.getCustomerId()) + .eqIfPresent(CrmContractDO::getBusinessId, pageReqVO.getBusinessId()) + .orderByDesc(CrmContractDO::getId)); + } + + default PageResult selectPage(CrmContractPageReqVO pageReqVO, Long userId, CrmContractConfigDO config) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CONTRACT.getType(), + CrmContractDO::getId, userId, pageReqVO.getSceneType(), Boolean.FALSE); + // 拼接自身的查询条件 + query.selectAll(CrmContractDO.class) + .likeIfPresent(CrmContractDO::getNo, pageReqVO.getNo()) + .likeIfPresent(CrmContractDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmContractDO::getCustomerId, pageReqVO.getCustomerId()) + .eqIfPresent(CrmContractDO::getBusinessId, pageReqVO.getBusinessId()) + .eqIfPresent(CrmContractDO::getAuditStatus, pageReqVO.getAuditStatus()) + .orderByDesc(CrmContractDO::getId); + + // Backlog: 即将到期的合同 + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + LocalDateTime endOfToday = LocalDateTimeUtil.endOfDay(LocalDateTime.now()); + if (CrmContractPageReqVO.EXPIRY_TYPE_ABOUT_TO_EXPIRE.equals(pageReqVO.getExpiryType())) { // 即将到期 + query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.APPROVE.getStatus()) + .between(CrmContractDO::getEndTime, beginOfToday, endOfToday.plusDays(config.getNotifyDays())); + } else if (CrmContractPageReqVO.EXPIRY_TYPE_EXPIRED.equals(pageReqVO.getExpiryType())) { // 已到期 + query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.APPROVE.getStatus()) + .lt(CrmContractDO::getEndTime, endOfToday); + } + return selectJoinPage(pageReqVO, CrmContractDO.class, query); + } + + default List selectBatchIds(Collection ids, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 构建数据权限连表条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CONTRACT.getType(), ids, userId); + // 拼接自身的查询条件 + query.selectAll(CrmContractDO.class).in(CrmContractDO::getId, ids).orderByDesc(CrmContractDO::getId); + return selectJoinList(CrmContractDO.class, query); + } + + default Long selectCountByContactId(Long contactId) { + return selectCount(CrmContractDO::getSignContactId, contactId); + } + + default Long selectCountByBusinessId(Long businessId) { + return selectCount(CrmContractDO::getBusinessId, businessId); + } + + default Long selectCountByAudit(Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CONTRACT.getType(), + CrmContractDO::getId, userId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 未审核 + query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus()); + return selectCount(query); + } + + default Long selectCountByRemind(Long userId, CrmContractConfigDO config) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CONTRACT.getType(), + CrmContractDO::getId, userId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 即将到期 + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + LocalDateTime endOfToday = LocalDateTimeUtil.endOfDay(LocalDateTime.now()); + query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.APPROVE.getStatus()) // 必须审批通过! + .between(CrmContractDO::getEndTime, beginOfToday, endOfToday.plusDays(config.getNotifyDays())); + return selectCount(query); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractProductMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractProductMapper.java new file mode 100644 index 0000000000..feafbc4443 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/contract/CrmContractProductMapper.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.contract; + + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractProductDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 合同产品 Mapper + * + * @author HUIHUI + */ +@Mapper +public interface CrmContractProductMapper extends BaseMapperX { + + default List selectListByContractId(Long contractId) { + return selectList(new LambdaQueryWrapperX().eq(CrmContractProductDO::getContractId, contractId)); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerMapper.java new file mode 100644 index 0000000000..5034453887 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/customer/CrmCustomerMapper.java @@ -0,0 +1,189 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.customer; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.CrmCustomerPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.clue.CrmClueDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerPoolConfigDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 客户 Mapper + * + * @author Wanwan + */ +@Mapper +public interface CrmCustomerMapper extends BaseMapperX { + + default Long selectCountByLockStatusAndOwnerUserId(Boolean lockStatus, Long ownerUserId) { + return selectCount(new LambdaUpdateWrapper() + .eq(CrmCustomerDO::getLockStatus, lockStatus) + .eq(CrmCustomerDO::getOwnerUserId, ownerUserId)); + } + + default Long selectCountByDealStatusAndOwnerUserId(@Nullable Boolean dealStatus, Long ownerUserId) { + return selectCount(new LambdaQueryWrapperX() + .eqIfPresent(CrmCustomerDO::getDealStatus, dealStatus) + .eq(CrmCustomerDO::getOwnerUserId, ownerUserId)); + } + + default int updateOwnerUserIdById(Long id, Long ownerUserId) { + return update(new LambdaUpdateWrapper() + .eq(CrmCustomerDO::getId, id) + .set(CrmCustomerDO::getOwnerUserId, ownerUserId)); + } + + default PageResult selectPage(CrmCustomerPageReqVO pageReqVO, Long ownerUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CUSTOMER.getType(), + CrmCustomerDO::getId, ownerUserId, pageReqVO.getSceneType(), pageReqVO.getPool()); + // 拼接自身的查询条件 + query.selectAll(CrmCustomerDO.class) + .likeIfPresent(CrmCustomerDO::getName, pageReqVO.getName()) + .eqIfPresent(CrmCustomerDO::getMobile, pageReqVO.getMobile()) + .eqIfPresent(CrmCustomerDO::getIndustryId, pageReqVO.getIndustryId()) + .eqIfPresent(CrmCustomerDO::getLevel, pageReqVO.getLevel()) + .eqIfPresent(CrmCustomerDO::getSource, pageReqVO.getSource()) + .eqIfPresent(CrmCustomerDO::getFollowUpStatus, pageReqVO.getFollowUpStatus()); + + // backlog 查询 + if (ObjUtil.isNotNull(pageReqVO.getContactStatus())) { + Assert.isNull(pageReqVO.getPool(), "pool 必须是 null"); + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + LocalDateTime endOfToday = LocalDateTimeUtil.endOfDay(LocalDateTime.now()); + if (pageReqVO.getContactStatus().equals(CrmCustomerPageReqVO.CONTACT_TODAY)) { // 今天需联系 + query.between(CrmCustomerDO::getContactNextTime, beginOfToday, endOfToday); + } else if (pageReqVO.getContactStatus().equals(CrmCustomerPageReqVO.CONTACT_EXPIRED)) { // 已逾期 + query.lt(CrmCustomerDO::getContactNextTime, beginOfToday); + } else if (pageReqVO.getContactStatus().equals(CrmCustomerPageReqVO.CONTACT_ALREADY)) { // 已联系 + query.between(CrmCustomerDO::getContactLastTime, beginOfToday, endOfToday); + } else { + throw new IllegalArgumentException("未知联系状态:" + pageReqVO.getContactStatus()); + } + } + return selectJoinPage(pageReqVO, CrmCustomerDO.class, query); + } + + default List selectBatchIds(Collection ids, Long ownerUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CUSTOMER.getType(), ids, ownerUserId); + // 拼接自身的查询条件 + query.selectAll(CrmCustomerDO.class).in(CrmCustomerDO::getId, ids).orderByDesc(CrmCustomerDO::getId); + return selectJoinList(CrmCustomerDO.class, query); + } + + default CrmCustomerDO selectByCustomerName(String name) { + return selectOne(CrmCustomerDO::getName, name); + } + + default PageResult selectPutPoolRemindCustomerPage(CrmCustomerPageReqVO pageReqVO, + CrmCustomerPoolConfigDO poolConfig, + Long ownerUserId) { + final MPJLambdaWrapperX query = buildPutPoolRemindCustomerQuery(pageReqVO, poolConfig, ownerUserId); + return selectJoinPage(pageReqVO, CrmCustomerDO.class, query.selectAll(CrmCustomerDO.class)); + } + + default Long selectPutPoolRemindCustomerCount(CrmCustomerPageReqVO pageReqVO, + CrmCustomerPoolConfigDO poolConfigDO, + Long userId) { + final MPJLambdaWrapperX query = buildPutPoolRemindCustomerQuery(pageReqVO, poolConfigDO, userId); + return selectCount(query); + } + + static MPJLambdaWrapperX buildPutPoolRemindCustomerQuery(CrmCustomerPageReqVO pageReqVO, + CrmCustomerPoolConfigDO poolConfig, + Long ownerUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CUSTOMER.getType(), + CrmCustomerDO::getId, ownerUserId, pageReqVO.getSceneType(), null); + + // 未锁定 + 未成交 + query.eq(CrmCustomerDO::getLockStatus, false).eq(CrmCustomerDO::getDealStatus, false); + + // 情况一:未成交提醒日期区间 + Integer dealExpireDays = poolConfig.getDealExpireDays(); + LocalDateTime startDealRemindTime = LocalDateTime.now().minusDays(dealExpireDays); + LocalDateTime endDealRemindTime = LocalDateTime.now() + .minusDays(Math.max(dealExpireDays - poolConfig.getNotifyDays(), 0)); + // 情况二:未跟进提醒日期区间 + Integer contactExpireDays = poolConfig.getContactExpireDays(); + LocalDateTime startContactRemindTime = LocalDateTime.now().minusDays(contactExpireDays); + LocalDateTime endContactRemindTime = LocalDateTime.now() + .minusDays(Math.max(contactExpireDays - poolConfig.getNotifyDays(), 0)); + query.and(q -> { + // 情况一:成交超时提醒 + q.between(CrmCustomerDO::getOwnerTime, startDealRemindTime, endDealRemindTime) + // 情况二:跟进超时提醒 + .or(w -> w.between(CrmCustomerDO::getOwnerTime, startContactRemindTime, endContactRemindTime) + .and(p -> p.between(CrmCustomerDO::getContactLastTime, startContactRemindTime, endContactRemindTime) + .or().isNull(CrmCustomerDO::getContactLastTime))); + }); + return query; + } + + /** + * 获得需要过期到公海的客户列表 + * + * @return 客户列表 + */ + default List selectListByAutoPool(CrmCustomerPoolConfigDO poolConfig) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.gt(CrmCustomerDO::getOwnerUserId, 0); + // 未锁定 + 未成交 + query.eq(CrmCustomerDO::getLockStatus, false).eq(CrmCustomerDO::getDealStatus, false); + // 已经超时 + LocalDateTime dealExpireTime = LocalDateTime.now().minusDays(poolConfig.getDealExpireDays()); + LocalDateTime contactExpireTime = LocalDateTime.now().minusDays(poolConfig.getContactExpireDays()); + query.and(q -> { + // 情况一:成交超时 + q.lt(CrmCustomerDO::getOwnerTime, dealExpireTime) + // 情况二:跟进超时 + .or(w -> w.lt(CrmCustomerDO::getOwnerTime, contactExpireTime) + .and(p -> p.lt(CrmCustomerDO::getContactLastTime, contactExpireTime) + .or().isNull(CrmCustomerDO::getContactLastTime))); + }); + return selectList(query); + } + + default Long selectCountByTodayContact(Long ownerUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CUSTOMER.getType(), + CrmCustomerDO::getId, ownerUserId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 今天需联系 + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + LocalDateTime endOfToday = LocalDateTimeUtil.endOfDay(LocalDateTime.now()); + query.between(CrmCustomerDO::getContactNextTime, beginOfToday, endOfToday); + return selectCount(query); + } + + default Long selectCountByFollow(Long ownerUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_CUSTOMER.getType(), + CrmCustomerDO::getId, ownerUserId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 未跟进 + query.eq(CrmClueDO::getFollowUpStatus, false); + return selectCount(query); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/product/CrmProductMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/product/CrmProductMapper.java new file mode 100644 index 0000000000..4d1d61809d --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/product/CrmProductMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.product; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * CRM 产品 Mapper + * + * @author ZanGe丶 + */ +@Mapper +public interface CrmProductMapper extends BaseMapperX { + + default PageResult selectPage(CrmProductPageReqVO reqVO) { + return selectPage(reqVO, new MPJLambdaWrapperX() + .likeIfPresent(CrmProductDO::getName, reqVO.getName()) + .eqIfPresent(CrmProductDO::getStatus, reqVO.getStatus()) + .orderByDesc(CrmProductDO::getId)); + } + + default CrmProductDO selectByNo(String no) { + return selectOne(CrmProductDO::getNo, no); + } + + default Long selectCountByCategoryId(Long categoryId) { + return selectCount(CrmProductDO::getCategoryId, categoryId); + } + + default List selectListByStatus(Integer status) { + return selectList(CrmProductDO::getStatus, status); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java new file mode 100644 index 0000000000..5a5e9ce2b6 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.receivable; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 回款 Mapper + * + * @author 赤焰 + */ +@Mapper +public interface CrmReceivableMapper extends BaseMapperX { + + default CrmReceivableDO selectByNo(String no) { + return selectOne(CrmReceivableDO::getNo, no); + } + + default PageResult selectPageByCustomerId(CrmReceivablePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(CrmReceivableDO::getCustomerId, reqVO.getCustomerId()) // 必须传递 + .eqIfPresent(CrmReceivableDO::getNo, reqVO.getNo()) + .eqIfPresent(CrmReceivableDO::getContractId, reqVO.getContractId()) + .eqIfPresent(CrmReceivableDO::getPlanId, reqVO.getPlanId()) + .orderByDesc(CrmReceivableDO::getId)); + } + + default PageResult selectPage(CrmReceivablePageReqVO pageReqVO, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE.getType(), + CrmReceivableDO::getId, userId, pageReqVO.getSceneType(), Boolean.FALSE); + // 拼接自身的查询条件 + query.selectAll(CrmReceivableDO.class) + .eqIfPresent(CrmReceivableDO::getNo, pageReqVO.getNo()) + .eqIfPresent(CrmReceivableDO::getPlanId, pageReqVO.getPlanId()) + .eqIfPresent(CrmReceivableDO::getContractId, pageReqVO.getContractId()) + .eqIfPresent(CrmReceivableDO::getAuditStatus, pageReqVO.getAuditStatus()) + .orderByDesc(CrmReceivableDO::getId); + return selectJoinPage(pageReqVO, CrmReceivableDO.class, query); + } + + default List selectBatchIds(Collection ids, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE.getType(), ids, userId); + // 拼接自身的查询条件 + query.selectAll(CrmReceivableDO.class).in(CrmReceivableDO::getId, ids).orderByDesc(CrmReceivableDO::getId); + return selectJoinList(CrmReceivableDO.class, query); + } + + default Long selectCountByAudit(Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE.getType(), + CrmReceivableDO::getId, userId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 未审核 + query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus()); + return selectCount(query); + } + + default List selectListByContractIdAndStatus(Long contractId, Collection auditStatuses) { + return selectList(new LambdaQueryWrapperX() + .eq(CrmReceivableDO::getContractId, contractId) + .in(CrmReceivableDO::getAuditStatus, auditStatuses)); + } + + default Map selectReceivablePriceMapByContractId(Collection contractIds) { + if (CollUtil.isEmpty(contractIds)) { + return Collections.emptyMap(); + } + // SQL sum 查询 + List> result = selectMaps(new QueryWrapper() + .select("contract_id, SUM(price) AS total_price") + .in("audit_status", CrmAuditStatusEnum.DRAFT.getStatus(), // 草稿 + 审批中 + 审批通过 + CrmAuditStatusEnum.PROCESS, CrmAuditStatusEnum.APPROVE.getStatus()) + .groupBy("contract_id") + .in("contract_id", contractIds)); + // 获得金额 + return convertMap(result, obj -> (Long) obj.get("contract_id"), obj -> (BigDecimal) obj.get("total_price")); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java new file mode 100644 index 0000000000..4d53897936 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.receivable; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanPageReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * 回款计划 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface CrmReceivablePlanMapper extends BaseMapperX { + + default CrmReceivablePlanDO selectMaxPeriodByContractId(Long contractId) { + return selectOne(new MPJLambdaWrapperX() + .eq(CrmReceivablePlanDO::getContractId, contractId) + .orderByDesc(CrmReceivablePlanDO::getPeriod) + .last("LIMIT 1")); + } + + default PageResult selectPageByCustomerId(CrmReceivablePlanPageReqVO reqVO) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + if (Objects.nonNull(reqVO.getContractNo())) { // 根据合同编号检索 + query.innerJoin(CrmContractDO.class, on -> on.like(CrmContractDO::getNo, reqVO.getContractNo()) + .eq(CrmContractDO::getId, CrmReceivablePlanDO::getContractId)); + } + query.eq(CrmReceivablePlanDO::getCustomerId, reqVO.getCustomerId()) // 必须传递 + .eqIfPresent(CrmReceivablePlanDO::getContractId, reqVO.getContractId()) + .orderByDesc(CrmReceivablePlanDO::getPeriod); + return selectJoinPage(reqVO, CrmReceivablePlanDO.class, query); + } + + default PageResult selectPage(CrmReceivablePlanPageReqVO pageReqVO, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), + CrmReceivablePlanDO::getId, userId, pageReqVO.getSceneType(), Boolean.FALSE); + // 拼接自身的查询条件 + query.selectAll(CrmReceivablePlanDO.class) + .eqIfPresent(CrmReceivablePlanDO::getCustomerId, pageReqVO.getCustomerId()) + .eqIfPresent(CrmReceivablePlanDO::getContractId, pageReqVO.getContractId()) + .orderByDesc(CrmReceivablePlanDO::getPeriod); + if (Objects.nonNull(pageReqVO.getContractNo())) { // 根据合同编号检索 + query.innerJoin(CrmContractDO.class, on -> on.like(CrmContractDO::getNo, pageReqVO.getContractNo()) + .eq(CrmContractDO::getId, CrmReceivablePlanDO::getContractId)); + } + + // Backlog: 回款提醒类型 + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + if (CrmReceivablePlanPageReqVO.REMIND_TYPE_NEEDED.equals(pageReqVO.getRemindType())) { // 待回款 + query.isNull(CrmReceivablePlanDO::getReceivableId) // 未回款 + .lt(CrmReceivablePlanDO::getReturnTime, beginOfToday) // 已逾期 + .lt(CrmReceivablePlanDO::getRemindTime, beginOfToday); // 今天开始提醒 + } else if (CrmReceivablePlanPageReqVO.REMIND_TYPE_EXPIRED.equals(pageReqVO.getRemindType())) { // 已逾期 + query.isNull(CrmReceivablePlanDO::getReceivableId) // 未回款 + .ge(CrmReceivablePlanDO::getReturnTime, beginOfToday); // 已逾期 + } else if (CrmReceivablePlanPageReqVO.REMIND_TYPE_RECEIVED.equals(pageReqVO.getRemindType())) { // 已回款 + query.isNotNull(CrmReceivablePlanDO::getReceivableId); + } + return selectJoinPage(pageReqVO, CrmReceivablePlanDO.class, query); + } + + default List selectBatchIds(Collection ids, Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 拼接数据权限的查询条件 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), ids, userId); + // 拼接自身的查询条件 + query.selectAll(CrmReceivablePlanDO.class).in(CrmReceivablePlanDO::getId, ids).orderByDesc(CrmReceivablePlanDO::getId); + return selectJoinList(CrmReceivablePlanDO.class, query); + } + + default Long selectReceivablePlanCountByRemind(Long userId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + // 我负责的 + 非公海 + CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), + CrmReceivablePlanDO::getId, userId, CrmSceneTypeEnum.OWNER.getType(), Boolean.FALSE); + // 未回款 + 已逾期 + 今天开始提醒 + LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + query.isNull(CrmReceivablePlanDO::getReceivableId) // 未回款 + .lt(CrmReceivablePlanDO::getReturnTime, beginOfToday) // 已逾期 + .lt(CrmReceivablePlanDO::getRemindTime, beginOfToday); // 今天开始提醒 + return selectCount(query); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/statistics/CrmStatisticsRankingMapper.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/statistics/CrmStatisticsRankingMapper.java new file mode 100644 index 0000000000..4b51ab2fe9 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/statistics/CrmStatisticsRankingMapper.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.crm.dal.mysql.statistics; + +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRanKRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRankReqVO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * CRM 排行榜统计 Mapper + * + * @author anhaohao + */ +@Mapper +public interface CrmStatisticsRankingMapper { + + /** + * 查询合同金额排行榜 + * + * @param rankReqVO 参数 + * @return 合同金额排行榜 + */ + List selectContractPriceRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询回款金额排行榜 + * + * @param rankReqVO 参数 + * @return 回款金额排行榜 + */ + List selectReceivablePriceRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询签约合同数量排行榜 + * + * @param rankReqVO 参数 + * @return 签约合同数量排行榜 + */ + List selectContractCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询产品销量排行榜 + * + * @param rankReqVO 参数 + * @return 产品销量排行榜 + */ + List selectProductSalesRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询新增客户数排行榜 + * + * @param rankReqVO 参数 + * @return 新增客户数排行榜 + */ + List selectCustomerCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询联系人数量排行榜 + * + * @param rankReqVO 参数 + * @return 联系人数量排行榜 + */ + List selectContactsCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询跟进次数排行榜 + * + * @param rankReqVO 参数 + * @return 跟进次数排行榜 + */ + List selectFollowCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 查询跟进客户数排行榜 + * + * @param rankReqVO 参数 + * @return 跟进客户数排行榜 + */ + List selectFollowCustomerCountRank(CrmStatisticsRankReqVO rankReqVO); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/RedisKeyConstants.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/RedisKeyConstants.java new file mode 100644 index 0000000000..2932c1db14 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/RedisKeyConstants.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.crm.dal.redis; + +/** + * CRM Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface RedisKeyConstants { + + /** + * 序号的缓存 + * + * KEY 格式:trade_no:{prefix} + * VALUE 数据格式:编号自增 + */ + String NO = "crm:seq_no:"; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/no/CrmNoRedisDAO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/no/CrmNoRedisDAO.java new file mode 100644 index 0000000000..68f0835ecc --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/dal/redis/no/CrmNoRedisDAO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.crm.dal.redis.no; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateUtil; +import cn.iocoder.yudao.module.crm.dal.redis.RedisKeyConstants; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.time.Duration; +import java.time.LocalDateTime; + + +/** + * Crm 订单序号的 Redis DAO + * + * @author HUIHUI + */ +@Repository +public class CrmNoRedisDAO { + + /** + * 合同 {@link cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO} + */ + public static final String CONTRACT_NO_PREFIX = "HT"; + + /** + * 回款 {@link cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO} + */ + public static final String RECEIVABLE_PREFIX = "HK"; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 生成序号,使用当前日期,格式为 {PREFIX} + yyyyMMdd + 6 位自增 + * 例如说:QTRK 202109 000001 (没有中间空格) + * + * @param prefix 前缀 + * @return 序号 + */ + public String generate(String prefix) { + // 递增序号 + String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); + String key = RedisKeyConstants.NO + noPrefix; + Long no = stringRedisTemplate.opsForValue().increment(key); + // 设置过期时间 + stringRedisTemplate.expire(key, Duration.ofDays(1L)); + return noPrefix + String.format("%06d", no); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerIndustryParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerIndustryParseFunction.java new file mode 100644 index 0000000000..495255056d --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerIndustryParseFunction.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_INDUSTRY; + +/** + * 行业的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class CrmCustomerIndustryParseFunction implements IParseFunction { + + public static final String NAME = "getCustomerIndustry"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(CRM_CUSTOMER_INDUSTRY, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerLevelParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerLevelParseFunction.java new file mode 100644 index 0000000000..0cf44c8543 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerLevelParseFunction.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_LEVEL; + +/** + * 客户等级的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class CrmCustomerLevelParseFunction implements IParseFunction { + + public static final String NAME = "getCustomerLevel"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(CRM_CUSTOMER_LEVEL, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerSourceParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerSourceParseFunction.java new file mode 100644 index 0000000000..638ca79511 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmCustomerSourceParseFunction.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_CUSTOMER_SOURCE; + +/** + * CRM 客户来源的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class CrmCustomerSourceParseFunction implements IParseFunction { + + public static final String NAME = "getCustomerSource"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(CRM_CUSTOMER_SOURCE, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductStatusParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductStatusParseFunction.java new file mode 100644 index 0000000000..4c626faddb --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductStatusParseFunction.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 产品状态的 {@link IParseFunction} 实现类 + * + * @author anhaohao + */ +@Component +@Slf4j +public class CrmProductStatusParseFunction implements IParseFunction { + + public static final String NAME = "getProductStatusName"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(DictTypeConstants.CRM_PRODUCT_STATUS, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductUnitParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductUnitParseFunction.java new file mode 100644 index 0000000000..da7b82d164 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmProductUnitParseFunction.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 产品单位的 {@link IParseFunction} 实现类 + * + * @author anhaohao + */ +@Component +@Slf4j +public class CrmProductUnitParseFunction implements IParseFunction { + + public static final String NAME = "getProductUnitName"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(DictTypeConstants.CRM_PRODUCT_UNIT, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivablePlanParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivablePlanParseFunction.java new file mode 100644 index 0000000000..e9943eb671 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivablePlanParseFunction.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivablePlanService; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * CRM 回款计划的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class CrmReceivablePlanParseFunction implements IParseFunction { + + public static final String NAME = "getReceivablePlanServiceById"; + + @Resource + private CrmReceivablePlanService receivablePlanService; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(Long.parseLong(value.toString())); + return receivablePlan == null ? "" : receivablePlan.getPeriod().toString(); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivableReturnTypeParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivableReturnTypeParseFunction.java new file mode 100644 index 0000000000..a8e8f8d130 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/CrmReceivableReturnTypeParseFunction.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import static cn.iocoder.yudao.module.crm.enums.DictTypeConstants.CRM_RECEIVABLE_RETURN_TYPE; + +/** + * CRM 回款方式的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Slf4j +@Component +public class CrmReceivableReturnTypeParseFunction implements IParseFunction { + + public static final String NAME = "getReceivableReturnType"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(CRM_RECEIVABLE_RETURN_TYPE, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysBooleanParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysBooleanParseFunction.java new file mode 100644 index 0000000000..1a7e2358ad --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysBooleanParseFunction.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.infra.enums.DictTypeConstants; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 是否类型的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class SysBooleanParseFunction implements IParseFunction { + + public static final String NAME = "getBoolean"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(DictTypeConstants.BOOLEAN_STRING, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysSexParseFunction.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysSexParseFunction.java new file mode 100644 index 0000000000..29d2706d15 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/operatelog/core/SysSexParseFunction.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.crm.framework.operatelog.core; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.system.enums.DictTypeConstants; +import com.mzt.logapi.service.IParseFunction; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 行业的 {@link IParseFunction} 实现类 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class SysSexParseFunction implements IParseFunction { + + public static final String NAME = "getSex"; + + @Override + public boolean executeBefore() { + return true; // 先转换值后对比 + } + + @Override + public String functionName() { + return NAME; + } + + @Override + public String apply(Object value) { + if (StrUtil.isEmptyIfStr(value)) { + return ""; + } + return DictFrameworkUtils.getDictDataLabel(DictTypeConstants.USER_SEX, value.toString()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java new file mode 100644 index 0000000000..deeb04e5f1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java @@ -0,0 +1,130 @@ +package cn.iocoder.yudao.module.crm.framework.permission.core.aop; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.spring.SpringExpressionUtils; +import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; +import cn.iocoder.yudao.module.crm.dal.dataobject.permission.CrmPermissionDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CRM_PERMISSION_DENIED; + +/** + * Crm 数据权限校验 AOP 切面 + * + * @author HUIHUI + */ +@Component +@Aspect +@Slf4j +public class CrmPermissionAspect { + + @Resource + private CrmPermissionService crmPermissionService; + + @Before("@annotation(crmPermission)") + public void doBefore(JoinPoint joinPoint, CrmPermission crmPermission) { + // 1.1 获取相关属性值 + Map expressionValues = parseExpressions(joinPoint, crmPermission); + Integer bizType = StrUtil.isEmpty(crmPermission.bizTypeValue()) ? + crmPermission.bizType()[0].getType() : (Integer) expressionValues.get(crmPermission.bizTypeValue()); // 模块类型 + // 1.2 处理兼容多个 bizId 的情况 + Object object = expressionValues.get(crmPermission.bizId()); // 模块数据编号 + Set bizIds = new HashSet<>(); + if (object instanceof Collection) { + bizIds.addAll(convertSet((Collection) object, item -> Long.parseLong(item.toString()))); + } else { + bizIds.add(Long.parseLong(object.toString())); + } + Integer permissionLevel = crmPermission.level().getLevel(); // 需要的权限级别 + + // 2. 逐个校验权限 + List permissionList = crmPermissionService.getPermissionListByBiz(bizType, bizIds); + Map> multiMap = convertMultiMap(permissionList, CrmPermissionDO::getBizId); + bizIds.forEach(bizId -> validatePermission(bizType, multiMap.get(bizId), permissionLevel)); + } + + private void validatePermission(Integer bizType, List bizPermissions, Integer permissionLevel) { + // 1. 如果是超级管理员则直接通过 + if (CrmPermissionUtils.isCrmAdmin()) { + return; + } + // 1.1 没有数据权限的情况 + if (CollUtil.isEmpty(bizPermissions)) { + // 公海数据如果没有团队成员大家也因该有读权限才对 + if (CrmPermissionLevelEnum.isRead(permissionLevel)) { + return; + } + // 没有数据权限的情况下超出了读权限直接报错,避免后面校验空指针 + throw exception(CRM_PERMISSION_DENIED, CrmBizTypeEnum.getNameByType(bizType)); + } else { // 1.2 有数据权限但是没有负责人的情况 + if (!anyMatch(bizPermissions, item -> CrmPermissionLevelEnum.isOwner(item.getLevel()))) { + if (CrmPermissionLevelEnum.isRead(permissionLevel)) { + return; + } + } + } + + // 2.1 情况一:如果自己是负责人,则默认有所有权限 + CrmPermissionDO userPermission = CollUtil.findOne(bizPermissions, permission -> ObjUtil.equal(permission.getUserId(), getUserId())); + if (userPermission != null) { + if (CrmPermissionLevelEnum.isOwner(userPermission.getLevel())) { + return; + } + // 2.2 情况二:校验自己是否有读权限 + if (CrmPermissionLevelEnum.isRead(permissionLevel)) { + if (CrmPermissionLevelEnum.isRead(userPermission.getLevel()) // 校验当前用户是否有读权限 + || CrmPermissionLevelEnum.isWrite(userPermission.getLevel())) { // 校验当前用户是否有写权限 + return; + } + } + // 2.3 情况三:校验自己是否有写权限 + if (CrmPermissionLevelEnum.isWrite(permissionLevel)) { + if (CrmPermissionLevelEnum.isWrite(userPermission.getLevel())) { // 校验当前用户是否有写权限 + return; + } + } + } + // 2.4 没有权限,抛出异常 + log.info("[doBefore][userId({}) 要求权限({}) 实际权限({}) 数据校验错误]", // 打个 info 日志,方便后续排查问题、审计 + getUserId(), permissionLevel, toJsonString(userPermission)); + throw exception(CRM_PERMISSION_DENIED, CrmBizTypeEnum.getNameByType(bizType)); + } + + /** + * 获得用户编号 + * + * @return 用户编号 + */ + private static Long getUserId() { + return WebFrameworkUtils.getLoginUserId(); + } + + private static Map parseExpressions(JoinPoint joinPoint, CrmPermission crmPermission) { + // 1. 需要解析的表达式 + List expressionStrings = new ArrayList<>(2); + expressionStrings.add(crmPermission.bizId()); + if (StrUtil.isNotEmpty(crmPermission.bizTypeValue())) { // 为空则表示 bizType 有值 + expressionStrings.add(crmPermission.bizTypeValue()); + } + // 2. 执行解析 + return SpringExpressionUtils.parseExpressions(joinPoint, expressionStrings); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/config/CrmWebConfiguration.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/config/CrmWebConfiguration.java new file mode 100644 index 0000000000..4f50375324 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/config/CrmWebConfiguration.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.crm.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * crm 模块的 web 组件的 Configuration + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class CrmWebConfiguration { + + /** + * crm 模块的 API 分组 + */ + @Bean + public GroupedOpenApi crmGroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("crm"); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/package-info.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/package-info.java new file mode 100644 index 0000000000..e18c3cdb51 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * trade 模块的 web 配置 + */ +package cn.iocoder.yudao.module.crm.framework.web; diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/job/package-info.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/job/package-info.java new file mode 100644 index 0000000000..2214e6b952 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/job/package-info.java @@ -0,0 +1,4 @@ +/** + * 定时任务 + */ +package cn.iocoder.yudao.module.crm.job; \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessService.java new file mode 100644 index 0000000000..f71680acd8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessService.java @@ -0,0 +1,188 @@ +package cn.iocoder.yudao.module.crm.service.business; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessTransferReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessUpdateStatusReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessProductDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.enums.business.CrmBusinessEndStatusEnum; + +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 商机 Service 接口 + * + * @author ljlleo + */ +public interface CrmBusinessService { + + /** + * 创建商机 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 编号 + */ + Long createBusiness(@Valid CrmBusinessSaveReqVO createReqVO, Long userId); + + /** + * 更新商机 + * + * @param updateReqVO 更新信息 + */ + void updateBusiness(@Valid CrmBusinessSaveReqVO updateReqVO); + + /** + * 更新商机相关跟进信息 + * + * @param id 编号 + * @param contactNextTime 下次联系时间 + * @param contactLastContent 最后联系内容 + */ + void updateBusinessFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent); + + /** + * 更新商机的下次联系时间 + * + * @param ids 编号数组 + * @param contactNextTime 下次联系时间 + */ + void updateBusinessContactNextTime(Collection ids, LocalDateTime contactNextTime); + + /** + * 更新商机的状态 + * + * @param reqVO 更新请求 + */ + void updateBusinessStatus(CrmBusinessUpdateStatusReqVO reqVO); + + /** + * 删除商机 + * + * @param id 编号 + */ + void deleteBusiness(Long id); + + /** + * 商机转移 + * + * @param reqVO 请求 + * @param userId 用户编号 + */ + void transferBusiness(CrmBusinessTransferReqVO reqVO, Long userId); + + /** + * 获得商机 + * + * @param id 编号 + * @return 商机 + */ + CrmBusinessDO getBusiness(Long id); + + /** + * 校验商机是否有效 + * + * @param id 编号 + * @return 商机 + */ + CrmBusinessDO validateBusiness(Long id); + + /** + * 获得商机列表 + * + * @param ids 编号 + * @return 商机列表 + */ + List getBusinessList(Collection ids); + + /** + * 获得商机 Map + * + * @param ids 编号 + * @return 商机 Map + */ + default Map getBusinessMap(Collection ids) { + return convertMap(getBusinessList(ids), CrmBusinessDO::getId); + } + + /** + * 获得指定商机编号的产品列表 + * + * @param businessId 商机编号 + * @return 商机产品列表 + */ + List getBusinessProductListByBusinessId(Long businessId); + + /** + * 获得商机分页 + * + * 数据权限:基于 {@link CrmBusinessDO} + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 商机分页 + */ + PageResult getBusinessPage(CrmBusinessPageReqVO pageReqVO, Long userId); + + /** + * 获得商机分页,基于指定客户 + * + * 数据权限:基于 {@link CrmCustomerDO} 读取 + * + * @param pageReqVO 分页查询 + * @return 商机分页 + */ + PageResult getBusinessPageByCustomerId(CrmBusinessPageReqVO pageReqVO); + + /** + * 获得商机分页,基于指定联系人 + * + * 数据权限:基于 {@link CrmContactDO} 读取 + * + * @param pageReqVO 分页参数 + * @return 商机分页 + */ + PageResult getBusinessPageByContact(CrmBusinessPageReqVO pageReqVO); + + /** + * 获取关联客户的商机数量 + * + * @param customerId 客户编号 + * @return 数量 + */ + Long getBusinessCountByCustomerId(Long customerId); + + /** + * 获得使用指定商机状态组的商机数量 + * + * @param statusTypeId 商机状态组编号 + * @return 数量 + */ + Long getBusinessCountByStatusTypeId(Long statusTypeId); + + /** + * 获得商机状态名称 + * + * @param endStatus 结束状态 + * @param status 商机状态 + * @return 商机状态名称 + */ + default String getBusinessStatusName(Integer endStatus, CrmBusinessStatusDO status) { + if (endStatus != null) { + return CrmBusinessEndStatusEnum.fromStatus(endStatus).getName(); + } + return status.getName(); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java new file mode 100644 index 0000000000..47865c64f6 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessServiceImpl.java @@ -0,0 +1,373 @@ +package cn.iocoder.yudao.module.crm.service.business; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessTransferReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.business.CrmBusinessUpdateStatusReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusinessReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessProductDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactBusinessDO; +import cn.iocoder.yudao.module.crm.dal.mysql.business.CrmBusinessMapper; +import cn.iocoder.yudao.module.crm.dal.mysql.business.CrmBusinessProductMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.crm.service.product.CrmProductService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + +/** + * 商机 Service 实现类 + * + * @author ljlleo + */ +@Service +@Validated +public class CrmBusinessServiceImpl implements CrmBusinessService { + + @Resource + private CrmBusinessMapper businessMapper; + @Resource + private CrmBusinessProductMapper businessProductMapper; + + @Resource + private CrmBusinessStatusService businessStatusService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private CrmContractService contractService; + @Resource + private CrmCustomerService customerService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private CrmContactService contactService; + @Resource + private CrmPermissionService permissionService; + @Resource + private CrmContactBusinessService contactBusinessService; + @Resource + private CrmProductService productService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_CREATE_SUB_TYPE, bizNo = "{{#business.id}}", + success = CRM_BUSINESS_CREATE_SUCCESS) + public Long createBusiness(CrmBusinessSaveReqVO createReqVO, Long userId) { + // 1.1 校验产品项的有效性 + List businessProducts = validateBusinessProducts(createReqVO.getProducts()); + // 1.2 校验关联字段 + validateRelationDataExists(createReqVO); + + // 2.1 插入商机 + CrmBusinessDO business = BeanUtils.toBean(createReqVO, CrmBusinessDO.class); + business.setStatusId(businessStatusService.getBusinessStatusListByTypeId(createReqVO.getStatusTypeId()).get(0).getId()); // 默认状态 + calculateTotalPrice(business, businessProducts); + businessMapper.insert(business); + // 2.2 插入商机关联商品 + if (CollUtil.isNotEmpty(businessProducts)) { + businessProducts.forEach(item -> item.setBusinessId(business.getId())); + businessProductMapper.insertBatch(businessProducts); + } + + // 3. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(business.getOwnerUserId()) + .setBizType(CrmBizTypeEnum.CRM_BUSINESS.getType()).setBizId(business.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + + // 4. 在联系人的详情页,如果直接【新建商机】,则需要关联下 + if (createReqVO.getContactId() != null) { + contactBusinessService.createContactBusinessList(new CrmContactBusinessReqVO().setContactId(createReqVO.getContactId()) + .setBusinessIds(Collections.singletonList(business.getId()))); + } + + // 5. 记录操作日志上下文 + LogRecordContext.putVariable("business", business); + return business.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_BUSINESS_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateBusiness(CrmBusinessSaveReqVO updateReqVO) { + updateReqVO.setOwnerUserId(null).setStatusTypeId(null); // 不允许更新的字段 + // 1.1 校验存在 + CrmBusinessDO oldBusiness = validateBusinessExists(updateReqVO.getId()); + // 1.2 校验产品项的有效性 + List businessProducts = validateBusinessProducts(updateReqVO.getProducts()); + // 1.3 校验关联字段 + validateRelationDataExists(updateReqVO); + + // 2.1 更新商机 + CrmBusinessDO updateObj = BeanUtils.toBean(updateReqVO, CrmBusinessDO.class); + calculateTotalPrice(updateObj, businessProducts); + businessMapper.updateById(updateObj); + // 2.2 更新商机关联商品 + updateBusinessProduct(updateObj.getId(), businessProducts); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldBusiness, CrmBusinessSaveReqVO.class)); + LogRecordContext.putVariable("businessName", oldBusiness.getName()); + } + + @Override + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_FOLLOW_UP_SUB_TYPE, bizNo = "{{#id}", + success = CRM_BUSINESS_FOLLOW_UP_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateBusinessFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent) { + // 1. 校验存在 + CrmBusinessDO business = validateBusinessExists(id); + + // 2. 更新联系人的跟进信息 + businessMapper.updateById(new CrmBusinessDO().setId(id).setFollowUpStatus(true).setContactNextTime(contactNextTime) + .setContactLastTime(LocalDateTime.now())); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("businessName", business.getName()); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#ids", level = CrmPermissionLevelEnum.WRITE) + public void updateBusinessContactNextTime(Collection ids, LocalDateTime contactNextTime) { + businessMapper.updateBatch(convertList(ids, id -> new CrmBusinessDO().setId(id).setContactNextTime(contactNextTime))); + } + + private void updateBusinessProduct(Long id, List newList) { + List oldList = businessProductMapper.selectListByBusinessId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setBusinessId(id)); + businessProductMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + businessProductMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + businessProductMapper.deleteBatchIds(convertSet(diffList.get(2), CrmBusinessProductDO::getId)); + } + } + + private void validateRelationDataExists(CrmBusinessSaveReqVO saveReqVO) { + // 校验商机状态 + if (saveReqVO.getStatusTypeId() != null) { + businessStatusService.validateBusinessStatusType(saveReqVO.getStatusTypeId()); + } + // 校验客户 + if (saveReqVO.getCustomerId() != null) { + customerService.validateCustomer(saveReqVO.getCustomerId()); + } + // 校验联系人 + if (saveReqVO.getContactId() != null) { + contactService.validateContact(saveReqVO.getContactId()); + } + // 校验负责人 + if (saveReqVO.getOwnerUserId() != null) { + adminUserApi.validateUser(saveReqVO.getOwnerUserId()); + } + } + + private List validateBusinessProducts(List list) { + // 1. 校验产品存在 + productService.validProductList(convertSet(list, CrmBusinessSaveReqVO.Product::getProductId)); + // 2. 转化为 CrmBusinessProductDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, CrmBusinessProductDO.class, + item -> item.setTotalPrice(MoneyUtils.priceMultiply(item.getBusinessPrice(), item.getCount())))); + } + + private void calculateTotalPrice(CrmBusinessDO business, List businessProducts) { + business.setTotalProductPrice(getSumValue(businessProducts, CrmBusinessProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + BigDecimal discountPrice = MoneyUtils.priceMultiplyPercent(business.getTotalProductPrice(), business.getDiscountPercent()); + business.setTotalPrice(business.getTotalProductPrice().subtract(discountPrice)); + } + + @Override + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_UPDATE_STATUS_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_BUSINESS_UPDATE_STATUS_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateBusinessStatus(CrmBusinessUpdateStatusReqVO reqVO) { + // 1.1 校验存在 + CrmBusinessDO business = validateBusinessExists(reqVO.getId()); + // 1.2 校验商机未结束 + if (business.getEndStatus() != null) { + throw exception(BUSINESS_UPDATE_STATUS_FAIL_END_STATUS); + } + // 1.3 校验商机状态 + CrmBusinessStatusDO status = null; + if (reqVO.getStatusId() != null) { + status = businessStatusService.validateBusinessStatus(business.getStatusTypeId(), reqVO.getStatusId()); + } + // 1.4 校验是不是状态没变更 + if ((reqVO.getStatusId() != null && reqVO.getStatusId().equals(business.getStatusId())) + || (reqVO.getEndStatus() != null && reqVO.getEndStatus().equals(business.getEndStatus()))) { + throw exception(BUSINESS_UPDATE_STATUS_FAIL_STATUS_EQUALS); + } + + // 2. 更新商机状态 + businessMapper.updateById(new CrmBusinessDO().setId(reqVO.getId()).setStatusId(reqVO.getStatusId()) + .setEndStatus(reqVO.getEndStatus())); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("businessName", business.getName()); + LogRecordContext.putVariable("oldStatusName", getBusinessStatusName(business.getEndStatus(), + businessStatusService.getBusinessStatus(business.getStatusId()))); + LogRecordContext.putVariable("newStatusName", getBusinessStatusName(reqVO.getEndStatus(), status)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_BUSINESS_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteBusiness(Long id) { + // 1.1 校验存在 + CrmBusinessDO business = validateBusinessExists(id); + // 1.2 校验是否关联合同 + validateContractExists(id); + + // 删除商机 + businessMapper.deleteById(id); + // 删除数据权限 + permissionService.deletePermission(CrmBizTypeEnum.CRM_BUSINESS.getType(), id); + + // 记录操作日志上下文 + LogRecordContext.putVariable("businessName", business.getName()); + } + + /** + * 删除校验合同是关联合同 + * + * @param businessId 商机id + * @author lzxhqs + */ + private void validateContractExists(Long businessId) { + if (contractService.getContractCountByBusinessId(businessId) > 0) { + throw exception(BUSINESS_DELETE_FAIL_CONTRACT_EXISTS); + } + } + + private CrmBusinessDO validateBusinessExists(Long id) { + CrmBusinessDO crmBusiness = businessMapper.selectById(id); + if (crmBusiness == null) { + throw exception(BUSINESS_NOT_EXISTS); + } + return crmBusiness; + } + + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_BUSINESS_TYPE, subType = CRM_BUSINESS_TRANSFER_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_BUSINESS_TRANSFER_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void transferBusiness(CrmBusinessTransferReqVO reqVO, Long userId) { + // 1 校验商机是否存在 + CrmBusinessDO business = validateBusinessExists(reqVO.getId()); + + // 2.1 数据权限转移 + permissionService.transferPermission(new CrmPermissionTransferReqBO(userId, CrmBizTypeEnum.CRM_BUSINESS.getType(), + reqVO.getNewOwnerUserId(), reqVO.getId(), CrmPermissionLevelEnum.OWNER.getLevel())); + // 2.2 设置新的负责人 + businessMapper.updateOwnerUserIdById(reqVO.getId(), reqVO.getNewOwnerUserId()); + + // 记录操作日志上下文 + LogRecordContext.putVariable("business", business); + } + + //======================= 查询相关 ======================= + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmBusinessDO getBusiness(Long id) { + return businessMapper.selectById(id); + } + + @Override + public CrmBusinessDO validateBusiness(Long id) { + return validateBusinessExists(id); + } + + @Override + public List getBusinessList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return businessMapper.selectBatchIds(ids); + } + + @Override + public List getBusinessProductListByBusinessId(Long businessId) { + return businessProductMapper.selectListByBusinessId(businessId); + } + + @Override + public PageResult getBusinessPage(CrmBusinessPageReqVO pageReqVO, Long userId) { + return businessMapper.selectPage(pageReqVO, userId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ) + public PageResult getBusinessPageByCustomerId(CrmBusinessPageReqVO pageReqVO) { + return businessMapper.selectPageByCustomerId(pageReqVO); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#pageReqVO.contactId", level = CrmPermissionLevelEnum.READ) + public PageResult getBusinessPageByContact(CrmBusinessPageReqVO pageReqVO) { + // 1. 查询关联的商机编号 + List contactBusinessList = contactBusinessService.getContactBusinessListByContactId( + pageReqVO.getContactId()); + if (CollUtil.isEmpty(contactBusinessList)) { + return PageResult.empty(); + } + // 2. 查询商机分页 + return businessMapper.selectPageByContactId(pageReqVO, + convertSet(contactBusinessList, CrmContactBusinessDO::getBusinessId)); + } + + @Override + public Long getBusinessCountByCustomerId(Long customerId) { + return businessMapper.selectCount(CrmBusinessDO::getCustomerId, customerId); + } + + @Override + public Long getBusinessCountByStatusTypeId(Long statusTypeId) { + return businessMapper.selectCountByStatusTypeId(statusTypeId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusService.java new file mode 100644 index 0000000000..285e984d22 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusService.java @@ -0,0 +1,135 @@ +package cn.iocoder.yudao.module.crm.service.business; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.status.CrmBusinessStatusSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusTypeDO; + +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 商机状态 Service 接口 + * + * @author ljlleo + */ +public interface CrmBusinessStatusService { + + /** + * 创建商机状态 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createBusinessStatus(@Valid CrmBusinessStatusSaveReqVO createReqVO); + + /** + * 更新商机状态 + * + * @param updateReqVO 更新信息 + */ + void updateBusinessStatus(@Valid CrmBusinessStatusSaveReqVO updateReqVO); + + /** + * 删除商机状态 + * + * @param id 编号 + */ + void deleteBusinessStatusType(Long id); + + /** + * 获得商机状态组 + * + * @param id 编号 + * @return 商机状态组 + */ + CrmBusinessStatusTypeDO getBusinessStatusType(Long id); + + /** + * 校验商机状态组 + * + * @param id 编号 + */ + void validateBusinessStatusType(Long id); + + /** + * 获得商机状态组列表 + * + * @return 商机状态组列表 + */ + List getBusinessStatusTypeList(); + + /** + * 获得商机状态组分页 + * + * @param pageReqVO 分页查询 + * @return 商机状态组分页 + */ + PageResult getBusinessStatusTypePage(PageParam pageReqVO); + + /** + * 获得商机状态组列表 + * + * @param ids 编号数组 + * @return 商机状态组列表 + */ + List getBusinessStatusTypeList(Collection ids); + + /** + * 获得商机状态组 Map + * + * @param ids 编号数组 + * @return 商机状态组 Map + */ + default Map getBusinessStatusTypeMap(Collection ids) { + return convertMap(getBusinessStatusTypeList(ids), CrmBusinessStatusTypeDO::getId); + } + + /** + * 获得指定类型的商机状态列表 + * + * @param typeId 商机状态组编号 + * @return 商机状态列表 + */ + List getBusinessStatusListByTypeId(Long typeId); + + /** + * 获得商机状态列表 + * + * @param ids 编号数组 + * @return 商机状态列表 + */ + List getBusinessStatusList(Collection ids); + + /** + * 获得商机状态 Map + * + * @param ids 编号数组 + * @return 商机状态 Map + */ + default Map getBusinessStatusMap(Collection ids) { + return convertMap(getBusinessStatusList(ids), CrmBusinessStatusDO::getId); + } + + /** + * 获得商机状态 + * + * @param id 编号 + * @return 商机状态 + */ + CrmBusinessStatusDO getBusinessStatus(Long id); + + /** + * 校验商机状态 + * + * @param statusTypeId 商机状态组编号 + * @param statusId 商机状态编号 + */ + CrmBusinessStatusDO validateBusinessStatus(Long statusTypeId, Long statusId); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusServiceImpl.java new file mode 100644 index 0000000000..3c2e0b6646 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/business/CrmBusinessStatusServiceImpl.java @@ -0,0 +1,195 @@ +package cn.iocoder.yudao.module.crm.service.business; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.business.vo.status.CrmBusinessStatusSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessStatusTypeDO; +import cn.iocoder.yudao.module.crm.dal.mysql.business.CrmBusinessStatusMapper; +import cn.iocoder.yudao.module.crm.dal.mysql.business.CrmBusinessStatusTypeMapper; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; + +/** + * 商机状态 Service 实现类 + * + * @author ljlleo + */ +@Service +@Validated +public class CrmBusinessStatusServiceImpl implements CrmBusinessStatusService { + + @Resource + private CrmBusinessStatusTypeMapper businessStatusTypeMapper; + @Resource + private CrmBusinessStatusMapper businessStatusMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private CrmBusinessService businessService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createBusinessStatus(CrmBusinessStatusSaveReqVO createReqVO) { + // 1.1 检验名称是否存在 + validateBusinessStatusTypeNameUnique(createReqVO.getName(), null); + // 1.2 设置状态的排序 + int sort = 0; + for (CrmBusinessStatusSaveReqVO.Status status : createReqVO.getStatuses()) { + status.setSort(sort++); + } + + // 2.1 插入类型 + CrmBusinessStatusTypeDO statusType = BeanUtils.toBean(createReqVO, CrmBusinessStatusTypeDO.class); + businessStatusTypeMapper.insert(statusType); + // 2.2 插入状态 + List statuses = BeanUtils.toBean(createReqVO.getStatuses(), CrmBusinessStatusDO.class, + status -> status.setTypeId(statusType.getId())); + businessStatusMapper.insertBatch(statuses); + return statusType.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateBusinessStatus(CrmBusinessStatusSaveReqVO updateReqVO) { + // 1.1 校验存在 + validateBusinessStatusTypeExists(updateReqVO.getId()); + // 1.2 校验名称是否存在 + validateBusinessStatusTypeNameUnique(updateReqVO.getName(), updateReqVO.getId()); + // 1.3 设置状态的排序 + int sort = 0; + for (CrmBusinessStatusSaveReqVO.Status status : updateReqVO.getStatuses()) { + status.setSort(sort++); + } + // 1.4 已经使用,无法更新 + if (businessService.getBusinessCountByStatusTypeId(updateReqVO.getId()) > 0) { + throw exception(BUSINESS_STATUS_UPDATE_FAIL_USED); + } + + // 2.1 更新类型 + CrmBusinessStatusTypeDO updateObj = BeanUtils.toBean(updateReqVO, CrmBusinessStatusTypeDO.class); + businessStatusTypeMapper.updateById(updateObj); + // 2.2 更新状态 + updateBusinessStatus(updateReqVO.getId(), BeanUtils.toBean(updateReqVO.getStatuses(), CrmBusinessStatusDO.class)); + } + + private void updateBusinessStatus(Long id, List newList) { + List oldList = businessStatusMapper.selectListByTypeId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setTypeId(id)); + businessStatusMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + businessStatusMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + businessStatusMapper.deleteBatchIds(convertSet(diffList.get(2), CrmBusinessStatusDO::getId)); + } + } + + private void validateBusinessStatusTypeExists(Long id) { + if (businessStatusTypeMapper.selectById(id) == null) { + throw exception(BUSINESS_STATUS_TYPE_NOT_EXISTS); + } + } + + private void validateBusinessStatusTypeNameUnique(String name, Long id) { + CrmBusinessStatusTypeDO statusType = businessStatusTypeMapper.selectByName(name); + if (statusType == null + || statusType.getId().equals(id)) { + return; + } + throw exception(BUSINESS_STATUS_TYPE_NAME_EXISTS); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBusinessStatusType(Long id) { + // 1.1 校验存在 + validateBusinessStatusTypeExists(id); + // 1.2 已经使用,无法更新 + if (businessService.getBusinessCountByStatusTypeId(id) > 0) { + throw exception(BUSINESS_STATUS_DELETE_FAIL_USED); + } + + // 2.1 删除类型 + businessStatusTypeMapper.deleteById(id); + // 2.2 删除状态 + businessStatusMapper.deleteByTypeId(id); + } + + @Override + public CrmBusinessStatusTypeDO getBusinessStatusType(Long id) { + return businessStatusTypeMapper.selectById(id); + } + + @Override + public void validateBusinessStatusType(Long id) { + validateBusinessStatusTypeExists(id); + } + + @Override + public List getBusinessStatusTypeList() { + return businessStatusTypeMapper.selectList(); + } + + @Override + public PageResult getBusinessStatusTypePage(PageParam pageReqVO) { + return businessStatusTypeMapper.selectPage(pageReqVO); + } + + @Override + public List getBusinessStatusTypeList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return businessStatusTypeMapper.selectBatchIds(ids); + } + + @Override + public List getBusinessStatusListByTypeId(Long typeId) { + List list = businessStatusMapper.selectListByTypeId(typeId); + list.sort(Comparator.comparingInt(CrmBusinessStatusDO::getSort)); + return list; + } + + @Override + public List getBusinessStatusList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return businessStatusMapper.selectBatchIds(ids); + } + + @Override + public CrmBusinessStatusDO getBusinessStatus(Long id) { + return businessStatusMapper.selectById(id); + } + + @Override + public CrmBusinessStatusDO validateBusinessStatus(Long statusTypeId, Long statusId) { + CrmBusinessStatusDO status = businessStatusMapper.selectByTypeIdAndId(statusTypeId, statusId); + if (status == null) { + throw exception(BUSINESS_STATUS_NOT_EXISTS); + } + return status; + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueService.java new file mode 100644 index 0000000000..b325bfd257 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueService.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.crm.service.clue; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmCluePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.clue.CrmClueDO; + +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 线索 Service 接口 + * + * @author Wanwan + */ +public interface CrmClueService { + + /** + * 创建线索 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createClue(@Valid CrmClueSaveReqVO createReqVO); + + /** + * 更新线索 + * + * @param updateReqVO 更新信息 + */ + void updateClue(@Valid CrmClueSaveReqVO updateReqVO); + + /** + * 更新线索相关的跟进信息 + * + * @param id 编号 + * @param contactNextTime 下次联系时间 + * @param contactLastContent 最后联系内容 + */ + void updateClueFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent); + + /** + * 删除线索 + * + * @param id 编号 + */ + void deleteClue(Long id); + + /** + * 获得线索 + * + * @param id 编号 + * @return 线索 + */ + CrmClueDO getClue(Long id); + + /** + * 获得线索列表 + * + * @param ids 编号 + * @return 线索列表 + */ + List getClueList(Collection ids, Long userId); + + /** + * 获得线索分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 线索分页 + */ + PageResult getCluePage(CrmCluePageReqVO pageReqVO, Long userId); + + /** + * 线索转移 + * + * @param reqVO 请求 + * @param userId 用户编号 + */ + void transferClue(CrmClueTransferReqVO reqVO, Long userId); + + /** + * 线索转化为客户 + * + * @param id 线索编号 + * @param userId 用户编号 + */ + void transformClue(Long id, Long userId); + + /** + * 获得分配给我的、待跟进的线索数量 + * + * @param userId 用户编号 + * @return 数量 + */ + Long getFollowClueCount(Long userId); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueServiceImpl.java new file mode 100644 index 0000000000..304dcc2078 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueServiceImpl.java @@ -0,0 +1,241 @@ +package cn.iocoder.yudao.module.crm.service.clue; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmCluePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.clue.vo.CrmClueTransferReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.CrmCustomerSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.clue.CrmClueDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.followup.CrmFollowUpRecordDO; +import cn.iocoder.yudao.module.crm.dal.mysql.clue.CrmClueMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.customer.bo.CrmCustomerCreateReqBO; +import cn.iocoder.yudao.module.crm.service.followup.CrmFollowUpRecordService; +import cn.iocoder.yudao.module.crm.service.followup.bo.CrmFollowUpCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.singleton; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CLUE_NOT_EXISTS; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CLUE_TRANSFORM_FAIL_ALREADY; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.USER_NOT_EXISTS; + +/** + * 线索 Service 实现类 + * + * @author Wanwan + */ +@Service +@Validated +public class CrmClueServiceImpl implements CrmClueService { + + @Resource + private CrmClueMapper clueMapper; + + @Resource + private CrmCustomerService customerService; + @Resource + private CrmPermissionService crmPermissionService; + @Resource + private CrmFollowUpRecordService followUpRecordService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_CREATE_SUB_TYPE, bizNo = "{{#clue.id}}", + success = CRM_CLUE_CREATE_SUCCESS) + public Long createClue(CrmClueSaveReqVO createReqVO) { + // 1.1 校验关联数据 + validateRelationDataExists(createReqVO); + // 1.2 校验负责人是否存在 + adminUserApi.validateUser(createReqVO.getOwnerUserId()); + + // 2. 插入线索 + CrmClueDO clue = BeanUtils.toBean(createReqVO, CrmClueDO.class); + clueMapper.insert(clue); + + // 3. 创建数据权限 + CrmPermissionCreateReqBO createReqBO = new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_CLUE.getType()) + .setBizId(clue.getId()).setUserId(clue.getOwnerUserId()).setLevel(CrmPermissionLevelEnum.OWNER.getLevel()); + crmPermissionService.createPermission(createReqBO); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("clue", clue); + return clue.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_CLUE_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#updateReq.id", level = CrmPermissionLevelEnum.OWNER) + public void updateClue(CrmClueSaveReqVO updateReq) { + Assert.notNull(updateReq.getId(), "线索编号不能为空"); + // 1.1 校验线索是否存在 + CrmClueDO oldClue = validateClueExists(updateReq.getId()); + // 1.2 校验关联数据 + validateRelationDataExists(updateReq); + + // 2. 更新线索 + CrmClueDO updateObj = BeanUtils.toBean(updateReq, CrmClueDO.class); + clueMapper.updateById(updateObj); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldClue, CrmCustomerSaveReqVO.class)); + LogRecordContext.putVariable("clueName", oldClue.getName()); + } + + private void validateRelationDataExists(CrmClueSaveReqVO reqVO) { + // 校验负责人 + if (Objects.nonNull(reqVO.getOwnerUserId()) && + Objects.isNull(adminUserApi.getUser(reqVO.getOwnerUserId()))) { + throw exception(USER_NOT_EXISTS); + } + } + + @Override + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_FOLLOW_UP_SUB_TYPE, bizNo = "{{#id}", + success = CRM_CLUE_FOLLOW_UP_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateClueFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent) { + // 校验线索是否存在 + CrmClueDO oldClue = validateClueExists(id); + + // 更新线索 + clueMapper.updateById(new CrmClueDO().setId(id).setFollowUpStatus(true).setContactNextTime(contactNextTime) + .setContactLastTime(LocalDateTime.now()).setContactLastContent(contactLastContent)); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("clueName", oldClue.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CLUE_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteClue(Long id) { + // 1. 校验存在 + CrmClueDO clue = validateClueExists(id); + + // 2. 删除 + clueMapper.deleteById(id); + + // 3. 删除数据权限 + crmPermissionService.deletePermission(CrmBizTypeEnum.CRM_CLUE.getType(), id); + + // 4. 删除跟进 + followUpRecordService.deleteFollowUpRecordByBiz(CrmBizTypeEnum.CRM_CLUE.getType(), id); + + // 5. 记录操作日志上下文 + LogRecordContext.putVariable("clueName", clue.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_TRANSFER_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_CLUE_TRANSFER_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void transferClue(CrmClueTransferReqVO reqVO, Long userId) { + // 1 校验线索是否存在 + CrmClueDO clue = validateClueExists(reqVO.getId()); + + // 2.1 数据权限转移 + crmPermissionService.transferPermission(new CrmPermissionTransferReqBO(userId, CrmBizTypeEnum.CRM_CLUE.getType(), + reqVO.getId(), reqVO.getNewOwnerUserId(), reqVO.getOldOwnerPermissionLevel())); + // 2.2 设置新的负责人 + clueMapper.updateById(new CrmClueDO().setId(reqVO.getId()).setOwnerUserId(reqVO.getNewOwnerUserId())); + + // 3. 记录转移日志 + LogRecordContext.putVariable("clue", clue); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CLUE_TYPE, subType = CRM_CLUE_TRANSLATE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CLUE_TRANSLATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void transformClue(Long id, Long userId) { + // 1.1 校验线索都存在 + CrmClueDO clue = validateClueExists(id); + // 1.2 存在已经转化的 + if (clue.getTransformStatus()) { + throw exception(CLUE_TRANSFORM_FAIL_ALREADY); + } + + // 2.1 遍历线索(未转化的线索),创建对应的客户 + Long customerId = customerService.createCustomer(BeanUtils.toBean(clue, CrmCustomerCreateReqBO.class), userId); + // 2.2 更新线索 + clueMapper.updateById(new CrmClueDO().setId(id).setTransformStatus(Boolean.TRUE).setCustomerId(customerId)); + // 2.3 复制跟进记录 + List followUpRecords = followUpRecordService.getFollowUpRecordByBiz( + CrmBizTypeEnum.CRM_CLUE.getType(), singleton(clue.getId())); + if (CollUtil.isNotEmpty(followUpRecords)) { + followUpRecordService.createFollowUpRecordBatch(convertList(followUpRecords, record -> + BeanUtils.toBean(record, CrmFollowUpCreateReqBO.class) + .setBizType(CrmBizTypeEnum.CRM_CUSTOMER.getType()).setBizId(customerId))); + } + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("clueName", clue.getName()); + } + + private CrmClueDO validateClueExists(Long id) { + CrmClueDO crmClueDO = clueMapper.selectById(id); + if (crmClueDO == null) { + throw exception(CLUE_NOT_EXISTS); + } + return crmClueDO; + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CLUE, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmClueDO getClue(Long id) { + return clueMapper.selectById(id); + } + + @Override + public List getClueList(Collection ids, Long userId) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return clueMapper.selectBatchIds(ids, userId); + } + + @Override + public PageResult getCluePage(CrmCluePageReqVO pageReqVO, Long userId) { + return clueMapper.selectPage(pageReqVO, userId); + } + + @Override + public Long getFollowClueCount(Long userId) { + return clueMapper.selectCountByFollow(userId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessService.java new file mode 100644 index 0000000000..e3e94cab75 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessService.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.crm.service.contact; + +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusiness2ReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusinessReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactBusinessDO; + +import javax.validation.Valid; +import java.util.List; + +/** + * CRM 联系人与商机的关联 Service 接口 + * + * @author 芋道源码 + */ +public interface CrmContactBusinessService { + + /** + * 创建联系人与商机的关联【通过联系人,关联商机】 + * + * @param createReqVO 创建信息 + */ + void createContactBusinessList(@Valid CrmContactBusinessReqVO createReqVO); + + /** + * 创建联系人与商机的关联【通过商机,关联联系人】 + * + * @param createReqVO 创建信息 + */ + void createContactBusinessList2(@Valid CrmContactBusiness2ReqVO createReqVO); + + /** + * 删除联系人与商机的关联【通过联系人,取关商机】 + * + * @param deleteReqVO 删除信息 + */ + void deleteContactBusinessList(@Valid CrmContactBusinessReqVO deleteReqVO); + + /** + * 删除联系人与商机的关联【通过商机,取关联系人】 + * + * @param deleteReqVO 删除信息 + */ + void deleteContactBusinessList2(@Valid CrmContactBusiness2ReqVO deleteReqVO); + + /** + * 删除联系人与商机的关联,基于联系人编号 + * + * @param contactId 联系人编号 + */ + void deleteContactBusinessByContactId(Long contactId); + + /** + * 获得联系人与商机的关联列表,基于联系人编号 + * + * @param contactId 联系人编号 + * @return 联系人商机关联 + */ + List getContactBusinessListByContactId(Long contactId); + + /** + * 获得联系人与商机的关联列表,基于商机编号 + * + * @param businessId 商机编号 + * @return 联系人商机关联 + */ + List getContactBusinessListByBusinessId(Long businessId); + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessServiceImpl.java new file mode 100644 index 0000000000..c8249460bb --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactBusinessServiceImpl.java @@ -0,0 +1,139 @@ +package cn.iocoder.yudao.module.crm.service.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusiness2ReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusinessReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.mysql.contact.CrmContactBusinessMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.BUSINESS_NOT_EXISTS; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.CONTACT_NOT_EXISTS; + +/** + * 联系人与商机的关联 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class CrmContactBusinessServiceImpl implements CrmContactBusinessService { + + @Resource + private CrmContactBusinessMapper contactBusinessMapper; + + @Resource + @Lazy // 延迟加载,为了解决延迟加载 + private CrmBusinessService businessService; + @Resource + @Lazy // 延迟加载,为了解决延迟加载 + private CrmContactService contactService; + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#createReqVO.contactId", level = CrmPermissionLevelEnum.WRITE) + public void createContactBusinessList(CrmContactBusinessReqVO createReqVO) { + CrmContactDO contact = contactService.getContact(createReqVO.getContactId()); + if (contact == null) { + throw exception(CONTACT_NOT_EXISTS); + } + // 遍历处理,考虑到一般数量不会太多,代码处理简单 + List saveDOList = new ArrayList<>(); + createReqVO.getBusinessIds().forEach(businessId -> { + CrmBusinessDO business = businessService.getBusiness(businessId); + if (business == null) { + throw exception(BUSINESS_NOT_EXISTS); + } + // 关联判重 + if (contactBusinessMapper.selectByContactIdAndBusinessId(createReqVO.getContactId(), businessId) != null) { + return; + } + saveDOList.add(new CrmContactBusinessDO(null, createReqVO.getContactId(), businessId)); + }); + // 批量插入 + if (CollUtil.isNotEmpty(saveDOList)) { + contactBusinessMapper.insertBatch(saveDOList); + } + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#createReqVO.businessId", level = CrmPermissionLevelEnum.WRITE) + public void createContactBusinessList2(CrmContactBusiness2ReqVO createReqVO) { + CrmBusinessDO business = businessService.getBusiness(createReqVO.getBusinessId()); + if (business == null) { + throw exception(BUSINESS_NOT_EXISTS); + } + // 遍历处理,考虑到一般数量不会太多,代码处理简单 + List saveDOList = new ArrayList<>(); + createReqVO.getContactIds().forEach(contactId -> { + CrmContactDO contact = contactService.getContact(contactId); + if (contact == null) { + throw exception(CONTACT_NOT_EXISTS); + } + // 关联判重 + if (contactBusinessMapper.selectByContactIdAndBusinessId(contactId, createReqVO.getBusinessId()) != null) { + return; + } + saveDOList.add(new CrmContactBusinessDO(null, contactId, createReqVO.getBusinessId())); + }); + // 批量插入 + if (CollUtil.isNotEmpty(saveDOList)) { + contactBusinessMapper.insertBatch(saveDOList); + } + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#deleteReqVO.contactId", level = CrmPermissionLevelEnum.WRITE) + public void deleteContactBusinessList(CrmContactBusinessReqVO deleteReqVO) { + CrmContactDO contact = contactService.getContact(deleteReqVO.getContactId()); + if (contact == null) { + throw exception(CONTACT_NOT_EXISTS); + } + // 直接删除 + contactBusinessMapper.deleteByContactIdAndBusinessId( + deleteReqVO.getContactId(), deleteReqVO.getBusinessIds()); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#deleteReqVO.businessId", level = CrmPermissionLevelEnum.WRITE) + public void deleteContactBusinessList2(CrmContactBusiness2ReqVO deleteReqVO) { + CrmBusinessDO business = businessService.getBusiness(deleteReqVO.getBusinessId()); + if (business == null) { + throw exception(BUSINESS_NOT_EXISTS); + } + // 直接删除 + contactBusinessMapper.deleteByBusinessIdAndContactId( + deleteReqVO.getBusinessId(), deleteReqVO.getContactIds()); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#contactId", level = CrmPermissionLevelEnum.WRITE) + public void deleteContactBusinessByContactId(Long contactId) { + contactBusinessMapper.delete(CrmContactBusinessDO::getContactId, contactId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#contactId", level = CrmPermissionLevelEnum.READ) + public List getContactBusinessListByContactId(Long contactId) { + return contactBusinessMapper.selectListByContactId(contactId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#businessId", level = CrmPermissionLevelEnum.READ) + public List getContactBusinessListByBusinessId(Long businessId) { + return contactBusinessMapper.selectListByBusinessId(businessId); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactService.java new file mode 100644 index 0000000000..72ddd37bd9 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactService.java @@ -0,0 +1,163 @@ +package cn.iocoder.yudao.module.crm.service.contact; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; + +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * CRM 联系人 Service 接口 + * + * @author 芋道源码 + */ +public interface CrmContactService { + + /** + * 创建联系人 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 编号 + */ + Long createContact(@Valid CrmContactSaveReqVO createReqVO, Long userId); + + /** + * 更新联系人 + * + * @param updateReqVO 更新信息 + */ + void updateContact(@Valid CrmContactSaveReqVO updateReqVO); + + /** + * 删除联系人 + * + * @param id 编号 + */ + void deleteContact(Long id); + + /** + * 联系人转移 + * + * @param reqVO 请求 + * @param userId 用户编号 + */ + void transferContact(CrmContactTransferReqVO reqVO, Long userId); + + /** + * 更新指定客户的联系人的负责人 + * 数据权限基于 【客户】 + * + * @param customerId 客户编号 + * @param ownerUserId 用户编号 + */ + void updateOwnerUserIdByCustomerId(Long customerId, Long ownerUserId); + + /** + * 更新联系人相关跟进信息 + * + * @param id 编号 + * @param contactNextTime 下次联系时间 + * @param contactLastContent 最后联系内容 + */ + void updateContactFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent); + + /** + * 更新联系人的下次联系时间 + * + * @param ids 编号数组 + * @param contactNextTime 下次联系时间 + */ + void updateContactContactNextTime(Collection ids, LocalDateTime contactNextTime); + + /** + * 获得联系人 + * + * @param id 编号 + * @return 联系人 + */ + CrmContactDO getContact(Long id); + + /** + * 校验联系人 + * + * @param id 编号 + */ + void validateContact(Long id); + + /** + * 获得联系人列表 + * + * @param ids 编号 + * @return 联系人列表 + */ + List getContactList(Collection ids); + + /** + * 获得联系人 Map + * + * @param ids 编号 + * @return 联系人 Map + */ + default Map getContactMap(Collection ids) { + return convertMap(getContactList(ids), CrmContactDO::getId); + } + + /** + * 获取联系人列表(校验权限) + * + * @param userId 用户编号 + * @return 联系人列表 + */ + List getContactList(Long userId); + + /** + * 获得联系人分页 + * + * 数据权限:基于 {@link CrmContactDO} + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 联系人分页 + */ + PageResult getContactPage(CrmContactPageReqVO pageReqVO, Long userId); + + /** + * 获得联系人分页 + * + * 数据权限:基于 {@link CrmCustomerDO} + * + * @param pageVO 分页查询 + * @return 联系人分页 + */ + PageResult getContactPageByCustomerId(CrmContactPageReqVO pageVO); + + /** + * 获得联系人分页 + * + * 数据权限:基于 {@link CrmBusinessDO} + * + * @param pageVO 分页查询 + * @return 联系人分页 + */ + PageResult getContactPageByBusinessId(CrmContactPageReqVO pageVO); + + /** + * 获取关联客户的联系人数量 + * + * @param customerId 客户编号 + * @return 数量 + */ + Long getContactCountByCustomerId(Long customerId); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactServiceImpl.java new file mode 100644 index 0000000000..fb5a23ac53 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contact/CrmContactServiceImpl.java @@ -0,0 +1,301 @@ +package cn.iocoder.yudao.module.crm.service.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactBusinessReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contact.vo.CrmContactTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contact.CrmContactDO; +import cn.iocoder.yudao.module.crm.dal.mysql.contact.CrmContactMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; +import static java.util.Collections.singletonList; + +/** + * CRM 联系人 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class CrmContactServiceImpl implements CrmContactService { + + @Resource + private CrmContactMapper contactMapper; + + @Resource + private CrmCustomerService customerService; + @Resource + private CrmPermissionService permissionService; + @Resource + @Lazy + private CrmContractService contractService; + @Resource + private CrmContactBusinessService contactBusinessService; + @Resource + private CrmBusinessService businessService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_CREATE_SUB_TYPE, bizNo = "{{#contact.id}}", + success = CRM_CONTACT_CREATE_SUCCESS) + public Long createContact(CrmContactSaveReqVO createReqVO, Long userId) { + createReqVO.setId(null); + // 1. 校验关联数据 + validateRelationDataExists(createReqVO); + + // 2. 插入联系人 + CrmContactDO contact = BeanUtils.toBean(createReqVO, CrmContactDO.class); + contactMapper.insert(contact); + + // 3. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(userId) + .setBizType(CrmBizTypeEnum.CRM_CONTACT.getType()).setBizId(contact.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + + // 4. 如果有关联商机,则需要创建关联 + if (createReqVO.getBusinessId() != null) { + contactBusinessService.createContactBusinessList(new CrmContactBusinessReqVO() + .setContactId(contact.getId()).setBusinessIds(singletonList(createReqVO.getBusinessId()))); + } + + // 5. 记录操作日志 + LogRecordContext.putVariable("contact", contact); + return contact.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_CONTACT_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateContact(CrmContactSaveReqVO updateReqVO) { + // 1.1 校验存在 + CrmContactDO oldContact = validateContactExists(updateReqVO.getId()); + // 1.2 校验关联数据 + validateRelationDataExists(updateReqVO); + + // 2. 更新联系人 + CrmContactDO updateObj = BeanUtils.toBean(updateReqVO, CrmContactDO.class); + contactMapper.updateById(updateObj); + + // 3. 记录操作日志 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldContact, CrmContactSaveReqVO.class)); + LogRecordContext.putVariable("contactName", oldContact.getName()); + } + + /** + * 校验关联的数据都存在 + * + * @param saveReqVO 新增/修改请求 VO + */ + private void validateRelationDataExists(CrmContactSaveReqVO saveReqVO) { + // 1. 校验客户 + if (saveReqVO.getCustomerId() != null && customerService.getCustomer(saveReqVO.getCustomerId()) == null) { + customerService.validateCustomer(saveReqVO.getCustomerId()); + } + // 2. 校验负责人 + if (saveReqVO.getOwnerUserId() != null) { + adminUserApi.validateUser(saveReqVO.getOwnerUserId()); + } + // 3. 直属上级 + if (saveReqVO.getParentId() != null) { + validateContactExists(saveReqVO.getParentId()); + } + // 4. 如果有关联商机,则需要校验存在 + if (saveReqVO.getBusinessId() != null && businessService.getBusiness(saveReqVO.getBusinessId()) == null) { + throw exception(BUSINESS_NOT_EXISTS); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CONTACT_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteContact(Long id) { + // 1.1 校验存在 + CrmContactDO contact = validateContactExists(id); + // 1.2 校验是否关联合同 + if (contractService.getContractCountByContactId(id) > 0) { + throw exception(CONTACT_DELETE_FAIL_CONTRACT_LINK_EXISTS); + } + + // 2. 删除联系人 + contactMapper.deleteById(id); + + // 4.1 删除数据权限 + permissionService.deletePermission(CrmBizTypeEnum.CRM_CONTACT.getType(), id); + // 4.2 删除商机关联 + contactBusinessService.deleteContactBusinessByContactId(id); + + // 记录操作日志上下文 + LogRecordContext.putVariable("contactName", contact.getName()); + } + + private CrmContactDO validateContactExists(Long id) { + CrmContactDO contact = contactMapper.selectById(id); + if (contact == null) { + throw exception(CONTACT_NOT_EXISTS); + } + return contact; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_TRANSFER_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_CONTACT_TRANSFER_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void transferContact(CrmContactTransferReqVO reqVO, Long userId) { + // 1 校验联系人是否存在 + CrmContactDO contact = validateContactExists(reqVO.getId()); + + // 2.1 数据权限转移 + permissionService.transferPermission(new CrmPermissionTransferReqBO(userId, CrmBizTypeEnum.CRM_CONTACT.getType(), + reqVO.getId(), reqVO.getNewOwnerUserId(), reqVO.getOldOwnerPermissionLevel())); + // 2.2 设置新的负责人 + contactMapper.updateById(new CrmContactDO().setId(reqVO.getId()).setOwnerUserId(reqVO.getNewOwnerUserId())); + + // 3. 记录转移日志 + LogRecordContext.putVariable("contact", contact); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#customerId", level = CrmPermissionLevelEnum.OWNER) + public void updateOwnerUserIdByCustomerId(Long customerId, Long ownerUserId) { + // 1. 校验存在 + List contacts = contactMapper.selectListByCustomerId(customerId); + if (CollUtil.isEmpty(contacts)) { + return; + } + int count = contactMapper.updateOwnerUserIdByCustomerId(customerId, ownerUserId); + if (count == 0) { + throw exception(CONTACT_UPDATE_OWNER_USER_FAIL); + } + + // 2. 记录操作日志 + for (CrmContactDO contact : contacts) { + receiveContactLog(contact, ownerUserId); + } + } + + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_UPDATE_OWNER_USER_SUB_TYPE, bizNo = "{{#contact.id}", + success = CRM_CONTACT_UPDATE_OWNER_USER_SUCCESS) + public void receiveContactLog(CrmContactDO contact, Long ownerUserId) { + // 记录操作日志上下文 + LogRecordContext.putVariable("contact", contact); + LogRecordContext.putVariable("ownerUserId", ownerUserId); + } + + @Override + @LogRecord(type = CRM_CONTACT_TYPE, subType = CRM_CONTACT_FOLLOW_UP_SUB_TYPE, bizNo = "{{#id}", + success = CRM_CONTACT_FOLLOW_UP_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateContactFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent) { + // 1. 校验存在 + CrmContactDO contact = validateContactExists(id); + + // 2. 更新联系人的跟进信息 + contactMapper.updateById(new CrmContactDO().setId(id).setContactNextTime(contactNextTime) + .setContactLastTime(LocalDateTime.now()).setContactLastContent(contactLastContent)); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("contactName", contact.getName()); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#ids", level = CrmPermissionLevelEnum.WRITE) + public void updateContactContactNextTime(Collection ids, LocalDateTime contactNextTime) { + contactMapper.updateBatch(convertList(ids, id -> new CrmContactDO().setId(id).setContactNextTime(contactNextTime))); + } + + //======================= 查询相关 ======================= + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTACT, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmContactDO getContact(Long id) { + return contactMapper.selectById(id); + } + + @Override + public void validateContact(Long id) { + validateContactExists(id); + } + + @Override + public List getContactList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return contactMapper.selectBatchIds(ids); + } + + @Override + public List getContactList(Long userId) { + CrmContactPageReqVO reqVO = new CrmContactPageReqVO(); + reqVO.setPageSize(PAGE_SIZE_NONE); // 不分页 + return contactMapper.selectPage(reqVO, userId).getList(); + } + + @Override + public PageResult getContactPage(CrmContactPageReqVO pageReqVO, Long userId) { + return contactMapper.selectPage(pageReqVO, userId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageVO.customerId", level = CrmPermissionLevelEnum.READ) + public PageResult getContactPageByCustomerId(CrmContactPageReqVO pageVO) { + return contactMapper.selectPageByCustomerId(pageVO); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#pageVO.businessId", level = CrmPermissionLevelEnum.READ) + public PageResult getContactPageByBusinessId(CrmContactPageReqVO pageVO) { + List contactBusinessList = contactBusinessService.getContactBusinessListByBusinessId(pageVO.getBusinessId()); + if (CollUtil.isEmpty(contactBusinessList)) { + return PageResult.empty(); + } + return contactMapper.selectPageByBusinessId(pageVO, convertSet(contactBusinessList, CrmContactBusinessDO::getContactId)); + } + + @Override + public Long getContactCountByCustomerId(Long customerId) { + return contactMapper.selectCount(CrmContactDO::getCustomerId, customerId); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigService.java new file mode 100644 index 0000000000..39a06649a1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigService.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.crm.service.contract; + +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config.CrmContractConfigSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; + +import javax.validation.Valid; + +/** + * 合同配置 Service 接口 + * + * @author 芋道源码 + */ +public interface CrmContractConfigService { + + /** + * 获得合同配置 + * + * @return 合同配置 + */ + CrmContractConfigDO getContractConfig(); + + /** + * 保存合同配置 + * + * @param saveReqVO 更新信息 + */ + void saveContractConfig(@Valid CrmContractConfigSaveReqVO saveReqVO); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigServiceImpl.java new file mode 100644 index 0000000000..2580cc30c8 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractConfigServiceImpl.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.crm.service.contract; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.config.CrmContractConfigSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; +import cn.iocoder.yudao.module.crm.dal.mysql.contract.CrmContractConfigMapper; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Objects; + +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + +/** + * 合同配置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class CrmContractConfigServiceImpl implements CrmContractConfigService { + + @Resource + private CrmContractConfigMapper contractConfigMapper; + + @Override + public CrmContractConfigDO getContractConfig() { + return contractConfigMapper.selectOne(); + } + + @Override + @LogRecord(type = CRM_CONTRACT_CONFIG_TYPE, subType = CRM_CONTRACT_CONFIG_SUB_TYPE, bizNo = "{{#configId}}", + success = CRM_CONTRACT_CONFIG_SUCCESS) + public void saveContractConfig(CrmContractConfigSaveReqVO saveReqVO) { + // 1. 存在,则进行更新 + CrmContractConfigDO dbConfig = getContractConfig(); + CrmContractConfigDO config = BeanUtils.toBean(saveReqVO, CrmContractConfigDO.class); + if (Objects.nonNull(dbConfig)) { + contractConfigMapper.updateById(config.setId(dbConfig.getId())); + // 记录操作日志上下文 + LogRecordContext.putVariable("isConfigUpdate", Boolean.TRUE); + LogRecordContext.putVariable("configId", config.getId()); + return; + } + + // 2. 不存在,则进行插入 + contractConfigMapper.insert(config); + // 记录操作日志上下文 + LogRecordContext.putVariable("isConfigUpdate", Boolean.FALSE); + LogRecordContext.putVariable("configId", config.getId()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java new file mode 100644 index 0000000000..10be39142f --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractService.java @@ -0,0 +1,196 @@ +package cn.iocoder.yudao.module.crm.service.contract; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractProductDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; + +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * CRM 合同 Service 接口 + * + * @author dhb52 + */ +public interface CrmContractService { + + /** + * 创建合同 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 编号 + */ + Long createContract(@Valid CrmContractSaveReqVO createReqVO, Long userId); + + /** + * 更新合同 + * + * @param updateReqVO 更新信息 + */ + void updateContract(@Valid CrmContractSaveReqVO updateReqVO); + + /** + * 删除合同 + * + * @param id 编号 + */ + void deleteContract(Long id); + + /** + * 合同转移 + * + * @param reqVO 请求 + * @param userId 用户编号 + */ + void transferContract(CrmContractTransferReqVO reqVO, Long userId); + + /** + * 更新合同相关的更进信息 + * + * @param id 合同编号 + * @param contactNextTime 下次联系时间 + * @param contactLastContent 最后联系内容 + */ + void updateContractFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent); + + /** + * 发起合同审批流程 + * + * @param id 合同编号 + * @param userId 用户编号 + */ + void submitContract(Long id, Long userId); + + /** + * 更新合同流程审批结果 + * + * @param id 合同编号 + * @param bpmResult BPM 审批结果 + */ + void updateContractAuditStatus(Long id, Integer bpmResult); + + /** + * 获得合同 + * + * @param id 编号 + * @return 合同 + */ + CrmContractDO getContract(Long id); + + /** + * 校验合同是否合法 + * + * @param id 编号 + * @return 合同 + */ + CrmContractDO validateContract(Long id); + + /** + * 获得合同列表 + * + * @param ids 编号 + * @return 合同列表 + */ + List getContractList(Collection ids); + + /** + * 获得合同 Map + * + * @param ids 编号 + * @return 合同 Map + */ + default Map getContractMap(Collection ids) { + return convertMap(getContractList(ids), CrmContractDO::getId); + } + + /** + * 获得合同分页 + * + * 数据权限:基于 {@link CrmContractDO} 读取 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 合同分页 + */ + PageResult getContractPage(CrmContractPageReqVO pageReqVO, Long userId); + + /** + * 获得合同分页,基于指定客户 + * + * 数据权限:基于 {@link CrmCustomerDO} 读取 + * + * @param pageReqVO 分页查询 + * @return 合同分页 + */ + PageResult getContractPageByCustomerId(CrmContractPageReqVO pageReqVO); + + /** + * 获得合同分页,基于指定商机 + * + * 数据权限:基于 {@link CrmBusinessDO} 读取 + * + * @param pageReqVO 分页查询 + * @return 合同分页 + */ + PageResult getContractPageByBusinessId(CrmContractPageReqVO pageReqVO); + + /** + * 查询属于某个联系人的合同数量 + * + * @param contactId 联系人ID + * @return 合同 + */ + Long getContractCountByContactId(Long contactId); + + /** + * 获取关联客户的合同数量 + * + * @param customerId 客户编号 + * @return 数量 + */ + Long getContractCountByCustomerId(Long customerId); + + /** + * 根据商机编号,获取关联客户的合同数量 + * + * @param businessId 商机编号 + * @return 数量 + */ + Long getContractCountByBusinessId(Long businessId); + + /** + * 根据合同编号,获得合同的产品列表 + * + * @param contactId 合同编号 + * @return 产品列表 + */ + List getContractProductListByContractId(Long contactId); + + /** + * 获得待审核合同数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getAuditContractCount(Long userId); + + /** + * 获得即将到期(提醒)的合同数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getRemindContractCount(Long userId); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java new file mode 100644 index 0000000000..54304be5df --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java @@ -0,0 +1,399 @@ +package cn.iocoder.yudao.module.crm.service.contract; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractTransferReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractConfigDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractProductDO; +import cn.iocoder.yudao.module.crm.dal.mysql.contract.CrmContractMapper; +import cn.iocoder.yudao.module.crm.dal.mysql.contract.CrmContractProductMapper; +import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.crm.service.product.CrmProductService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; +import static cn.iocoder.yudao.module.crm.util.CrmAuditStatusUtils.convertBpmResultToAuditStatus; + +/** + * CRM 合同 Service 实现类 + * + * @author dhb52 + */ +@Service +@Validated +@Slf4j +public class CrmContractServiceImpl implements CrmContractService { + + /** + * BPM 合同审批流程标识 + */ + public static final String BPM_PROCESS_DEFINITION_KEY = "crm-contract-audit"; + + @Resource + private CrmContractMapper contractMapper; + @Resource + private CrmContractProductMapper contractProductMapper; + + @Resource + private CrmNoRedisDAO noRedisDAO; + + @Resource + private CrmPermissionService crmPermissionService; + @Resource + private CrmProductService productService; + @Resource + private CrmCustomerService customerService; + @Resource + private CrmBusinessService businessService; + @Resource + private CrmContactService contactService; + @Resource + private CrmContractConfigService contractConfigService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi bpmProcessInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_CREATE_SUB_TYPE, bizNo = "{{#contract.id}}", + success = CRM_CONTRACT_CREATE_SUCCESS) + public Long createContract(CrmContractSaveReqVO createReqVO, Long userId) { + // 1.1 校验产品项的有效性 + List contractProducts = validateContractProducts(createReqVO.getProducts()); + // 1.2 校验关联字段 + validateRelationDataExists(createReqVO); + // 1.3 生成序号 + String no = noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX); + if (contractMapper.selectByNo(no) != null) { + throw exception(CONTRACT_NO_EXISTS); + } + + // 2.1 插入合同 + CrmContractDO contract = BeanUtils.toBean(createReqVO, CrmContractDO.class).setNo(no); + calculateTotalPrice(contract, contractProducts); + contractMapper.insert(contract); + // 2.2 插入合同关联商品 + if (CollUtil.isNotEmpty(contractProducts)) { + contractProducts.forEach(item -> item.setContractId(contract.getId())); + contractProductMapper.insertBatch(contractProducts); + } + + // 3. 创建数据权限 + crmPermissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(contract.getOwnerUserId()) + .setBizType(CrmBizTypeEnum.CRM_CONTRACT.getType()).setBizId(contract.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("contract", contract); + return contract.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_CONTRACT_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTRACT, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateContract(CrmContractSaveReqVO updateReqVO) { + Assert.notNull(updateReqVO.getId(), "合同编号不能为空"); + updateReqVO.setOwnerUserId(null); // 不允许更新的字段 + // 1.1 校验存在 + CrmContractDO contract = validateContractExists(updateReqVO.getId()); + // 1.2 只有草稿、审批中,可以编辑; + if (!ObjectUtils.equalsAny(contract.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(), + CrmAuditStatusEnum.PROCESS.getStatus())) { + throw exception(CONTRACT_UPDATE_FAIL_NOT_DRAFT); + } + // 1.3 校验产品项的有效性 + List contractProducts = validateContractProducts(updateReqVO.getProducts()); + // 1.4 校验关联字段 + validateRelationDataExists(updateReqVO); + + // 2.1 更新合同 + CrmContractDO updateObj = BeanUtils.toBean(updateReqVO, CrmContractDO.class); + calculateTotalPrice(updateObj, contractProducts); + contractMapper.updateById(updateObj); + // 2.2 更新合同关联商品 + updateContractProduct(updateReqVO.getId(), contractProducts); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(contract, CrmContractSaveReqVO.class)); + LogRecordContext.putVariable("contractName", contract.getName()); + } + + private void updateContractProduct(Long id, List newList) { + List oldList = contractProductMapper.selectListByContractId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setContractId(id)); + contractProductMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + contractProductMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + contractProductMapper.deleteBatchIds(convertSet(diffList.get(2), CrmContractProductDO::getId)); + } + } + + /** + * 校验关联数据是否存在 + * + * @param reqVO 请求 + */ + private void validateRelationDataExists(CrmContractSaveReqVO reqVO) { + // 1. 校验客户 + if (reqVO.getCustomerId() != null) { + customerService.validateCustomer(reqVO.getCustomerId()); + } + // 2. 校验负责人 + if (reqVO.getOwnerUserId() != null) { + adminUserApi.validateUser(reqVO.getOwnerUserId()); + } + // 3. 如果有关联商机,则需要校验存在 + if (reqVO.getBusinessId() != null) { + businessService.validateBusiness(reqVO.getBusinessId()); + } + // 4. 校验签约相关字段 + if (reqVO.getSignContactId() != null) { + contactService.validateContact(reqVO.getSignContactId()); + } + if (reqVO.getSignUserId() != null) { + adminUserApi.validateUser(reqVO.getSignUserId()); + } + } + + private List validateContractProducts(List list) { + // 1. 校验产品存在 + productService.validProductList(convertSet(list, CrmContractSaveReqVO.Product::getProductId)); + // 2. 转化为 CrmContractProductDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, CrmContractProductDO.class, + item -> item.setTotalPrice(MoneyUtils.priceMultiply(item.getContractPrice(), item.getCount())))); + } + + private void calculateTotalPrice(CrmContractDO contract, List contractProducts) { + contract.setTotalProductPrice(getSumValue(contractProducts, CrmContractProductDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + BigDecimal discountPrice = MoneyUtils.priceMultiplyPercent(contract.getTotalProductPrice(), contract.getDiscountPercent()); + contract.setTotalPrice(contract.getTotalProductPrice().subtract(discountPrice)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CONTRACT_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTRACT, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteContract(Long id) { + // TODO @puhui999:如果被 CrmReceivableDO 所使用,则不允许删除 + // 校验存在 + CrmContractDO contract = validateContractExists(id); + // 删除 + contractMapper.deleteById(id); + // 删除数据权限 + crmPermissionService.deletePermission(CrmBizTypeEnum.CRM_CONTRACT.getType(), id); + + // 记录操作日志上下文 + LogRecordContext.putVariable("contractName", contract.getName()); + } + + private CrmContractDO validateContractExists(Long id) { + CrmContractDO contract = contractMapper.selectById(id); + if (contract == null) { + throw exception(CONTRACT_NOT_EXISTS); + } + return contract; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_TRANSFER_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_CONTRACT_TRANSFER_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTRACT, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void transferContract(CrmContractTransferReqVO reqVO, Long userId) { + // 1. 校验合同是否存在 + CrmContractDO contract = validateContractExists(reqVO.getId()); + + // 2.1 数据权限转移 + crmPermissionService.transferPermission(new CrmPermissionTransferReqBO(userId, CrmBizTypeEnum.CRM_CONTRACT.getType(), + reqVO.getId(), reqVO.getNewOwnerUserId(), reqVO.getOldOwnerPermissionLevel())); + // 2.2 设置负责人 + contractMapper.updateById(new CrmContractDO().setId(reqVO.getId()).setOwnerUserId(reqVO.getNewOwnerUserId())); + + // 3. 记录转移日志 + LogRecordContext.putVariable("contract", contract); + } + + @Override + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_FOLLOW_UP_SUB_TYPE, bizNo = "{{#id}", + success = CRM_CONTRACT_FOLLOW_UP_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTRACT, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateContractFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent) { + // 1. 校验存在 + CrmContractDO contract = validateContractExists(id); + + // 2. 更新联系人的跟进信息 + contractMapper.updateById(new CrmContractDO().setId(id).setContactLastTime(LocalDateTime.now())); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("contractName", contract.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_SUBMIT_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CONTRACT_SUBMIT_SUCCESS) + public void submitContract(Long id, Long userId) { + // 1. 校验合同是否在审批 + CrmContractDO contract = validateContractExists(id); + if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) { + throw exception(CONTRACT_SUBMIT_FAIL_NOT_DRAFT); + } + + // 2. 创建合同审批流程实例 + String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id))); + + // 3. 更新合同工作流编号 + contractMapper.updateById(new CrmContractDO().setId(id).setProcessInstanceId(processInstanceId) + .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus())); + + // 3. 记录日志 + LogRecordContext.putVariable("contractName", contract.getName()); + } + + @Override + public void updateContractAuditStatus(Long id, Integer bpmResult) { + // 1.1 校验合同是否存在 + CrmContractDO contract = validateContractExists(id); + // 1.2 只有审批中,可以更新审批结果 + if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) { + log.error("[updateContractAuditStatus][contract({}) 不处于审批中,无法更新审批结果({})]", + contract.getId(), bpmResult); + throw exception(CONTRACT_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS); + } + + // 2. 更新合同审批结果 + Integer auditStatus = convertBpmResultToAuditStatus(bpmResult); + contractMapper.updateById(new CrmContractDO().setId(id).setAuditStatus(auditStatus)); + } + + // ======================= 查询相关 ======================= + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CONTRACT, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmContractDO getContract(Long id) { + return contractMapper.selectById(id); + } + + @Override + public CrmContractDO validateContract(Long id) { + return validateContractExists(id); + } + + @Override + public List getContractList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return contractMapper.selectBatchIds(ids); + } + + @Override + public PageResult getContractPage(CrmContractPageReqVO pageReqVO, Long userId) { + // 1. 即将到期,需要查询合同配置 + CrmContractConfigDO config = null; + if (CrmContractPageReqVO.EXPIRY_TYPE_ABOUT_TO_EXPIRE.equals(pageReqVO.getExpiryType())) { + config = contractConfigService.getContractConfig(); + if (config != null && Boolean.FALSE.equals(config.getNotifyEnabled())) { + config = null; + } + } + // 2. 查询分页 + return contractMapper.selectPage(pageReqVO, userId, config); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ) + public PageResult getContractPageByCustomerId(CrmContractPageReqVO pageReqVO) { + return contractMapper.selectPageByCustomerId(pageReqVO); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_BUSINESS, bizId = "#pageReqVO.businessId", level = CrmPermissionLevelEnum.READ) + public PageResult getContractPageByBusinessId(CrmContractPageReqVO pageReqVO) { + return contractMapper.selectPageByBusinessId(pageReqVO); + } + + @Override + public Long getContractCountByContactId(Long contactId) { + return contractMapper.selectCountByContactId(contactId); + } + + @Override + public Long getContractCountByCustomerId(Long customerId) { + return contractMapper.selectCount(CrmContractDO::getCustomerId, customerId); + } + + @Override + public Long getContractCountByBusinessId(Long businessId) { + return contractMapper.selectCountByBusinessId(businessId); + } + + @Override + public List getContractProductListByContractId(Long contactId) { + return contractProductMapper.selectListByContractId(contactId); + } + + @Override + public Long getAuditContractCount(Long userId) { + return contractMapper.selectCountByAudit(userId); + } + + @Override + public Long getRemindContractCount(Long userId) { + CrmContractConfigDO config = contractConfigService.getContractConfig(); + if (config == null || Boolean.FALSE.equals(config.getNotifyEnabled())) { + return 0L; + } + return contractMapper.selectCountByRemind(userId, config); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/listener/CrmContractResultListener.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/listener/CrmContractResultListener.java new file mode 100644 index 0000000000..cc2d150249 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/contract/listener/CrmContractResultListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.crm.service.contract.listener; + +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEvent; +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEventListener; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractServiceImpl; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 合同审批的结果的监听器实现类 + * + * @author HUIHUI + */ +@Component +public class CrmContractResultListener extends BpmProcessInstanceResultEventListener { + + @Resource + private CrmContractService contractService; + + @Override + public String getProcessDefinitionKey() { + return CrmContractServiceImpl.BPM_PROCESS_DEFINITION_KEY; + } + + @Override + protected void onEvent(BpmProcessInstanceResultEvent event) { + contractService.updateContractAuditStatus(Long.parseLong(event.getBusinessKey()), event.getResult()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerPoolConfigServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerPoolConfigServiceImpl.java new file mode 100644 index 0000000000..b00315c673 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerPoolConfigServiceImpl.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.crm.service.customer; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.poolconfig.CrmCustomerPoolConfigSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerPoolConfigDO; +import cn.iocoder.yudao.module.crm.dal.mysql.customer.CrmCustomerPoolConfigMapper; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Objects; + +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + +/** + * 客户公海配置 Service 实现类 + * + * @author Wanwan + */ +@Service +@Validated +public class CrmCustomerPoolConfigServiceImpl implements CrmCustomerPoolConfigService { + + @Resource + private CrmCustomerPoolConfigMapper customerPoolConfigMapper; + + @Override + public CrmCustomerPoolConfigDO getCustomerPoolConfig() { + return customerPoolConfigMapper.selectOne(); + } + + @Override + @LogRecord(type = CRM_CUSTOMER_POOL_CONFIG_TYPE, subType = CRM_CUSTOMER_POOL_CONFIG_SUB_TYPE, bizNo = "{{#poolConfigId}}", + success = CRM_CUSTOMER_POOL_CONFIG_SUCCESS) + public void saveCustomerPoolConfig(CrmCustomerPoolConfigSaveReqVO saveReqVO) { + // 1. 存在,则进行更新 + CrmCustomerPoolConfigDO dbConfig = getCustomerPoolConfig(); + CrmCustomerPoolConfigDO poolConfig = BeanUtils.toBean(saveReqVO, CrmCustomerPoolConfigDO.class); + if (Objects.nonNull(dbConfig)) { + customerPoolConfigMapper.updateById(poolConfig.setId(dbConfig.getId())); + // 记录操作日志上下文 + LogRecordContext.putVariable("isPoolConfigUpdate", Boolean.TRUE); + LogRecordContext.putVariable("poolConfigId", poolConfig.getId()); + return; + } + + // 2. 不存在,则进行插入 + customerPoolConfigMapper.insert(poolConfig); + // 记录操作日志上下文 + LogRecordContext.putVariable("isPoolConfigUpdate", Boolean.FALSE); + LogRecordContext.putVariable("poolConfigId", poolConfig.getId()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerService.java new file mode 100644 index 0000000000..edaa65088b --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerService.java @@ -0,0 +1,198 @@ +package cn.iocoder.yudao.module.crm.service.customer; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.*; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.service.customer.bo.CrmCustomerCreateReqBO; + +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 客户 Service 接口 + * + * @author Wanwan + */ +public interface CrmCustomerService { + + /** + * 创建客户 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 编号 + */ + Long createCustomer(@Valid CrmCustomerSaveReqVO createReqVO, Long userId); + + /** + * 更新客户 + * + * @param updateReqVO 更新信息 + */ + void updateCustomer(@Valid CrmCustomerSaveReqVO updateReqVO); + + /** + * 更新客户的跟进状态 + * + * @param id 编号 + * @param dealStatus 跟进状态 + */ + void updateCustomerDealStatus(Long id, Boolean dealStatus); + + /** + * 更新客户相关的跟进信息 + * + * @param id 编号 + * @param contactNextTime 下次联系时间 + * @param contactLastContent 最后联系内容 + */ + void updateCustomerFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent); + + /** + * 删除客户 + * + * @param id 编号 + */ + void deleteCustomer(Long id); + + /** + * 获得客户 + * + * @param id 编号 + * @return 客户 + */ + CrmCustomerDO getCustomer(Long id); + + /** + * 获得客户列表 + * + * @param ids 客户编号数组 + * @return 客户列表 + * @author ljlleo + */ + List getCustomerList(Collection ids); + + /** + * 获得客户 Map + * + * @param ids 客户编号数组 + * @return 客户 Map + */ + default Map getCustomerMap(Collection ids) { + return convertMap(getCustomerList(ids), CrmCustomerDO::getId); + } + + /** + * 获得客户分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 客户分页 + */ + PageResult getCustomerPage(CrmCustomerPageReqVO pageReqVO, Long userId); + + /** + * 获得放入公海提醒的客户分页 + * + * @param pageVO 分页查询 + * @param userId 用户编号 + * @return 客户分页 + */ + PageResult getPutPoolRemindCustomerPage(CrmCustomerPageReqVO pageVO, Long userId); + + /** + * 获得待进入公海的客户数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getPutPoolRemindCustomerCount(Long userId); + + /** + * 获得今日需联系客户数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getTodayContactCustomerCount(Long userId); + + /** + * 获得分配给我的客户数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getFollowCustomerCount(Long userId); + + /** + * 校验客户是否存在 + * + * @param id 编号 + */ + void validateCustomer(Long id); + + /** + * 客户转移 + * + * @param reqVO 请求 + * @param userId 用户编号 + */ + void transferCustomer(CrmCustomerTransferReqVO reqVO, Long userId); + + /** + * 锁定/解锁客户 + * + * @param lockReqVO 更新信息 + * @param userId 用户编号 + */ + void lockCustomer(@Valid CrmCustomerLockReqVO lockReqVO, Long userId); + + /** + * 创建客户 + * + * @param customerCreateReq 请求信息 + * @param userId 用户编号 + * @return 客户列表 + */ + Long createCustomer(CrmCustomerCreateReqBO customerCreateReq, Long userId); + + /** + * 批量导入客户 + * + * @param importCustomers 导入客户列表 + * @param importReqVO 请求 + * @return 导入结果 + */ + CrmCustomerImportRespVO importCustomerList(List importCustomers, CrmCustomerImportReqVO importReqVO); + + // ==================== 公海相关操作 ==================== + + /** + * 客户放入公海 + * + * @param id 客户编号 + */ + void putCustomerPool(Long id); + + /** + * 领取公海客户 + * + * @param ids 要领取的客户编号数组 + * @param ownerUserId 负责人 + * @param isReceive 是/否领取;true - 领取;false - 分配 + */ + void receiveCustomer(List ids, Long ownerUserId, Boolean isReceive); + + /** + * 【系统】客户自动掉入公海 + * + * @return 掉入公海数量 + */ + int autoPutCustomerPool(); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerServiceImpl.java new file mode 100644 index 0000000000..3fb37f9155 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/CrmCustomerServiceImpl.java @@ -0,0 +1,622 @@ +package cn.iocoder.yudao.module.crm.service.customer; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.*; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerLimitConfigDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerPoolConfigDO; +import cn.iocoder.yudao.module.crm.dal.mysql.customer.CrmCustomerMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.bo.CrmCustomerCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; +import static cn.iocoder.yudao.module.crm.enums.customer.CrmCustomerLimitConfigTypeEnum.CUSTOMER_LOCK_LIMIT; +import static cn.iocoder.yudao.module.crm.enums.customer.CrmCustomerLimitConfigTypeEnum.CUSTOMER_OWNER_LIMIT; +import static java.util.Collections.singletonList; + +/** + * 客户 Service 实现类 + * + * @author Wanwan + */ +@Service +@Slf4j +@Validated +public class CrmCustomerServiceImpl implements CrmCustomerService { + + @Resource + private CrmCustomerMapper customerMapper; + + @Resource + private CrmPermissionService permissionService; + @Resource + private CrmCustomerLimitConfigService customerLimitConfigService; + @Resource + @Lazy + private CrmCustomerPoolConfigService customerPoolConfigService; + @Resource + @Lazy + private CrmContactService contactService; + @Resource + @Lazy + private CrmBusinessService businessService; + @Resource + @Lazy + private CrmContractService contractService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_CREATE_SUB_TYPE, bizNo = "{{#customer.id}}", + success = CRM_CUSTOMER_CREATE_SUCCESS) + public Long createCustomer(CrmCustomerSaveReqVO createReqVO, Long userId) { + createReqVO.setId(null); + // 1. 校验拥有客户是否到达上限 + validateCustomerExceedOwnerLimit(createReqVO.getOwnerUserId(), 1); + + // 2. 插入客户 + CrmCustomerDO customer = initCustomer(createReqVO, userId); + customerMapper.insert(customer); + + // 3. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_CUSTOMER.getType()) + .setBizId(customer.getId()).setUserId(userId).setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人 + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("customer", customer); + return customer.getId(); + } + + /** + * 初始化客户的通用字段 + * + * @param customer 客户信息 + * @param ownerUserId 负责人编号 + * @return 客户信息 DO + */ + private static CrmCustomerDO initCustomer(Object customer, Long ownerUserId) { + return BeanUtils.toBean(customer, CrmCustomerDO.class).setOwnerUserId(ownerUserId) + .setOwnerTime(LocalDateTime.now()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_CUSTOMER_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateCustomer(CrmCustomerSaveReqVO updateReqVO) { + Assert.notNull(updateReqVO.getId(), "客户编号不能为空"); + updateReqVO.setOwnerUserId(null); // 更新的时候,要把 updateReqVO 负责人设置为空,避免修改 + // 1. 校验存在 + CrmCustomerDO oldCustomer = validateCustomerExists(updateReqVO.getId()); + + // 2. 更新客户 + CrmCustomerDO updateObj = BeanUtils.toBean(updateReqVO, CrmCustomerDO.class); + customerMapper.updateById(updateObj); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldCustomer, CrmCustomerSaveReqVO.class)); + LogRecordContext.putVariable("customerName", oldCustomer.getName()); + } + + @Override + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_UPDATE_DEAL_STATUS_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CUSTOMER_UPDATE_DEAL_STATUS_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateCustomerDealStatus(Long id, Boolean dealStatus) { + // 1.1 校验存在 + CrmCustomerDO customer = validateCustomerExists(id); + // 1.2 校验是否重复操作 + if (Objects.equals(customer.getDealStatus(), dealStatus)) { + throw exception(CUSTOMER_UPDATE_DEAL_STATUS_FAIL); + } + + // 2. 更新客户的成交状态 + customerMapper.updateById(new CrmCustomerDO().setId(id).setDealStatus(dealStatus)); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("customerName", customer.getName()); + LogRecordContext.putVariable("dealStatus", dealStatus); + } + + @Override + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_FOLLOW_UP_SUB_TYPE, bizNo = "{{#id}", + success = CRM_CUSTOMER_FOLLOW_UP_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#id", level = CrmPermissionLevelEnum.WRITE) + public void updateCustomerFollowUp(Long id, LocalDateTime contactNextTime, String contactLastContent) { + // 1.1 校验存在 + CrmCustomerDO customer = validateCustomerExists(id); + + // 2. 更新客户的跟进信息 + customerMapper.updateById(new CrmCustomerDO().setId(id).setFollowUpStatus(true).setContactNextTime(contactNextTime) + .setContactLastTime(LocalDateTime.now()).setContactLastContent(contactLastContent)); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("customerName", customer.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CUSTOMER_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteCustomer(Long id) { + // 1.1 校验存在 + CrmCustomerDO customer = validateCustomerExists(id); + // 1.2 检查引用 + validateCustomerReference(id); + + // 2. 删除客户 + customerMapper.deleteById(id); + // 3. 删除数据权限 + permissionService.deletePermission(CrmBizTypeEnum.CRM_CUSTOMER.getType(), id); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("customerName", customer.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_TRANSFER_SUB_TYPE, bizNo = "{{#reqVO.id}}", + success = CRM_CUSTOMER_TRANSFER_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void transferCustomer(CrmCustomerTransferReqVO reqVO, Long userId) { + // 1.1 校验客户是否存在 + CrmCustomerDO customer = validateCustomerExists(reqVO.getId()); + // 1.2 校验拥有客户是否到达上限 + validateCustomerExceedOwnerLimit(reqVO.getNewOwnerUserId(), 1); + + // 2.1 数据权限转移 + permissionService.transferPermission(new CrmPermissionTransferReqBO(userId, CrmBizTypeEnum.CRM_CUSTOMER.getType(), + reqVO.getId(), reqVO.getNewOwnerUserId(), reqVO.getOldOwnerPermissionLevel())); + // 2.2 转移后重新设置负责人 + customerMapper.updateById(new CrmCustomerDO().setId(reqVO.getId()) + .setOwnerUserId(reqVO.getNewOwnerUserId()).setOwnerTime(LocalDateTime.now())); + + // 3. 记录转移日志 + LogRecordContext.putVariable("customer", customer); + } + + @Override + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_LOCK_SUB_TYPE, bizNo = "{{#lockReqVO.id}}", + success = CRM_CUSTOMER_LOCK_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#lockReqVO.id", level = CrmPermissionLevelEnum.OWNER) + public void lockCustomer(CrmCustomerLockReqVO lockReqVO, Long userId) { + // 1.1 校验当前客户是否存在 + CrmCustomerDO customer = validateCustomerExists(lockReqVO.getId()); + // 1.2 校验当前是否重复操作锁定/解锁状态 + if (customer.getLockStatus().equals(lockReqVO.getLockStatus())) { + throw exception(customer.getLockStatus() ? CUSTOMER_LOCK_FAIL_IS_LOCK : CUSTOMER_UNLOCK_FAIL_IS_UNLOCK); + } + // 1.3 校验锁定上限 + if (lockReqVO.getLockStatus()) { + validateCustomerExceedLockLimit(userId); + } + + // 2. 更新锁定状态 + customerMapper.updateById(BeanUtils.toBean(lockReqVO, CrmCustomerDO.class)); + + // 3. 记录操作日志上下文 + // tips: 因为这里使用的是老的状态所以记录时反着记录,也就是 lockStatus 为 true 那么就是解锁反之为锁定 + LogRecordContext.putVariable("customer", customer); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_CREATE_SUB_TYPE, bizNo = "{{#customer.id}}", + success = CRM_CUSTOMER_CREATE_SUCCESS) + public Long createCustomer(CrmCustomerCreateReqBO createReqBO, Long userId) { + // 1. 插入客户 + CrmCustomerDO customer = initCustomer(createReqBO, userId); + customerMapper.insert(customer); + + // 2. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_CUSTOMER.getType()) + .setBizId(customer.getId()).setUserId(userId).setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人 + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable("customer", customer); + return customer.getId(); + } + + @Override + public CrmCustomerImportRespVO importCustomerList(List importCustomers, + CrmCustomerImportReqVO importReqVO) { + // 校验非空 + importCustomers = filterList(importCustomers, item -> Objects.nonNull(item.getName())); + if (CollUtil.isEmpty(importCustomers)) { + throw exception(CUSTOMER_IMPORT_LIST_IS_EMPTY); + } + + // 逐条处理 + CrmCustomerImportRespVO respVO = CrmCustomerImportRespVO.builder().createCustomerNames(new ArrayList<>()) + .updateCustomerNames(new ArrayList<>()).failureCustomerNames(new LinkedHashMap<>()).build(); + importCustomers.forEach(importCustomer -> { + // 校验,判断是否有不符合的原因 + try { + validateCustomerForCreate(importCustomer); + } catch (ServiceException ex) { + respVO.getFailureCustomerNames().put(importCustomer.getName(), ex.getMessage()); + return; + } + // 情况一:判断如果不存在,在进行插入 + CrmCustomerDO existCustomer = customerMapper.selectByCustomerName(importCustomer.getName()); + if (existCustomer == null) { + // 1.1 插入客户信息 + CrmCustomerDO customer = initCustomer(importCustomer, importReqVO.getOwnerUserId()); + customerMapper.insert(customer); + respVO.getCreateCustomerNames().add(importCustomer.getName()); + // 1.2 创建数据权限 + if (importReqVO.getOwnerUserId() != null) { + permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_CUSTOMER.getType()) + .setBizId(customer.getId()).setUserId(importReqVO.getOwnerUserId()).setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + } + // 1.3 记录操作日志 + getSelf().importCustomerLog(customer, false); + return; + } + + // 情况二:如果存在,判断是否允许更新 + if (!importReqVO.getUpdateSupport()) { + respVO.getFailureCustomerNames().put(importCustomer.getName(), + StrUtil.format(CUSTOMER_NAME_EXISTS.getMsg(), importCustomer.getName())); + return; + } + // 2.1 更新客户信息 + CrmCustomerDO updateCustomer = BeanUtils.toBean(importCustomer, CrmCustomerDO.class) + .setId(existCustomer.getId()); + customerMapper.updateById(updateCustomer); + respVO.getUpdateCustomerNames().add(importCustomer.getName()); + // 2.2 记录操作日志 + getSelf().importCustomerLog(updateCustomer, true); + }); + return respVO; + } + + /** + * 记录导入客户时的操作日志 + * + * @param customer 客户信息 + * @param isUpdate 是否更新;true - 更新,false - 新增 + */ + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_IMPORT_SUB_TYPE, bizNo = "{{#customer.id}}", + success = CRM_CUSTOMER_IMPORT_SUCCESS) + public void importCustomerLog(CrmCustomerDO customer, boolean isUpdate) { + LogRecordContext.putVariable("customer", customer); + LogRecordContext.putVariable("isUpdate", isUpdate); + } + + // ==================== 公海相关操作 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_POOL_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_CUSTOMER_POOL_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void putCustomerPool(Long id) { + // 1. 校验存在 + CrmCustomerDO customer = customerMapper.selectById(id); + if (customer == null) { + throw exception(CUSTOMER_NOT_EXISTS); + } + // 1.2. 校验是否为公海数据 + validateCustomerOwnerExists(customer, true); + // 1.3. 校验客户是否锁定 + validateCustomerIsLocked(customer, true); + + // 2. 客户放入公海 + putCustomerPool(customer); + + // 记录操作日志上下文 + LogRecordContext.putVariable("customerName", customer.getName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void receiveCustomer(List ids, Long ownerUserId, Boolean isReceive) { + // 1.1 校验存在 + List customers = customerMapper.selectBatchIds(ids); + if (customers.size() != ids.size()) { + throw exception(CUSTOMER_NOT_EXISTS); + } + // 1.2 校验负责人是否存在 + adminUserApi.validateUserList(singletonList(ownerUserId)); + // 1.3 校验状态 + customers.forEach(customer -> { + // 校验是否已有负责人 + validateCustomerOwnerExists(customer, false); + // 校验是否锁定 + validateCustomerIsLocked(customer, false); + // 校验成交状态 + validateCustomerDeal(customer); + }); + // 1.4 校验负责人是否到达上限 + validateCustomerExceedOwnerLimit(ownerUserId, customers.size()); + + // 2. 领取公海数据 + List updateCustomers = new ArrayList<>(); + List createPermissions = new ArrayList<>(); + customers.forEach(customer -> { + // 2.1. 设置负责人 + updateCustomers.add(new CrmCustomerDO().setId(customer.getId()) + .setOwnerUserId(ownerUserId).setOwnerTime(LocalDateTime.now())); + // 2.2. 创建负责人数据权限 + createPermissions.add(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_CUSTOMER.getType()) + .setBizId(customer.getId()).setUserId(ownerUserId).setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + }); + // 2.2 更新客户负责人 + customerMapper.updateBatch(updateCustomers); + // 2.3 创建负责人数据权限 + permissionService.createPermissionBatch(createPermissions); + // TODO @芋艿:要不要处理关联的联系人??? + + // 3. 记录操作日志 + AdminUserRespDTO user = null; + if (!isReceive) { + user = adminUserApi.getUser(ownerUserId); + } + for (CrmCustomerDO customer : customers) { + getSelf().receiveCustomerLog(customer, user == null ? null : user.getNickname()); + } + } + + @Override + public int autoPutCustomerPool() { + CrmCustomerPoolConfigDO poolConfig = customerPoolConfigService.getCustomerPoolConfig(); + if (poolConfig == null || !poolConfig.getEnabled()) { + return 0; + } + // 1. 获得需要放到的客户列表 + List customerList = customerMapper.selectListByAutoPool(poolConfig); + // 2. 逐个放入公海 + int count = 0; + for (CrmCustomerDO customer : customerList) { + try { + getSelf().putCustomerPool(customer); + count++; + } catch (Throwable e) { + log.error("[autoPutCustomerPool][客户({}) 放入公海异常]", customer.getId(), e); + } + } + return count; + } + + @Transactional(rollbackFor = Exception.class) // 需要 protected 修饰,因为需要在事务中调用 + protected void putCustomerPool(CrmCustomerDO customer) { + // 1. 设置负责人为 NULL + int updateOwnerUserIncr = customerMapper.updateOwnerUserIdById(customer.getId(), null); + if (updateOwnerUserIncr == 0) { + throw exception(CUSTOMER_UPDATE_OWNER_USER_FAIL); + } + + // 2. 联系人的负责人,也要设置为 null。因为:因为领取后,负责人也要关联过来,这块和 receiveCustomer 是对应的 + contactService.updateOwnerUserIdByCustomerId(customer.getId(), null); + + // 3. 删除负责人数据权限 + // 注意:需要放在 contactService 后面,不然【客户】数据权限已经被删除,无法操作! + permissionService.deletePermission(CrmBizTypeEnum.CRM_CUSTOMER.getType(), customer.getId(), + CrmPermissionLevelEnum.OWNER.getLevel()); + } + + @LogRecord(type = CRM_CUSTOMER_TYPE, subType = CRM_CUSTOMER_RECEIVE_SUB_TYPE, bizNo = "{{#customer.id}}", + success = CRM_CUSTOMER_RECEIVE_SUCCESS) + public void receiveCustomerLog(CrmCustomerDO customer, String ownerUserName) { + // 记录操作日志上下文 + LogRecordContext.putVariable("customer", customer); + LogRecordContext.putVariable("ownerUserName", ownerUserName); + } + + //======================= 查询相关 ======================= + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmCustomerDO getCustomer(Long id) { + return customerMapper.selectById(id); + } + + @Override + public List getCustomerList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return customerMapper.selectBatchIds(ids); + } + + @Override + public PageResult getCustomerPage(CrmCustomerPageReqVO pageReqVO, Long userId) { + return customerMapper.selectPage(pageReqVO, userId); + } + + @Override + public PageResult getPutPoolRemindCustomerPage(CrmCustomerPageReqVO pageVO, Long userId) { + CrmCustomerPoolConfigDO poolConfig = customerPoolConfigService.getCustomerPoolConfig(); + if (ObjUtil.isNull(poolConfig) + || Boolean.FALSE.equals(poolConfig.getEnabled()) + || Boolean.FALSE.equals(poolConfig.getNotifyEnabled())) { + return PageResult.empty(); + } + return customerMapper.selectPutPoolRemindCustomerPage(pageVO, poolConfig, userId); + } + + @Override + public Long getPutPoolRemindCustomerCount(Long userId) { + CrmCustomerPoolConfigDO poolConfig = customerPoolConfigService.getCustomerPoolConfig(); + if (ObjUtil.isNull(poolConfig) + || Boolean.FALSE.equals(poolConfig.getEnabled()) + || Boolean.FALSE.equals(poolConfig.getNotifyEnabled())) { + return 0L; + } + CrmCustomerPageReqVO pageVO = new CrmCustomerPageReqVO() + .setPool(null) + .setContactStatus(CrmCustomerPageReqVO.CONTACT_TODAY) + .setSceneType(CrmSceneTypeEnum.OWNER.getType()); + return customerMapper.selectPutPoolRemindCustomerCount(pageVO, poolConfig, userId); + } + + @Override + public Long getTodayContactCustomerCount(Long userId) { + return customerMapper.selectCountByTodayContact(userId); + } + + @Override + public Long getFollowCustomerCount(Long userId) { + return customerMapper.selectCountByFollow(userId); + } + + // ======================= 校验相关 ======================= + + private void validateCustomerForCreate(CrmCustomerImportExcelVO importCustomer) { + // 校验客户名称不能为空 + if (StrUtil.isEmptyIfStr(importCustomer.getName())) { + throw exception(CUSTOMER_CREATE_NAME_NOT_NULL); + } + } + + /** + * 校验客户是否被引用 + * + * @param id 客户编号 + */ + private void validateCustomerReference(Long id) { + if (contactService.getContactCountByCustomerId(id) > 0) { + throw exception(CUSTOMER_DELETE_FAIL_HAVE_REFERENCE, CrmBizTypeEnum.CRM_CONTACT.getName()); + } + if (businessService.getBusinessCountByCustomerId(id) > 0) { + throw exception(CUSTOMER_DELETE_FAIL_HAVE_REFERENCE, CrmBizTypeEnum.CRM_BUSINESS.getName()); + } + if (contractService.getContractCountByCustomerId(id) > 0) { + throw exception(CUSTOMER_DELETE_FAIL_HAVE_REFERENCE, CrmBizTypeEnum.CRM_CONTRACT.getName()); + } + } + + /** + * 校验客户是否存在 + * + * @param id 客户 id + */ + @Override + public void validateCustomer(Long id) { + validateCustomerExists(id); + } + + private void validateCustomerOwnerExists(CrmCustomerDO customer, Boolean pool) { + if (customer == null) { // 防御一下 + throw exception(CUSTOMER_NOT_EXISTS); + } + // 校验是否为公海数据 + if (pool && customer.getOwnerUserId() == null) { + throw exception(CUSTOMER_IN_POOL, customer.getName()); + } + // 负责人已存在 + if (!pool && customer.getOwnerUserId() != null) { + throw exception(CUSTOMER_OWNER_EXISTS, customer.getName()); + } + } + + private CrmCustomerDO validateCustomerExists(Long id) { + CrmCustomerDO customerDO = customerMapper.selectById(id); + if (customerDO == null) { + throw exception(CUSTOMER_NOT_EXISTS); + } + return customerDO; + } + + private void validateCustomerIsLocked(CrmCustomerDO customer, Boolean pool) { + if (customer.getLockStatus()) { + throw exception(pool ? CUSTOMER_LOCKED_PUT_POOL_FAIL : CUSTOMER_LOCKED, customer.getName()); + } + } + + private void validateCustomerDeal(CrmCustomerDO customer) { + if (customer.getDealStatus()) { + throw exception(CUSTOMER_ALREADY_DEAL); + } + } + + /** + * 校验用户拥有的客户数量,是否到达上限 + * + * @param userId 用户编号 + * @param newCount 附加数量 + */ + private void validateCustomerExceedOwnerLimit(Long userId, int newCount) { + List limitConfigs = customerLimitConfigService.getCustomerLimitConfigListByUserId( + CUSTOMER_OWNER_LIMIT.getType(), userId); + if (CollUtil.isEmpty(limitConfigs)) { + return; + } + Long ownerCount = customerMapper.selectCountByDealStatusAndOwnerUserId(null, userId); + Long dealOwnerCount = customerMapper.selectCountByDealStatusAndOwnerUserId(true, userId); + limitConfigs.forEach(limitConfig -> { + long nowCount = limitConfig.getDealCountEnabled() ? ownerCount : ownerCount - dealOwnerCount; + if (nowCount + newCount > limitConfig.getMaxCount()) { + throw exception(CUSTOMER_OWNER_EXCEED_LIMIT); + } + }); + } + + /** + * 校验用户锁定的客户数量,是否到达上限 + * + * @param userId 用户编号 + */ + private void validateCustomerExceedLockLimit(Long userId) { + List limitConfigs = customerLimitConfigService.getCustomerLimitConfigListByUserId( + CUSTOMER_LOCK_LIMIT.getType(), userId); + if (CollUtil.isEmpty(limitConfigs)) { + return; + } + Long lockCount = customerMapper.selectCountByLockStatusAndOwnerUserId(true, userId); + Integer maxCount = CollectionUtils.getMaxValue(limitConfigs, CrmCustomerLimitConfigDO::getMaxCount); + assert maxCount != null; + if (lockCount >= maxCount) { + throw exception(CUSTOMER_LOCK_EXCEED_LIMIT); + } + } + + /** + * 获得自身的代理对象,解决 AOP 生效问题 + * + * @return 自己 + */ + private CrmCustomerServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/bo/CrmCustomerCreateReqBO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/bo/CrmCustomerCreateReqBO.java new file mode 100644 index 0000000000..87a6b3387a --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/customer/bo/CrmCustomerCreateReqBO.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.crm.service.customer.bo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import cn.iocoder.yudao.framework.common.validation.Telephone; +import cn.iocoder.yudao.module.crm.enums.DictTypeConstants; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 客户创建 Create Req BO + * + * @author HUIHUI + */ +@Data +public class CrmCustomerCreateReqBO { + + /** + * 客户名称 + */ + @NotEmpty(message = "客户名称不能为空") + private String name; + /** + * 跟进状态 + */ + private Boolean followUpStatus; + /** + * 锁定状态 + */ + private Boolean lockStatus; + /** + * 成交状态 + */ + private Boolean dealStatus; + /** + * 所属行业 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_INDUSTRY} + */ + private Integer industryId; + /** + * 客户等级 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_LEVEL} + */ + private Integer level; + /** + * 客户来源 + * + * 对应字典 {@link DictTypeConstants#CRM_CUSTOMER_SOURCE} + */ + private Integer source; + + /** + * 手机 + */ + @Mobile + private String mobile; + /** + * 电话 + */ + @Telephone + private String telephone; + /** + * QQ + */ + private String qq; + /** + * wechat + */ + private String wechat; + + /** + * 邮箱 + */ + @Email(message = "邮箱格式不正确") + private String email; + + /** + * 客户描述 + */ + @Size(max = 4096, message = "客户描述长度不能超过 4096 个字符") + private String description; + /** + * 备注 + */ + private String remark; + /** + * 负责人的用户编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + /** + * 所在地 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} 字段 + */ + private Integer areaId; + /** + * 详细地址 + */ + private String detailAddress; + + /** + * 最后跟进时间 + */ + private LocalDateTime contactLastTime; + /** + * 最后跟进内容 + */ + private String contactLastContent; + /** + * 下次联系时间 + */ + private LocalDateTime contactNextTime; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/followup/CrmFollowUpRecordServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/followup/CrmFollowUpRecordServiceImpl.java new file mode 100644 index 0000000000..0bc4480e33 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/followup/CrmFollowUpRecordServiceImpl.java @@ -0,0 +1,149 @@ +package cn.iocoder.yudao.module.crm.service.followup; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.followup.vo.CrmFollowUpRecordPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.followup.vo.CrmFollowUpRecordSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.followup.CrmFollowUpRecordDO; +import cn.iocoder.yudao.module.crm.dal.mysql.followup.CrmFollowUpRecordMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService; +import cn.iocoder.yudao.module.crm.service.clue.CrmClueService; +import cn.iocoder.yudao.module.crm.service.contact.CrmContactService; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; +import cn.iocoder.yudao.module.crm.service.followup.bo.CrmFollowUpCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import javax.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.FOLLOW_UP_RECORD_DELETE_DENIED; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.FOLLOW_UP_RECORD_NOT_EXISTS; + +/** + * 跟进记录 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class CrmFollowUpRecordServiceImpl implements CrmFollowUpRecordService { + + @Resource + private CrmFollowUpRecordMapper crmFollowUpRecordMapper; + + @Resource + @Lazy + private CrmPermissionService permissionService; + @Resource + @Lazy + private CrmBusinessService businessService; + @Resource + @Lazy + private CrmClueService clueService; + @Resource + @Lazy + private CrmContactService contactService; + @Resource + @Lazy + private CrmContractService contractService; + @Resource + @Lazy + private CrmCustomerService customerService; + + @Override + @CrmPermission(bizTypeValue = "#createReqVO.bizType", bizId = "#createReqVO.bizId", level = CrmPermissionLevelEnum.WRITE) + public Long createFollowUpRecord(CrmFollowUpRecordSaveReqVO createReqVO) { + // 1. 创建更进记录 + CrmFollowUpRecordDO record = BeanUtils.toBean(createReqVO, CrmFollowUpRecordDO.class); + crmFollowUpRecordMapper.insert(record); + + // 2. 更新 bizId 对应的记录 + if (ObjUtil.equal(CrmBizTypeEnum.CRM_CUSTOMER.getType(), record.getBizType())) { // 更新客户跟进信息 + customerService.updateCustomerFollowUp(record.getBizId(), record.getNextTime(), record.getContent()); + } + if (ObjUtil.equal(CrmBizTypeEnum.CRM_BUSINESS.getType(), record.getBizType())) { // 更新商机跟进信息 + businessService.updateBusinessFollowUp(record.getBizId(), record.getNextTime(), record.getContent()); + } + if (ObjUtil.equal(CrmBizTypeEnum.CRM_CLUE.getType(), record.getBizType())) { // 更新线索跟进信息 + clueService.updateClueFollowUp(record.getBizId(), record.getNextTime(), record.getContent()); + } + if (ObjUtil.equal(CrmBizTypeEnum.CRM_CONTACT.getType(), record.getBizType())) { // 更新联系人跟进信息 + contactService.updateContactFollowUp(record.getBizId(), record.getNextTime(), record.getContent()); + } + if (ObjUtil.equal(CrmBizTypeEnum.CRM_CONTRACT.getType(), record.getBizType())) { // 更新合同跟进信息 + contractService.updateContractFollowUp(record.getBizId(), record.getNextTime(), record.getContent()); + } + + // 3.1 更新 contactIds 对应的记录,只更新 nextTime + if (CollUtil.isNotEmpty(createReqVO.getContactIds())) { + contactService.updateContactContactNextTime(createReqVO.getContactIds(), createReqVO.getNextTime()); + } + // 3.2 需要更新 businessIds 对应的记录,只更新 nextTime + if (CollUtil.isNotEmpty(createReqVO.getBusinessIds())) { + businessService.updateBusinessContactNextTime(createReqVO.getBusinessIds(), createReqVO.getNextTime()); + } + return record.getId(); + } + + @Override + public void createFollowUpRecordBatch(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + crmFollowUpRecordMapper.insertBatch(BeanUtils.toBean(list, CrmFollowUpRecordDO.class)); + } + + @Override + public void deleteFollowUpRecord(Long id, Long userId) { + // 校验存在 + CrmFollowUpRecordDO followUpRecord = validateFollowUpRecordExists(id); + // 校验权限 + if (!permissionService.hasPermission(followUpRecord.getBizType(), followUpRecord.getBizId(), userId, CrmPermissionLevelEnum.OWNER)) { + throw exception(FOLLOW_UP_RECORD_DELETE_DENIED); + } + + // 删除 + crmFollowUpRecordMapper.deleteById(id); + } + + @Override + public void deleteFollowUpRecordByBiz(Integer bizType, Long bizId) { + crmFollowUpRecordMapper.deleteByBiz(bizType, bizId); + } + + private CrmFollowUpRecordDO validateFollowUpRecordExists(Long id) { + CrmFollowUpRecordDO followUpRecord = crmFollowUpRecordMapper.selectById(id); + if (followUpRecord == null) { + throw exception(FOLLOW_UP_RECORD_NOT_EXISTS); + } + return followUpRecord; + } + + @Override + public CrmFollowUpRecordDO getFollowUpRecord(Long id) { + return crmFollowUpRecordMapper.selectById(id); + } + + @Override + @CrmPermission(bizTypeValue = "#pageReqVO.bizType", bizId = "#pageReqVO.bizId", level = CrmPermissionLevelEnum.READ) + public PageResult getFollowUpRecordPage(CrmFollowUpRecordPageReqVO pageReqVO) { + return crmFollowUpRecordMapper.selectPage(pageReqVO); + } + + @Override + public List getFollowUpRecordByBiz(Integer bizType, Collection bizIds) { + return crmFollowUpRecordMapper.selectListByBiz(bizType, bizIds); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/CrmPermissionServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/CrmPermissionServiceImpl.java new file mode 100644 index 0000000000..d67e76fa4a --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/CrmPermissionServiceImpl.java @@ -0,0 +1,223 @@ +package cn.iocoder.yudao.module.crm.service.permission; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.permission.vo.CrmPermissionUpdateReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.permission.CrmPermissionDO; +import cn.iocoder.yudao.module.crm.dal.mysql.permission.CrmPermissionMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionTransferReqBO; +import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.anyMatch; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum.isOwner; + +/** + * CRM 数据权限 Service 接口实现类 + * + * @author HUIHUI + */ +@Service +@Validated +public class CrmPermissionServiceImpl implements CrmPermissionService { + + @Resource + private CrmPermissionMapper permissionMapper; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createPermission(CrmPermissionCreateReqBO createReqBO) { + validatePermissionNotExists(Collections.singletonList(createReqBO)); + // 1. 校验用户是否存在 + adminUserApi.validateUserList(Collections.singletonList(createReqBO.getUserId())); + + // 2. 创建 + CrmPermissionDO permission = BeanUtils.toBean(createReqBO, CrmPermissionDO.class); + permissionMapper.insert(permission); + return permission.getId(); + } + + @Override + public void createPermissionBatch(List createReqBOs) { + validatePermissionNotExists(createReqBOs); + // 1. 校验用户是否存在 + adminUserApi.validateUserList(convertSet(createReqBOs, CrmPermissionCreateReqBO::getUserId)); + + // 2. 创建 + List permissions = BeanUtils.toBean(createReqBOs, CrmPermissionDO.class); + permissionMapper.insertBatch(permissions); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePermission(CrmPermissionUpdateReqVO updateReqVO) { + // 1. 校验存在 + validatePermissionExists(updateReqVO.getIds()); + // 2. 更新 + List updateList = CollectionUtils.convertList(updateReqVO.getIds(), + id -> new CrmPermissionDO().setId(id).setLevel(updateReqVO.getLevel())); + permissionMapper.updateBatch(updateList); + } + + private void validatePermissionExists(Collection ids) { + List permissionList = permissionMapper.selectBatchIds(ids); + if (ObjUtil.notEqual(permissionList.size(), ids.size())) { + throw exception(CRM_PERMISSION_NOT_EXISTS); + } + } + + private void validatePermissionNotExists(Collection createReqBOs) { + Set bizTypes = convertSet(createReqBOs, CrmPermissionCreateReqBO::getBizType); + Set bizIds = convertSet(createReqBOs, CrmPermissionCreateReqBO::getBizId); + Set userIds = convertSet(createReqBOs, CrmPermissionCreateReqBO::getUserId); + Long count = permissionMapper.selectListByBiz(bizTypes, bizIds, userIds); + if (count > 0) { + throw exception(CRM_PERMISSION_CREATE_FAIL); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void transferPermission(CrmPermissionTransferReqBO transferReqBO) { + // 1. 校验数据权限:是否是负责人,只有负责人才可以转移 + CrmPermissionDO oldPermission = permissionMapper.selectByBizTypeAndBizIdByUserId( + transferReqBO.getBizType(), transferReqBO.getBizId(), transferReqBO.getUserId()); + String bizTypeName = CrmBizTypeEnum.getNameByType(transferReqBO.getBizType()); + if (oldPermission == null // 不是拥有者,并且不是超管 + || (!isOwner(oldPermission.getLevel()) && !CrmPermissionUtils.isCrmAdmin())) { + throw exception(CRM_PERMISSION_DENIED, bizTypeName); + } + // 1.1 校验转移对象是否已经是该负责人 + if (ObjUtil.equal(transferReqBO.getNewOwnerUserId(), oldPermission.getUserId())) { + throw exception(CRM_PERMISSION_MODEL_TRANSFER_FAIL_OWNER_USER_EXISTS, bizTypeName); + } + // 1.2 校验新负责人是否存在 + adminUserApi.validateUserList(Collections.singletonList(transferReqBO.getNewOwnerUserId())); + + // 2. 修改新负责人的权限 + List permissions = permissionMapper.selectByBizTypeAndBizId( + transferReqBO.getBizType(), transferReqBO.getBizId()); // 获得所有数据权限 + CrmPermissionDO permission = CollUtil.findOne(permissions, + item -> ObjUtil.equal(item.getUserId(), transferReqBO.getNewOwnerUserId())); + if (permission == null) { + permissionMapper.insert(new CrmPermissionDO().setBizType(transferReqBO.getBizType()) + .setBizId(transferReqBO.getBizId()).setUserId(transferReqBO.getNewOwnerUserId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + } else { + permissionMapper.updateById(new CrmPermissionDO().setId(permission.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + } + + // 3. 修改老负责人的权限 + if (transferReqBO.getOldOwnerPermissionLevel() != null) { + permissionMapper.updateById(new CrmPermissionDO().setId(oldPermission.getId()) + .setLevel(transferReqBO.getOldOwnerPermissionLevel())); + } else { + permissionMapper.deleteById(oldPermission.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePermission(Integer bizType, Long bizId, Integer level) { + // 校验存在 + List permissions = permissionMapper.selectListByBizTypeAndBizIdAndLevel( + bizType, bizId, level); + if (CollUtil.isEmpty(permissions)) { + throw exception(CRM_PERMISSION_NOT_EXISTS); + } + + // 删除数据权限 + permissionMapper.deleteBatchIds(convertSet(permissions, CrmPermissionDO::getId)); + } + + @Override + public void deletePermission(Integer bizType, Long bizId) { + int deletedCount = permissionMapper.deletePermission(bizType, bizId); + if (deletedCount == 0) { + throw exception(CRM_PERMISSION_NOT_EXISTS); + } + } + + @Override + public void deletePermissionBatch(Collection ids, Long userId) { + List permissions = permissionMapper.selectBatchIds(ids); + if (CollUtil.isEmpty(permissions)) { + throw exception(CRM_PERMISSION_NOT_EXISTS); + } + // 校验:数据权限的模块数据编号是一致的不可能存在两个 + if (convertSet(permissions, CrmPermissionDO::getBizId).size() > 1) { + throw exception(CRM_PERMISSION_DELETE_FAIL); + } + // 校验操作人是否为负责人 + CrmPermissionDO permission = permissionMapper.selectByBizIdAndUserId(permissions.get(0).getBizId(), userId); + if (permission == null) { + throw exception(CRM_PERMISSION_DELETE_DENIED); + } + if (!CrmPermissionLevelEnum.isOwner(permission.getLevel())) { + throw exception(CRM_PERMISSION_DELETE_DENIED); + } + + // 删除数据权限 + permissionMapper.deleteBatchIds(ids); + } + + @Override + public void deleteSelfPermission(Long id, Long userId) { + // 校验数据存在且是自己 + CrmPermissionDO permission = permissionMapper.selectByIdAndUserId(id, userId); + if (permission == null) { + throw exception(CRM_PERMISSION_NOT_EXISTS); + } + // 校验是否是负责人 + if (CrmPermissionLevelEnum.isOwner(permission.getLevel())) { + throw exception(CRM_PERMISSION_DELETE_SELF_PERMISSION_FAIL_EXIST_OWNER); + } + + // 删除 + permissionMapper.deleteById(id); + } + + @Override + public List getPermissionListByBiz(Integer bizType, Long bizId) { + return permissionMapper.selectByBizTypeAndBizId(bizType, bizId); + } + + @Override + public List getPermissionListByBiz(Integer bizType, Collection bizIds) { + return permissionMapper.selectByBizTypeAndBizIds(bizType, bizIds); + } + + @Override + public List getPermissionListByBizTypeAndUserId(Integer bizType, Long userId) { + return permissionMapper.selectListByBizTypeAndUserId(bizType, userId); + } + + @Override + public boolean hasPermission(Integer bizType, Long bizId, Long userId, CrmPermissionLevelEnum level) { + List permissionList = permissionMapper.selectByBizTypeAndBizId(bizType, bizId); + return anyMatch(permissionList, permission -> + ObjUtil.equal(permission.getUserId(), userId) && ObjUtil.equal(permission.getLevel(), level.getLevel())); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/bo/CrmPermissionTransferReqBO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/bo/CrmPermissionTransferReqBO.java new file mode 100644 index 0000000000..fb7813a0f0 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/permission/bo/CrmPermissionTransferReqBO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.crm.service.permission.bo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; + +/** + * 数据权限转移 Request BO + * + * @author HUIHUI + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CrmPermissionTransferReqBO { + + /** + * 当前登录用户编号 + */ + @NotNull(message = "用户编号不能为空") + private Long userId; + + /** + * CRM 类型 + */ + @NotNull(message = "Crm 类型不能为空") + @InEnum(CrmBizTypeEnum.class) + private Integer bizType; + /** + * 数据编号 + */ + @NotNull(message = "CRM 数据编号不能为空") + private Long bizId; + + /** + * 新负责人的用户编号 + */ + @NotNull(message = "新负责人的用户编号不能为空") + private Long newOwnerUserId; + + /** + * 老负责人加入团队后的权限级别。如果 null 说明移除 + * + * 关联 {@link CrmPermissionLevelEnum} + */ + private Integer oldOwnerPermissionLevel; + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductCategoryService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductCategoryService.java new file mode 100644 index 0000000000..c82f97c4a7 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductCategoryService.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.crm.service.product; + +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.category.CrmProductCategoryCreateReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.category.CrmProductCategoryListReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductCategoryDO; + +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * CRM 产品分类 Service 接口 + * + * @author ZanGe丶 + */ +public interface CrmProductCategoryService { + + /** + * 创建产品分类 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createProductCategory(@Valid CrmProductCategoryCreateReqVO createReqVO); + + /** + * 更新产品分类 + * + * @param updateReqVO 更新信息 + */ + void updateProductCategory(@Valid CrmProductCategoryCreateReqVO updateReqVO); + + /** + * 删除产品分类 + * + * @param id 编号 + */ + void deleteProductCategory(Long id); + + /** + * 获得产品分类 + * + * @param id 编号 + * @return 产品分类 + */ + CrmProductCategoryDO getProductCategory(Long id); + + /** + * 获得产品分类列表 + * + * @param listReqVO 列表请求 + * @return 产品分类列表 + */ + List getProductCategoryList(CrmProductCategoryListReqVO listReqVO); + + /** + * 获得产品分类列表 + * + * @param ids 编号数组 + * @return 产品分类列表 + */ + List getProductCategoryList(Collection ids); + + /** + * 获得产品分类 Map + * + * @param ids 编号数组 + * @return 产品分类 Map + */ + default Map getProductCategoryMap(Collection ids) { + return convertMap(getProductCategoryList(ids), CrmProductCategoryDO::getId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductService.java new file mode 100644 index 0000000000..d2f6aa580a --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductService.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.crm.service.product; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; + +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * CRM 产品 Service 接口 + * + * @author ZanGe丶 + */ +public interface CrmProductService { + + /** + * 创建产品 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createProduct(@Valid CrmProductSaveReqVO createReqVO); + + /** + * 更新产品 + * + * @param updateReqVO 更新信息 + */ + void updateProduct(@Valid CrmProductSaveReqVO updateReqVO); + + /** + * 删除产品 + * + * @param id 编号 + */ + void deleteProduct(Long id); + + /** + * 获得产品 + * + * @param id 编号 + * @return 产品 + */ + CrmProductDO getProduct(Long id); + + /** + * 获得产品列表 + * + * @param ids 编号 + * @return 产品列表 + */ + List getProductList(Collection ids); + + /** + * 获得产品 Map + * + * @param ids 编号 + * @return 产品 Map + */ + default Map getProductMap(Collection ids) { + return convertMap(getProductList(ids), CrmProductDO::getId); + } + + /** + * 获得产品分页 + * + * @param pageReqVO 分页查询 + * @return 产品分页 + */ + PageResult getProductPage(CrmProductPageReqVO pageReqVO); + + /** + * 获得产品数量 + * + * @param categoryId 分类编号 + * @return 产品 + */ + Long getProductByCategoryId(Long categoryId); + + /** + * 获得指定状态的产品列表 + * + * @param status 状态 + * @return 产品列表 + */ + List getProductListByStatus(Integer status); + + /** + * 校验产品们的有效性 + * + * @param ids 编号数组 + * @return 产品列表 + */ + List validProductList(Collection ids); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductServiceImpl.java new file mode 100644 index 0000000000..5291b3e1d3 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/product/CrmProductServiceImpl.java @@ -0,0 +1,183 @@ +package cn.iocoder.yudao.module.crm.service.product; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.product.vo.product.CrmProductSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductCategoryDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.product.CrmProductDO; +import cn.iocoder.yudao.module.crm.dal.mysql.product.CrmProductMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + + +/** + * CRM 产品 Service 实现类 + * + * @author ZanGe丶 + */ +@Service +@Validated +public class CrmProductServiceImpl implements CrmProductService { + + @Resource(name = "crmProductMapper") + private CrmProductMapper productMapper; + + @Resource + private CrmProductCategoryService productCategoryService; + @Resource + private CrmPermissionService permissionService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_PRODUCT_TYPE, subType = CRM_PRODUCT_CREATE_SUB_TYPE, bizNo = "{{#productId}}", + success = CRM_PRODUCT_CREATE_SUCCESS) + public Long createProduct(CrmProductSaveReqVO createReqVO) { + // 1. 校验产品 + adminUserApi.validateUserList(Collections.singleton(createReqVO.getOwnerUserId())); + validateProductNoDuplicate(null, createReqVO.getNo()); + validateProductCategoryExists(createReqVO.getCategoryId()); + + // 2. 插入产品 + CrmProductDO product = BeanUtils.toBean(createReqVO, CrmProductDO.class); + productMapper.insert(product); + + // 3. 插入数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(product.getOwnerUserId()) + .setBizType(CrmBizTypeEnum.CRM_PRODUCT.getType()).setBizId(product.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("productId", product.getId()); + return product.getId(); + } + + @Override + @LogRecord(type = CRM_PRODUCT_TYPE, subType = CRM_PRODUCT_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_PRODUCT_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_PRODUCT, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateProduct(CrmProductSaveReqVO updateReqVO) { + // 1. 校验产品 + updateReqVO.setOwnerUserId(null); // 不修改负责人 + CrmProductDO crmProductDO = validateProductExists(updateReqVO.getId()); + validateProductNoDuplicate(updateReqVO.getId(), updateReqVO.getNo()); + validateProductCategoryExists(updateReqVO.getCategoryId()); + + // 2. 更新产品 + CrmProductDO updateObj = BeanUtils.toBean(updateReqVO, CrmProductDO.class); + productMapper.updateById(updateObj); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(crmProductDO, CrmProductSaveReqVO.class)); + } + + private CrmProductDO validateProductExists(Long id) { + CrmProductDO product = productMapper.selectById(id); + if (product == null) { + throw exception(PRODUCT_NOT_EXISTS); + } + return product; + } + + private void validateProductNoDuplicate(Long id, String no) { + CrmProductDO product = productMapper.selectByNo(no); + if (product == null + || product.getId().equals(id)) { + return; + } + throw exception(PRODUCT_NO_EXISTS); + } + + private void validateProductCategoryExists(Long categoryId) { + CrmProductCategoryDO category = productCategoryService.getProductCategory(categoryId); + if (category == null) { + throw exception(PRODUCT_CATEGORY_NOT_EXISTS); + } + } + + @Override + @LogRecord(type = CRM_PRODUCT_TYPE, subType = CRM_PRODUCT_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_PRODUCT_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_PRODUCT, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteProduct(Long id) { + // 校验存在 + validateProductExists(id); + // 删除 + productMapper.deleteById(id); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_PRODUCT, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmProductDO getProduct(Long id) { + return productMapper.selectById(id); + } + + @Override + public PageResult getProductPage(CrmProductPageReqVO pageReqVO) { + return productMapper.selectPage(pageReqVO); + } + + @Override + public Long getProductByCategoryId(Long categoryId) { + return productMapper.selectCountByCategoryId(categoryId); + } + + @Override + public List getProductListByStatus(Integer status) { + return productMapper.selectListByStatus(status); + } + + @Override + public List validProductList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + List list = productMapper.selectBatchIds(ids); + Map productMap = convertMap(list, CrmProductDO::getId); + for (Long id : ids) { + CrmProductDO product = productMap.get(id); + if (productMap.get(id) == null) { + throw exception(PRODUCT_NOT_EXISTS); + } + if (CommonStatusEnum.isDisable(product.getStatus())) { + throw exception(PRODUCT_NOT_ENABLE, product.getName()); + } + } + return list; + } + + @Override + public List getProductList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return productMapper.selectBatchIds(ids); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanService.java new file mode 100644 index 0000000000..e81f1e5e98 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.crm.service.receivable; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; + +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; + +/** + * CRM 回款计划 Service 接口 + * + * @author 芋道源码 + */ +public interface CrmReceivablePlanService { + + /** + * 创建回款计划 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createReceivablePlan(@Valid CrmReceivablePlanSaveReqVO createReqVO); + + /** + * 更新回款计划 + * + * @param updateReqVO 更新信息 + */ + void updateReceivablePlan(@Valid CrmReceivablePlanSaveReqVO updateReqVO); + + /** + * 更新回款计划关联的回款编号 + * + * @param id 编号 + * @param receivableId 回款编号 + */ + void updateReceivablePlanReceivableId(Long id, Long receivableId); + + /** + * 删除回款计划 + * + * @param id 编号 + */ + void deleteReceivablePlan(Long id); + + /** + * 获得回款计划 + * + * @param id 编号 + * @return 回款计划 + */ + CrmReceivablePlanDO getReceivablePlan(Long id); + + /** + * 获得回款计划列表 + * + * @param ids 编号 + * @return 回款计划列表 + */ + List getReceivablePlanList(Collection ids); + + /** + * 获得回款计划分页 + * + * 数据权限:基于 {@link CrmReceivablePlanDO} 读取 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 回款计划分页 + */ + PageResult getReceivablePlanPage(CrmReceivablePlanPageReqVO pageReqVO, Long userId); + + /** + * 获得回款计划分页,基于指定客户 + * + * 数据权限:基于 {@link CrmCustomerDO} 读取 + * + * @param pageReqVO 分页查询 + * @return 回款计划分页 + */ + PageResult getReceivablePlanPageByCustomerId(CrmReceivablePlanPageReqVO pageReqVO); + + /** + * 获得待回款提醒数量 + * + * @param userId 用户编号 + * @return 提醒数量 + */ + Long getReceivablePlanRemindCount(Long userId); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanServiceImpl.java new file mode 100644 index 0000000000..985003433e --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivablePlanServiceImpl.java @@ -0,0 +1,191 @@ +package cn.iocoder.yudao.module.crm.service.receivable; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanPageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.plan.CrmReceivablePlanSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; +import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.RECEIVABLE_PLAN_NOT_EXISTS; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.RECEIVABLE_PLAN_UPDATE_FAIL; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; + +/** + * 回款计划 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class CrmReceivablePlanServiceImpl implements CrmReceivablePlanService { + + @Resource + private CrmReceivablePlanMapper receivablePlanMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private CrmReceivableService receivableService; + @Resource + private CrmContractService contractService; + @Resource + private CrmPermissionService permissionService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_PLAN_TYPE, subType = CRM_RECEIVABLE_PLAN_CREATE_SUB_TYPE, bizNo = "{{#receivablePlan.id}}", + success = CRM_RECEIVABLE_PLAN_CREATE_SUCCESS) + public Long createReceivablePlan(CrmReceivablePlanSaveReqVO createReqVO) { + // 1. 校验关联数据是否存在 + validateRelationDataExists(createReqVO); + + // 2. 插入回款计划 + CrmReceivablePlanDO maxPeriodReceivablePlan = receivablePlanMapper.selectMaxPeriodByContractId(createReqVO.getContractId()); + int period = maxPeriodReceivablePlan == null ? 1 : maxPeriodReceivablePlan.getPeriod() + 1; + CrmReceivablePlanDO receivablePlan = BeanUtils.toBean(createReqVO, CrmReceivablePlanDO.class).setPeriod(period); + if (createReqVO.getReturnTime() != null && createReqVO.getRemindDays() != null) { + receivablePlan.setRemindTime(createReqVO.getReturnTime().minusDays(createReqVO.getRemindDays())); + } + receivablePlanMapper.insert(receivablePlan); + + // 3. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(createReqVO.getOwnerUserId()) + .setBizType(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType()).setBizId(receivablePlan.getId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("receivablePlan", receivablePlan); + return receivablePlan.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_PLAN_TYPE, subType = CRM_RECEIVABLE_PLAN_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_RECEIVABLE_PLAN_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE_PLAN, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateReceivablePlan(CrmReceivablePlanSaveReqVO updateReqVO) { + updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null); // 防止修改这些字段 + // 1.1 校验存在 + validateRelationDataExists(updateReqVO); + // 1.2 校验关联数据是否存在 + CrmReceivablePlanDO oldReceivablePlan = validateReceivablePlanExists(updateReqVO.getId()); + // 1.3 如果已经有对应的回款,则不允许编辑 + if (Objects.nonNull(oldReceivablePlan.getReceivableId())) { + throw exception(RECEIVABLE_PLAN_UPDATE_FAIL); + } + + // 2. 更新回款计划 + CrmReceivablePlanDO updateObj = BeanUtils.toBean(updateReqVO, CrmReceivablePlanDO.class); + if (updateReqVO.getReturnTime() != null && updateReqVO.getRemindDays() != null) { + updateObj.setRemindTime(updateReqVO.getReturnTime().minusDays(updateReqVO.getRemindDays())); + } + receivablePlanMapper.updateById(updateObj); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldReceivablePlan, CrmReceivablePlanSaveReqVO.class)); + LogRecordContext.putVariable("receivablePlan", oldReceivablePlan); + } + + private void validateRelationDataExists(CrmReceivablePlanSaveReqVO reqVO) { + // 校验负责人存在 + if (reqVO.getOwnerUserId() != null) { + adminUserApi.validateUser(reqVO.getOwnerUserId()); + } + // 校验合同存在 + if (reqVO.getContractId() != null) { + CrmContractDO contract = contractService.getContract(reqVO.getContractId()); + reqVO.setCustomerId(contract.getCustomerId()); + } + } + + @Override + public void updateReceivablePlanReceivableId(Long id, Long receivableId) { + // 校验存在 + validateReceivablePlanExists(id); + // 更新回款计划 + receivablePlanMapper.updateById(new CrmReceivablePlanDO().setId(id).setReceivableId(receivableId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_PLAN_TYPE, subType = CRM_RECEIVABLE_PLAN_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_RECEIVABLE_PLAN_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE_PLAN, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteReceivablePlan(Long id) { + // 1. 校验存在 + CrmReceivablePlanDO receivablePlan = validateReceivablePlanExists(id); + + // 2. 删除 + receivablePlanMapper.deleteById(id); + // 3. 删除数据权限 + permissionService.deletePermission(CrmBizTypeEnum.CRM_CUSTOMER.getType(), id); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("receivablePlan", receivablePlan); + } + + private CrmReceivablePlanDO validateReceivablePlanExists(Long id) { + CrmReceivablePlanDO receivablePlan = receivablePlanMapper.selectById(id); + if (receivablePlan == null) { + throw exception(RECEIVABLE_PLAN_NOT_EXISTS); + } + return receivablePlan; + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE_PLAN, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmReceivablePlanDO getReceivablePlan(Long id) { + return receivablePlanMapper.selectById(id); + } + + @Override + public List getReceivablePlanList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return receivablePlanMapper.selectBatchIds(ids); + } + + @Override + public PageResult getReceivablePlanPage(CrmReceivablePlanPageReqVO pageReqVO, Long userId) { + return receivablePlanMapper.selectPage(pageReqVO, userId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ) + public PageResult getReceivablePlanPageByCustomerId(CrmReceivablePlanPageReqVO pageReqVO) { + return receivablePlanMapper.selectPageByCustomerId(pageReqVO); + } + + @Override + public Long getReceivablePlanRemindCount(Long userId) { + return receivablePlanMapper.selectReceivablePlanCountByRemind(userId); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java new file mode 100644 index 0000000000..ec5f7008e3 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.crm.service.receivable; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; + +import javax.validation.Valid; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * CRM 回款 Service 接口 + * + * @author 赤焰 + */ +public interface CrmReceivableService { + + /** + * 创建回款 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createReceivable(@Valid CrmReceivableSaveReqVO createReqVO); + + /** + * 更新回款 + * + * @param updateReqVO 更新信息 + */ + void updateReceivable(@Valid CrmReceivableSaveReqVO updateReqVO); + + /** + * 更新回款流程审批结果 + * + * @param id 回款编号 + * @param bpmResult BPM 审批结果 + */ + void updateReceivableAuditStatus(Long id, Integer bpmResult); + + /** + * 删除回款 + * + * @param id 编号 + */ + void deleteReceivable(Long id); + + /** + * 发起回款审批流程 + * + * @param id 回款编号 + * @param userId 用户编号 + */ + void submitReceivable(Long id, Long userId); + + /** + * 获得回款 + * + * @param id 编号 + * @return 回款 + */ + CrmReceivableDO getReceivable(Long id); + + /** + * 获得回款列表 + * + * @param ids 编号 + * @return 回款列表 + */ + List getReceivableList(Collection ids); + + /** + * 获得回款 Map + * + * @param ids 编号 + * @return 回款 Map + */ + default Map getReceivableMap(Collection ids) { + return convertMap(getReceivableList(ids), CrmReceivableDO::getId); + } + + /** + * 获得回款分页 + * + * 数据权限:基于 {@link CrmReceivableDO} 读取 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 回款分页 + */ + PageResult getReceivablePage(CrmReceivablePageReqVO pageReqVO, Long userId); + + /** + * 获得回款分页,基于指定客户 + * + * 数据权限:基于 {@link CrmCustomerDO} 读取 + * + * @param pageReqVO 分页查询 + * @return 回款分页 + */ + PageResult getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO); + + /** + * 获得待审核回款数量 + * + * @param userId 用户编号 + * @return 待审批数量 + */ + Long getAuditReceivableCount(Long userId); + + /** + * 获得合同已回款金额 Map + * + * @param contractIds 合同编号 + * @return 回款金额 Map + */ + Map getReceivablePriceMapByContractId(Collection contractIds); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java new file mode 100644 index 0000000000..3674bed1e5 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java @@ -0,0 +1,292 @@ +package cn.iocoder.yudao.module.crm.service.receivable; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO; +import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO; +import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; +import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; +import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper; +import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission; +import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; +import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService; +import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.mzt.logapi.context.LogRecordContext; +import com.mzt.logapi.service.impl.DiffParseFunction; +import com.mzt.logapi.starter.annotation.LogRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*; +import static cn.iocoder.yudao.module.crm.util.CrmAuditStatusUtils.convertBpmResultToAuditStatus; + +/** + * CRM 回款 Service 实现类 + * + * @author 赤焰 + */ +@Service +@Validated +@Slf4j +public class CrmReceivableServiceImpl implements CrmReceivableService { + + /** + * BPM 合同审批流程标识 + */ + public static final String BPM_PROCESS_DEFINITION_KEY = "crm-receivable-audit"; + + @Resource + private CrmReceivableMapper receivableMapper; + + @Resource + private CrmNoRedisDAO noRedisDAO; + + @Resource + private CrmContractService contractService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private CrmReceivablePlanService receivablePlanService; + @Resource + private CrmPermissionService permissionService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi bpmProcessInstanceApi; + + // TODO @puhui999:操作日志没记录上 + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_CREATE_SUB_TYPE, bizNo = "{{#receivable.id}}", + success = CRM_RECEIVABLE_CREATE_SUCCESS) + public Long createReceivable(CrmReceivableSaveReqVO createReqVO) { + // 1.1 校验可回款金额超过上限 + validateReceivablePriceExceedsLimit(createReqVO); + // 1.2 校验关联数据存在 + validateRelationDataExists(createReqVO); + // 1.3 生成回款编号 + String no = noRedisDAO.generate(CrmNoRedisDAO.RECEIVABLE_PREFIX); + if (receivableMapper.selectByNo(no) != null) { + throw exception(RECEIVABLE_NO_EXISTS); + } + + // 2.1 插入回款 + CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class) + .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus()); + receivableMapper.insert(receivable); + // 2.2 + + // 3. 创建数据权限 + permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_RECEIVABLE.getType()) + .setBizId(receivable.getId()).setUserId(createReqVO.getOwnerUserId()) + .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人 + + // 4. 更新关联的回款计划 + if (createReqVO.getPlanId() != null) { + receivablePlanService.updateReceivablePlanReceivableId(receivable.getPlanId(), receivable.getId()); + } + + // 5. 记录操作日志上下文 + LogRecordContext.putVariable("receivable", receivable); + return receivable.getId(); + } + + private void validateReceivablePriceExceedsLimit(CrmReceivableSaveReqVO reqVO) { + // 1. 计算剩余可退款金额,不包括 reqVO 自身 + CrmContractDO contract = contractService.validateContract(reqVO.getContractId()); + List receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(), + Arrays.asList(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus())); + if (reqVO.getId() != null) { + receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId())); + } + BigDecimal notReceivablePrice = contract.getTotalPrice().subtract( + CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice, BigDecimal::add, BigDecimal.ZERO)); + // 2. 校验金额是否超过 + if (reqVO.getPrice().compareTo(notReceivablePrice) > 0) { + throw exception(RECEIVABLE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notReceivablePrice); + } + } + + private void validateRelationDataExists(CrmReceivableSaveReqVO reqVO) { + if (reqVO.getOwnerUserId() != null) { + adminUserApi.validateUser(reqVO.getOwnerUserId()); // 校验负责人存在 + } + if (reqVO.getContractId() != null) { + CrmContractDO contract = contractService.validateContract(reqVO.getContractId()); + if (ObjectUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { + throw exception(RECEIVABLE_CREATE_FAIL_CONTRACT_NOT_APPROVE); + } + reqVO.setCustomerId(contract.getCustomerId()); // 设置客户编号 + } + if (reqVO.getPlanId() != null) { + CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(reqVO.getPlanId()); + if (receivablePlan == null) { + throw exception(RECEIVABLE_PLAN_NOT_EXISTS); + } + if (receivablePlan.getReceivableId() != null) { + throw exception(RECEIVABLE_PLAN_EXISTS_RECEIVABLE); + } + } + } + + // TODO @puhui999:操作日志没记录上 + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", + success = CRM_RECEIVABLE_UPDATE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) + public void updateReceivable(CrmReceivableSaveReqVO updateReqVO) { + Assert.notNull(updateReqVO.getId(), "回款编号不能为空"); + updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // 不允许修改的字段 + // 1.1 校验可回款金额超过上限 + validateReceivablePriceExceedsLimit(updateReqVO); + // 1.2 校验存在 + CrmReceivableDO receivable = validateReceivableExists(updateReqVO.getId()); + // 1.3 只有草稿、审批中,可以编辑; + if (!ObjectUtils.equalsAny(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(), + CrmAuditStatusEnum.PROCESS.getStatus())) { + throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED); + } + + // 2. 更新回款 + CrmReceivableDO updateObj = BeanUtils.toBean(updateReqVO, CrmReceivableDO.class); + receivableMapper.updateById(updateObj); + + // 3. 记录操作日志上下文 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(receivable, CrmReceivableSaveReqVO.class)); + LogRecordContext.putVariable("receivable", receivable); + } + + @Override + public void updateReceivableAuditStatus(Long id, Integer bpmResult) { + // 1.1 校验存在 + CrmReceivableDO receivable = validateReceivableExists(id); + // 1.2 只有审批中,可以更新审批结果 + if (ObjUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) { + log.error("[updateReceivableAuditStatus][receivable({}) 不处于审批中,无法更新审批结果({})]", + receivable.getId(), bpmResult); + throw exception(RECEIVABLE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS); + } + + // 2. 更新回款审批状态 + Integer auditStatus = convertBpmResultToAuditStatus(bpmResult); + receivableMapper.updateById(new CrmReceivableDO().setId(id).setAuditStatus(auditStatus)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_DELETE_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_RECEIVABLE_DELETE_SUCCESS) + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) + public void deleteReceivable(Long id) { + // 1.1 校验存在 + CrmReceivableDO receivable = validateReceivableExists(id); + // 1.2 如果被 CrmReceivablePlanDO 所使用,则不允许删除 + if (receivable.getPlanId() != null && receivablePlanService.getReceivablePlan(receivable.getPlanId()) != null) { + throw exception(RECEIVABLE_DELETE_FAIL); + } + // TODO @puhui999:审批通过时,不允许删除; + + // 2. 删除 + receivableMapper.deleteById(id); + // 3. 删除数据权限 + permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE.getType(), id); + + // 4. 记录操作日志上下文 + LogRecordContext.putVariable("receivable", receivable); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_SUBMIT_SUB_TYPE, bizNo = "{{#id}}", + success = CRM_RECEIVABLE_SUBMIT_SUCCESS) + public void submitReceivable(Long id, Long userId) { + // 1. 校验回款是否在审批 + CrmReceivableDO receivable = validateReceivableExists(id); + if (ObjUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) { + throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT); + } + + // 2. 创建回款审批流程实例 + String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id))); + + // 3. 更新回款工作流编号 + receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId(processInstanceId) + .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus())); + + // 4. 记录日志 + LogRecordContext.putVariable("receivableNo", receivable.getNo()); + } + + private CrmReceivableDO validateReceivableExists(Long id) { + CrmReceivableDO receivable = receivableMapper.selectById(id); + if (receivable == null) { + throw exception(RECEIVABLE_NOT_EXISTS); + } + return receivable; + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.READ) + public CrmReceivableDO getReceivable(Long id) { + return receivableMapper.selectById(id); + } + + @Override + public List getReceivableList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return ListUtil.empty(); + } + return receivableMapper.selectBatchIds(ids); + } + + @Override + public PageResult getReceivablePage(CrmReceivablePageReqVO pageReqVO, Long userId) { + return receivableMapper.selectPage(pageReqVO, userId); + } + + @Override + @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ) + public PageResult getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO) { + return receivableMapper.selectPageByCustomerId(pageReqVO); + } + + @Override + public Long getAuditReceivableCount(Long userId) { + return receivableMapper.selectCountByAudit(userId); + } + + @Override + public Map getReceivablePriceMapByContractId(Collection contractIds) { + return receivableMapper.selectReceivablePriceMapByContractId(contractIds); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/listener/CrmReceivableResultListener.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/listener/CrmReceivableResultListener.java new file mode 100644 index 0000000000..41a82d0080 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/listener/CrmReceivableResultListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.crm.service.receivable.listener; + +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEvent; +import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceResultEventListener; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService; +import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableServiceImpl; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 回款审批的结果的监听器实现类 + * + * @author HUIHUI + */ +@Component +public class CrmReceivableResultListener extends BpmProcessInstanceResultEventListener { + + @Resource + private CrmReceivableService receivableService; + + @Override + public String getProcessDefinitionKey() { + return CrmReceivableServiceImpl.BPM_PROCESS_DEFINITION_KEY; + } + + @Override + public void onEvent(BpmProcessInstanceResultEvent event) { + receivableService.updateReceivableAuditStatus(Long.parseLong(event.getBusinessKey()), event.getResult()); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingService.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingService.java new file mode 100644 index 0000000000..c9455708c1 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingService.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.crm.service.statistics; + + +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRanKRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRankReqVO; + +import java.util.List; + +/** + * CRM 排行榜统计 Service 接口 + * + * @author anhaohao + */ +public interface CrmStatisticsRankingService { + + /** + * 获得合同金额排行榜 + * + * @param rankReqVO 排行参数 + * @return 合同金额排行榜 + */ + List getContractPriceRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得回款金额排行榜 + * + * @param rankReqVO 排行参数 + * @return 回款金额排行榜 + */ + List getReceivablePriceRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得签约合同数量排行榜 + * + * @param rankReqVO 排行参数 + * @return 签约合同数量排行榜 + */ + List getContractCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得产品销量排行榜 + * + * @param rankReqVO 排行参数 + * @return 产品销量排行榜 + */ + List getProductSalesRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得新增客户数排行榜 + * + * @param rankReqVO 排行参数 + * @return 新增客户数排行榜 + */ + List getCustomerCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得联系人数量排行榜 + * + * @param rankReqVO 排行参数 + * @return 联系人数量排行榜 + */ + List getContactsCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得跟进次数排行榜 + * + * @param rankReqVO 排行参数 + * @return 跟进次数排行榜 + */ + List getFollowCountRank(CrmStatisticsRankReqVO rankReqVO); + + /** + * 获得跟进客户数排行榜 + * + * @param rankReqVO 排行参数 + * @return 跟进客户数排行榜 + */ + List getFollowCustomerCountRank(CrmStatisticsRankReqVO rankReqVO); + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingServiceImpl.java new file mode 100644 index 0000000000..d54324ab11 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsRankingServiceImpl.java @@ -0,0 +1,134 @@ +package cn.iocoder.yudao.module.crm.service.statistics; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRanKRespVO; +import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.CrmStatisticsRankReqVO; +import cn.iocoder.yudao.module.crm.dal.mysql.statistics.CrmStatisticsRankingMapper; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +/** + * CRM 排行榜统计 Service 实现类 + * + * @author anhaohao + */ +@Service +@Validated +public class CrmStatisticsRankingServiceImpl implements CrmStatisticsRankingService { + + @Resource + private CrmStatisticsRankingMapper rankMapper; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @Override + public List getContractPriceRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectContractPriceRank); + } + + @Override + public List getReceivablePriceRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectReceivablePriceRank); + } + + @Override + public List getContractCountRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectContractCountRank); + } + + @Override + public List getProductSalesRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectProductSalesRank); + } + + @Override + public List getCustomerCountRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectCustomerCountRank); + } + + @Override + public List getContactsCountRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectContactsCountRank); + } + + @Override + public List getFollowCountRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectFollowCountRank); + } + + @Override + public List getFollowCustomerCountRank(CrmStatisticsRankReqVO rankReqVO) { + return getRank(rankReqVO, rankMapper::selectFollowCustomerCountRank); + } + + /** + * 获得排行版数据 + * + * @param rankReqVO 参数 + * @param rankFunction 排行榜方法 + * @return 排行版数据 + */ + private List getRank(CrmStatisticsRankReqVO rankReqVO, Function> rankFunction) { + // 1. 获得用户编号数组 + rankReqVO.setUserIds(getUserIds(rankReqVO.getDeptId())); + if (CollUtil.isEmpty(rankReqVO.getUserIds())) { + return Collections.emptyList(); + } + // 2. 获得排行数据 + List ranks = rankFunction.apply(rankReqVO); + if (CollUtil.isEmpty(ranks)) { + return Collections.emptyList(); + } + ranks.sort(Comparator.comparing(CrmStatisticsRanKRespVO::getCount).reversed()); + // 3. 拼接用户信息 + appendUserInfo(ranks); + return ranks; + } + + /** + * 拼接用户信息(昵称、部门) + * + * @param ranks 排行榜数据 + */ + private void appendUserInfo(List ranks) { + Map userMap = adminUserApi.getUserMap(convertSet(ranks, CrmStatisticsRanKRespVO::getOwnerUserId)); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + ranks.forEach(rank -> MapUtils.findAndThen(userMap, rank.getOwnerUserId(), user -> { + rank.setNickname(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> rank.setDeptName(dept.getName())); + })); + } + + /** + * 获得部门下的用户编号数组,包括子部门的 + * + * @param deptId 部门编号 + * @return 用户编号数组 + */ + public List getUserIds(Long deptId) { + // 1. 获得部门列表 + List deptIds = convertList(deptApi.getChildDeptList(deptId), DeptRespDTO::getId); + deptIds.add(deptId); + // 2. 获得用户编号 + return convertList(adminUserApi.getUserListByDeptIds(deptIds), AdminUserRespDTO::getId); + } + +} \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmAuditStatusUtils.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmAuditStatusUtils.java new file mode 100644 index 0000000000..665e98fbed --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmAuditStatusUtils.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.crm.util; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceResultEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; + +/** + * CRM 流程工具类 + * + * @author HUIHUI + */ +public class CrmAuditStatusUtils { + + /** + * BPM 审批结果转换 + * + * @param bpmResult BPM 审批结果 + */ + public static Integer convertBpmResultToAuditStatus(Integer bpmResult) { + Integer auditStatus = BpmProcessInstanceResultEnum.APPROVE.getResult().equals(bpmResult) ? CrmAuditStatusEnum.APPROVE.getStatus() + : BpmProcessInstanceResultEnum.REJECT.getResult().equals(bpmResult) ? CrmAuditStatusEnum.REJECT.getStatus() + : BpmProcessInstanceResultEnum.CANCEL.getResult().equals(bpmResult) ? BpmProcessInstanceResultEnum.CANCEL.getResult() : null; + Assert.notNull(auditStatus, "BPM 审批结果({}) 转换失败", bpmResult); + return auditStatus; + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java new file mode 100644 index 0000000000..587e9ab560 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.crm.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.crm.dal.dataobject.permission.CrmPermissionDO; +import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; +import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; +import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.github.yulichang.autoconfigure.MybatisPlusJoinProperties; +import com.github.yulichang.wrapper.MPJLambdaWrapper; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 数据权限工具类 + * + * @author HUIHUI + */ +public class CrmPermissionUtils { + + /** + * 校验用户是否是 CRM 管理员 + * + * @return 是/否 + */ + public static boolean isCrmAdmin() { + PermissionApi permissionApi = SpringUtil.getBean(PermissionApi.class); + return permissionApi.hasAnyRoles(getLoginUserId(), RoleCodeEnum.CRM_ADMIN.getCode()); + } + + /** + * 构造 CRM 数据类型数据分页查询条件 + * + * @param query 连表查询对象 + * @param bizType 数据类型 {@link CrmBizTypeEnum} + * @param bizId 数据编号 + * @param userId 用户编号 + * @param sceneType 场景类型 + * @param pool 公海 + */ + public static , S> void appendPermissionCondition(T query, Integer bizType, SFunction bizId, + Long userId, Integer sceneType, Boolean pool) { + MybatisPlusJoinProperties mybatisPlusJoinProperties = SpringUtil.getBean(MybatisPlusJoinProperties.class); + final String ownerUserIdField = mybatisPlusJoinProperties.getTableAlias() + ".owner_user_id"; + // 1. 构建数据权限连表条件 + if (!CrmPermissionUtils.isCrmAdmin() && ObjUtil.notEqual(pool, Boolean.TRUE)) { // 管理员,公海不需要数据权限 + query.innerJoin(CrmPermissionDO.class, on -> on.eq(CrmPermissionDO::getBizType, bizType) + .eq(CrmPermissionDO::getBizId, bizId) // 只能使用 SFunction 如果传 id 解析出来的 sql 不对 + .eq(CrmPermissionDO::getUserId, userId)); + } + // 2.1 场景一:我负责的数据 + if (CrmSceneTypeEnum.isOwner(sceneType)) { + query.eq(ownerUserIdField, userId); + } + // 2.2 场景二:我参与的数据 + if (CrmSceneTypeEnum.isInvolved(sceneType)) { + query.ne(ownerUserIdField, userId) + .in(CrmPermissionDO::getLevel, CrmPermissionLevelEnum.READ.getLevel(), CrmPermissionLevelEnum.WRITE.getLevel()); + } + // 2.3 场景三:下属负责的数据 + if (CrmSceneTypeEnum.isSubordinate(sceneType)) { + AdminUserApi adminUserApi = SpringUtil.getBean(AdminUserApi.class); + List subordinateUsers = adminUserApi.getUserListBySubordinate(userId); + if (CollUtil.isEmpty(subordinateUsers)) { + query.eq(ownerUserIdField, -1); // 不返回任何结果 + } else { + query.in(ownerUserIdField, convertSet(subordinateUsers, AdminUserRespDTO::getId)); + } + } + + // 3. 拼接公海的查询条件 + if (ObjUtil.equal(pool, Boolean.TRUE)) { // 情况一:公海 + query.isNull(ownerUserIdField); + } else { // 情况二:不是公海 + query.isNotNull(ownerUserIdField); + } + } + + /** + * 构造 CRM 数据类型批量数据查询条件 + * + * @param query 连表查询对象 + * @param bizType 数据类型 {@link CrmBizTypeEnum} + * @param bizIds 数据编号 + * @param userId 用户编号 + */ + public static > void appendPermissionCondition(T query, Integer bizType, Collection bizIds, Long userId) { + if (isCrmAdmin()) {// 管理员不需要数据权限 + return; + } + query.innerJoin(CrmPermissionDO.class, on -> + on.eq(CrmPermissionDO::getBizType, bizType).in(CrmPermissionDO::getBizId, bizIds) + .eq(CollUtil.isNotEmpty(bizIds), CrmPermissionDO::getUserId, userId)); + } + +} diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/bi/CrmBiRankingMapper.xml b/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/bi/CrmBiRankingMapper.xml new file mode 100644 index 0000000000..c193873ce0 --- /dev/null +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/bi/CrmBiRankingMapper.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/yudao-module-erp/yudao-module-erp-biz/pom.xml b/yudao-module-erp/yudao-module-erp-biz/pom.xml new file mode 100644 index 0000000000..53c9349506 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/pom.xml @@ -0,0 +1,73 @@ + + + + cn.iocoder.boot + yudao-module-erp + ${revision} + + 4.0.0 + yudao-module-erp-biz + + ${project.artifactId} + + erp 包下,企业资源管理(Enterprise Resource Planning)。 + 例如说:采购、销售、库存、财务、产品等等 + + + + + cn.iocoder.boot + yudao-module-system-api + ${revision} + + + cn.iocoder.boot + yudao-module-erp-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpPurchaseStatisticsController.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpPurchaseStatisticsController.java new file mode 100644 index 0000000000..be0258dabe --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpPurchaseStatisticsController.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchaseSummaryRespVO; +import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase.ErpPurchaseTimeSummaryRespVO; +import cn.iocoder.yudao.module.erp.service.statistics.ErpPurchaseStatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static cn.hutool.core.date.DatePattern.NORM_MONTH_PATTERN; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - ERP 采购统计") +@RestController +@RequestMapping("/erp/purchase-statistics") +@Validated +public class ErpPurchaseStatisticsController { + + @Resource + private ErpPurchaseStatisticsService purchaseStatisticsService; + + @GetMapping("/summary") + @Operation(summary = "获得采购统计") + @PreAuthorize("@ss.hasPermission('erp:statistics:query')") + public CommonResult getPurchaseSummary() { + LocalDateTime today = LocalDateTimeUtils.getToday(); + LocalDateTime yesterday = LocalDateTimeUtils.getYesterday(); + LocalDateTime month = LocalDateTimeUtils.getMonth(); + LocalDateTime year = LocalDateTimeUtils.getYear(); + ErpPurchaseSummaryRespVO summary = new ErpPurchaseSummaryRespVO() + .setTodayPrice(purchaseStatisticsService.getPurchasePrice(today, null)) + .setYesterdayPrice(purchaseStatisticsService.getPurchasePrice(yesterday, today)) + .setMonthPrice(purchaseStatisticsService.getPurchasePrice(month, null)) + .setYearPrice(purchaseStatisticsService.getPurchasePrice(year, null)); + return success(summary); + } + + @GetMapping("/time-summary") + @Operation(summary = "获得采购时间段统计") + @Parameter(name = "count", description = "时间段数量", example = "6") + @PreAuthorize("@ss.hasPermission('erp:statistics:query')") + public CommonResult> getPurchaseTimeSummary( + @RequestParam(value = "count", defaultValue = "6") Integer count) { + List summaryList = new ArrayList<>(); + for (int i = count - 1; i >= 0; i--) { + LocalDateTime startTime = LocalDateTimeUtils.beginOfMonth(LocalDateTime.now().minusMonths(i)); + LocalDateTime endTime = LocalDateTimeUtils.endOfMonth(startTime); + summaryList.add(new ErpPurchaseTimeSummaryRespVO() + .setTime(LocalDateTimeUtil.format(startTime, NORM_MONTH_PATTERN)) + .setPrice(purchaseStatisticsService.getPurchasePrice(startTime, endTime))); + } + return success(summaryList); + } + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.http b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.http new file mode 100644 index 0000000000..5f5cab1094 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.http @@ -0,0 +1,11 @@ +### 请求 /erp/sale-statistics/summary 接口 => 成功 +GET {{baseUrl}}/erp/sale-statistics/summary +Content-Type: application/json +tenant-id: {{adminTenentId}} +Authorization: Bearer {{token}} + +### 请求 /erp/sale-statistics/time-summary 接口 => 成功 +GET {{baseUrl}}/erp/sale-statistics/time-summary +Content-Type: application/json +tenant-id: {{adminTenentId}} +Authorization: Bearer {{token}} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.java new file mode 100644 index 0000000000..c43c971fe8 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/ErpSaleStatisticsController.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.sale.ErpSaleSummaryRespVO; +import cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.sale.ErpSaleTimeSummaryRespVO; +import cn.iocoder.yudao.module.erp.service.statistics.ErpSaleStatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static cn.hutool.core.date.DatePattern.NORM_MONTH_PATTERN; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - ERP 销售统计") +@RestController +@RequestMapping("/erp/sale-statistics") +@Validated +public class ErpSaleStatisticsController { + + @Resource + private ErpSaleStatisticsService saleStatisticsService; + + @GetMapping("/summary") + @Operation(summary = "获得销售统计") + @PreAuthorize("@ss.hasPermission('erp:statistics:query')") + public CommonResult getSaleSummary() { + LocalDateTime today = LocalDateTimeUtils.getToday(); + LocalDateTime yesterday = LocalDateTimeUtils.getYesterday(); + LocalDateTime month = LocalDateTimeUtils.getMonth(); + LocalDateTime year = LocalDateTimeUtils.getYear(); + ErpSaleSummaryRespVO summary = new ErpSaleSummaryRespVO() + .setTodayPrice(saleStatisticsService.getSalePrice(today, null)) + .setYesterdayPrice(saleStatisticsService.getSalePrice(yesterday, today)) + .setMonthPrice(saleStatisticsService.getSalePrice(month, null)) + .setYearPrice(saleStatisticsService.getSalePrice(year, null)); + return success(summary); + } + + @GetMapping("/time-summary") + @Operation(summary = "获得销售时间段统计") + @Parameter(name = "count", description = "时间段数量", example = "6") + @PreAuthorize("@ss.hasPermission('erp:statistics:query')") + public CommonResult> getSaleTimeSummary( + @RequestParam(value = "count", defaultValue = "6") Integer count) { + List summaryList = new ArrayList<>(); + for (int i = count - 1; i >= 0; i--) { + LocalDateTime startTime = LocalDateTimeUtils.beginOfMonth(LocalDateTime.now().minusMonths(i)); + LocalDateTime endTime = LocalDateTimeUtils.endOfMonth(startTime); + summaryList.add(new ErpSaleTimeSummaryRespVO() + .setTime(LocalDateTimeUtil.format(startTime, NORM_MONTH_PATTERN)) + .setPrice(saleStatisticsService.getSalePrice(startTime, endTime))); + } + return success(summaryList); + } + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseSummaryRespVO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseSummaryRespVO.java new file mode 100644 index 0000000000..22635e7457 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseSummaryRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - ERP 采购全局统计 Response VO") +@Data +public class ErpPurchaseSummaryRespVO { + + @Schema(description = "今日采购金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal todayPrice; + + @Schema(description = "昨日采购金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "888") + private BigDecimal yesterdayPrice; + + @Schema(description = "本月采购金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal monthPrice; + + @Schema(description = "今年采购金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "88888") + private BigDecimal yearPrice; + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseTimeSummaryRespVO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseTimeSummaryRespVO.java new file mode 100644 index 0000000000..15ae817de1 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/purchase/ErpPurchaseTimeSummaryRespVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.purchase; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - ERP 采购某个时间段的统计 Response VO") +@Data +public class ErpPurchaseTimeSummaryRespVO { + + @Schema(description = "时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2022-03") + private String time; + + @Schema(description = "采购金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal price; + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleSummaryRespVO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleSummaryRespVO.java new file mode 100644 index 0000000000..575d7da5e7 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleSummaryRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.sale; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - ERP 销售全局统计 Response VO") +@Data +public class ErpSaleSummaryRespVO { + + @Schema(description = "今日销售金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal todayPrice; + + @Schema(description = "昨日销售金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "888") + private BigDecimal yesterdayPrice; + + @Schema(description = "本月销售金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal monthPrice; + + @Schema(description = "今年销售金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "88888") + private BigDecimal yearPrice; + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleTimeSummaryRespVO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleTimeSummaryRespVO.java new file mode 100644 index 0000000000..48b9b6e9ac --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/statistics/vo/sale/ErpSaleTimeSummaryRespVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.erp.controller.admin.statistics.vo.sale; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - ERP 销售某个时间段的统计 Response VO") +@Data +public class ErpSaleTimeSummaryRespVO { + + @Schema(description = "时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2022-03") + private String time; + + @Schema(description = "销售金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private BigDecimal price; + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/ErpWarehouseController.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/ErpWarehouseController.java new file mode 100644 index 0000000000..488959bfa6 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/ErpWarehouseController.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.erp.controller.admin.stock; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.framework.operatelog.core.annotations.OperateLog; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehouseSaveReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehousePageReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehouseRespVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpWarehouseDO; +import cn.iocoder.yudao.module.erp.service.stock.ErpWarehouseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.IOException; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.operatelog.core.enums.OperateTypeEnum.EXPORT; + +@Tag(name = "管理后台 - ERP 仓库") +@RestController +@RequestMapping("/erp/warehouse") +@Validated +public class ErpWarehouseController { + + @Resource + private ErpWarehouseService warehouseService; + + @PostMapping("/create") + @Operation(summary = "创建仓库") + @PreAuthorize("@ss.hasPermission('erp:warehouse:create')") + public CommonResult createWarehouse(@Valid @RequestBody ErpWarehouseSaveReqVO createReqVO) { + return success(warehouseService.createWarehouse(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新仓库") + @PreAuthorize("@ss.hasPermission('erp:warehouse:update')") + public CommonResult updateWarehouse(@Valid @RequestBody ErpWarehouseSaveReqVO updateReqVO) { + warehouseService.updateWarehouse(updateReqVO); + return success(true); + } + + @PutMapping("/update-default-status") + @Operation(summary = "更新仓库默认状态") + @Parameters({ + @Parameter(name = "id", description = "编号", required = true), + @Parameter(name = "status", description = "状态", required = true) + }) + public CommonResult updateWarehouseDefaultStatus(@RequestParam("id") Long id, + @RequestParam("defaultStatus") Boolean defaultStatus) { + warehouseService.updateWarehouseDefaultStatus(id, defaultStatus); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除仓库") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('erp:warehouse:delete')") + public CommonResult deleteWarehouse(@RequestParam("id") Long id) { + warehouseService.deleteWarehouse(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得仓库") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('erp:warehouse:query')") + public CommonResult getWarehouse(@RequestParam("id") Long id) { + ErpWarehouseDO warehouse = warehouseService.getWarehouse(id); + return success(BeanUtils.toBean(warehouse, ErpWarehouseRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得仓库分页") + @PreAuthorize("@ss.hasPermission('erp:warehouse:query')") + public CommonResult> getWarehousePage(@Valid ErpWarehousePageReqVO pageReqVO) { + PageResult pageResult = warehouseService.getWarehousePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ErpWarehouseRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得仓库精简列表", description = "只包含被开启的仓库,主要用于前端的下拉选项") + public CommonResult> getWarehouseSimpleList() { + List list = warehouseService.getWarehouseListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, warehouse -> new ErpWarehouseRespVO().setId(warehouse.getId()) + .setName(warehouse.getName()).setDefaultStatus(warehouse.getDefaultStatus()))); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出仓库 Excel") + @PreAuthorize("@ss.hasPermission('erp:warehouse:export')") + @OperateLog(type = EXPORT) + public void exportWarehouseExcel(@Valid ErpWarehousePageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = warehouseService.getWarehousePage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "仓库.xls", "数据", ErpWarehouseRespVO.class, + BeanUtils.toBean(list, ErpWarehouseRespVO.class)); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/vo/warehouse/ErpWarehouseSaveReqVO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/vo/warehouse/ErpWarehouseSaveReqVO.java new file mode 100644 index 0000000000..06bf5580eb --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/stock/vo/warehouse/ErpWarehouseSaveReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - ERP 仓库新增/修改 Request VO") +@Data +public class ErpWarehouseSaveReqVO { + + @Schema(description = "仓库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "11614") + private Long id; + + @Schema(description = "仓库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @NotEmpty(message = "仓库名称不能为空") + private String name; + + @Schema(description = "仓库地址", example = "上海陆家嘴") + private String address; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "排序不能为空") + private Long sort; + + @Schema(description = "备注", example = "随便") + private String remark; + + @Schema(description = "负责人", example = "芋头") + private String principal; + + @Schema(description = "仓储费,单位:元", example = "13973") + private BigDecimal warehousePrice; + + @Schema(description = "搬运费,单位:元", example = "9903") + private BigDecimal truckagePrice; + + @Schema(description = "开启状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "开启状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpPurchaseStatisticsMapper.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpPurchaseStatisticsMapper.java new file mode 100644 index 0000000000..14b4e517bf --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpPurchaseStatisticsMapper.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.erp.dal.mysql.statistics; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 采购统计 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ErpPurchaseStatisticsMapper { + + BigDecimal getPurchasePrice(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpSaleStatisticsMapper.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpSaleStatisticsMapper.java new file mode 100644 index 0000000000..b29f1944d4 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/statistics/ErpSaleStatisticsMapper.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.erp.dal.mysql.statistics; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 销售统计 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ErpSaleStatisticsMapper { + + BigDecimal getSalePrice(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/RedisKeyConstants.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/RedisKeyConstants.java new file mode 100644 index 0000000000..06ec31387b --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/RedisKeyConstants.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.erp.dal.redis; + +/** + * ERP Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface RedisKeyConstants { + + /** + * 序号的缓存 + * + * KEY 格式:seq_no:{prefix} + * VALUE 数据格式:编号自增 + */ + String NO = "erp:seq_no:"; + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java new file mode 100644 index 0000000000..f7d976d286 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/dal/redis/no/ErpNoRedisDAO.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.erp.dal.redis.no; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateUtil; +import cn.iocoder.yudao.module.erp.dal.redis.RedisKeyConstants; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.time.Duration; +import java.time.LocalDateTime; + + +/** + * Erp 订单序号的 Redis DAO + * + * @author HUIHUI + */ +@Repository +public class ErpNoRedisDAO { + + /** + * 其它入库 {@link cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpStockInDO} + */ + public static final String STOCK_IN_NO_PREFIX = "QTRK"; + /** + * 其它出库 {@link cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpStockOutDO} + */ + public static final String STOCK_OUT_NO_PREFIX = "QCKD"; + + /** + * 库存调拨 {@link cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpStockMoveDO} + */ + public static final String STOCK_MOVE_NO_PREFIX = "QCDB"; + + /** + * 库存盘点 {@link cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpStockCheckDO} + */ + public static final String STOCK_CHECK_NO_PREFIX = "QCPD"; + + /** + * 销售订单 {@link cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO} + */ + public static final String SALE_ORDER_NO_PREFIX = "XSDD"; + /** + * 销售出库 {@link cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOutDO} + */ + public static final String SALE_OUT_NO_PREFIX = "XSCK"; + /** + * 销售退货 {@link cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleReturnDO} + */ + public static final String SALE_RETURN_NO_PREFIX = "XSTH"; + + /** + * 采购订单 {@link cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO} + */ + public static final String PURCHASE_ORDER_NO_PREFIX = "CGDD"; + /** + * 采购入库 {@link cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInDO} + */ + public static final String PURCHASE_IN_NO_PREFIX = "CGRK"; + /** + * 采购退货 {@link cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseReturnDO} + */ + public static final String PURCHASE_RETURN_NO_PREFIX = "CGTH"; + + /** + * 付款单 {@link cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinancePaymentDO} + */ + public static final String FINANCE_PAYMENT_NO_PREFIX = "FKD"; + /** + * 收款单 {@link cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpFinanceReceiptDO} + */ + public static final String FINANCE_RECEIPT_NO_PREFIX = "SKD"; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 生成序号,使用当前日期,格式为 {PREFIX} + yyyyMMdd + 6 位自增 + * 例如说:QTRK 202109 000001 (没有中间空格) + * + * @param prefix 前缀 + * @return 序号 + */ + public String generate(String prefix) { + // 递增序号 + String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); + String key = RedisKeyConstants.NO + noPrefix; + Long no = stringRedisTemplate.opsForValue().increment(key); + // 设置过期时间 + stringRedisTemplate.expire(key, Duration.ofDays(1L)); + return noPrefix + String.format("%06d", no); + } + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInServiceImpl.java new file mode 100644 index 0000000000..58f4952081 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseInServiceImpl.java @@ -0,0 +1,308 @@ +package cn.iocoder.yudao.module.erp.service.purchase; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.in.ErpPurchaseInPageReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.in.ErpPurchaseInSaveReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseInItemDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO; +import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInItemMapper; +import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseInMapper; +import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; +import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus; +import cn.iocoder.yudao.module.erp.enums.stock.ErpStockRecordBizTypeEnum; +import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService; +import cn.iocoder.yudao.module.erp.service.product.ErpProductService; +import cn.iocoder.yudao.module.erp.service.stock.ErpStockRecordService; +import cn.iocoder.yudao.module.erp.service.stock.bo.ErpStockRecordCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; + +// TODO 芋艿:记录操作日志 + +/** + * ERP 采购入库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ErpPurchaseInServiceImpl implements ErpPurchaseInService { + + @Resource + private ErpPurchaseInMapper purchaseInMapper; + @Resource + private ErpPurchaseInItemMapper purchaseInItemMapper; + + @Resource + private ErpNoRedisDAO noRedisDAO; + + @Resource + private ErpProductService productService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private ErpPurchaseOrderService purchaseOrderService; + @Resource + private ErpAccountService accountService; + @Resource + private ErpStockRecordService stockRecordService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createPurchaseIn(ErpPurchaseInSaveReqVO createReqVO) { + // 1.1 校验采购订单已审核 + ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(createReqVO.getOrderId()); + // 1.2 校验入库项的有效性 + List purchaseInItems = validatePurchaseInItems(createReqVO.getItems()); + // 1.3 校验结算账户 + accountService.validateAccount(createReqVO.getAccountId()); + // 1.4 生成入库单号,并校验唯一性 + String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_IN_NO_PREFIX); + if (purchaseInMapper.selectByNo(no) != null) { + throw exception(PURCHASE_IN_NO_EXISTS); + } + + // 2.1 插入入库 + ErpPurchaseInDO purchaseIn = BeanUtils.toBean(createReqVO, ErpPurchaseInDO.class, in -> in + .setNo(no).setStatus(ErpAuditStatus.PROCESS.getStatus())) + .setOrderNo(purchaseOrder.getNo()).setSupplierId(purchaseOrder.getSupplierId()); + calculateTotalPrice(purchaseIn, purchaseInItems); + purchaseInMapper.insert(purchaseIn); + // 2.2 插入入库项 + purchaseInItems.forEach(o -> o.setInId(purchaseIn.getId())); + purchaseInItemMapper.insertBatch(purchaseInItems); + + // 3. 更新采购订单的入库数量 + updatePurchaseOrderInCount(createReqVO.getOrderId()); + return purchaseIn.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePurchaseIn(ErpPurchaseInSaveReqVO updateReqVO) { + // 1.1 校验存在 + ErpPurchaseInDO purchaseIn = validatePurchaseInExists(updateReqVO.getId()); + if (ErpAuditStatus.APPROVE.getStatus().equals(purchaseIn.getStatus())) { + throw exception(PURCHASE_IN_UPDATE_FAIL_APPROVE, purchaseIn.getNo()); + } + // 1.2 校验采购订单已审核 + ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(updateReqVO.getOrderId()); + // 1.3 校验结算账户 + accountService.validateAccount(updateReqVO.getAccountId()); + // 1.4 校验订单项的有效性 + List purchaseInItems = validatePurchaseInItems(updateReqVO.getItems()); + + // 2.1 更新入库 + ErpPurchaseInDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseInDO.class) + .setOrderNo(purchaseOrder.getNo()).setSupplierId(purchaseOrder.getSupplierId()); + calculateTotalPrice(updateObj, purchaseInItems); + purchaseInMapper.updateById(updateObj); + // 2.2 更新入库项 + updatePurchaseInItemList(updateReqVO.getId(), purchaseInItems); + + // 3.1 更新采购订单的入库数量 + updatePurchaseOrderInCount(updateObj.getOrderId()); + // 3.2 注意:如果采购订单编号变更了,需要更新“老”采购订单的入库数量 + if (ObjectUtil.notEqual(purchaseIn.getOrderId(), updateObj.getOrderId())) { + updatePurchaseOrderInCount(purchaseIn.getOrderId()); + } + } + + private void calculateTotalPrice(ErpPurchaseInDO purchaseIn, List purchaseInItems) { + purchaseIn.setTotalCount(getSumValue(purchaseInItems, ErpPurchaseInItemDO::getCount, BigDecimal::add)); + purchaseIn.setTotalProductPrice(getSumValue(purchaseInItems, ErpPurchaseInItemDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + purchaseIn.setTotalTaxPrice(getSumValue(purchaseInItems, ErpPurchaseInItemDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO)); + purchaseIn.setTotalPrice(purchaseIn.getTotalProductPrice().add(purchaseIn.getTotalTaxPrice())); + // 计算优惠价格 + if (purchaseIn.getDiscountPercent() == null) { + purchaseIn.setDiscountPercent(BigDecimal.ZERO); + } + purchaseIn.setDiscountPrice(MoneyUtils.priceMultiplyPercent(purchaseIn.getTotalPrice(), purchaseIn.getDiscountPercent())); + purchaseIn.setTotalPrice(purchaseIn.getTotalPrice().subtract(purchaseIn.getDiscountPrice().add(purchaseIn.getOtherPrice()))); + } + + private void updatePurchaseOrderInCount(Long orderId) { + // 1.1 查询采购订单对应的采购入库单列表 + List purchaseIns = purchaseInMapper.selectListByOrderId(orderId); + // 1.2 查询对应的采购订单项的入库数量 + Map returnCountMap = purchaseInItemMapper.selectOrderItemCountSumMapByInIds( + convertList(purchaseIns, ErpPurchaseInDO::getId)); + // 2. 更新采购订单的入库数量 + purchaseOrderService.updatePurchaseOrderInCount(orderId, returnCountMap); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePurchaseInStatus(Long id, Integer status) { + boolean approve = ErpAuditStatus.APPROVE.getStatus().equals(status); + // 1.1 校验存在 + ErpPurchaseInDO purchaseIn = validatePurchaseInExists(id); + // 1.2 校验状态 + if (purchaseIn.getStatus().equals(status)) { + throw exception(approve ? PURCHASE_IN_APPROVE_FAIL : PURCHASE_IN_PROCESS_FAIL); + } + // 1.3 校验已付款 + if (!approve && purchaseIn.getPaymentPrice().compareTo(BigDecimal.ZERO) > 0) { + throw exception(PURCHASE_IN_PROCESS_FAIL_EXISTS_PAYMENT); + } + + // 2. 更新状态 + int updateCount = purchaseInMapper.updateByIdAndStatus(id, purchaseIn.getStatus(), + new ErpPurchaseInDO().setStatus(status)); + if (updateCount == 0) { + throw exception(approve ? PURCHASE_IN_APPROVE_FAIL : PURCHASE_IN_PROCESS_FAIL); + } + + // 3. 变更库存 + List purchaseInItems = purchaseInItemMapper.selectListByInId(id); + Integer bizType = approve ? ErpStockRecordBizTypeEnum.PURCHASE_IN.getType() + : ErpStockRecordBizTypeEnum.PURCHASE_IN_CANCEL.getType(); + purchaseInItems.forEach(purchaseInItem -> { + BigDecimal count = approve ? purchaseInItem.getCount() : purchaseInItem.getCount().negate(); + stockRecordService.createStockRecord(new ErpStockRecordCreateReqBO( + purchaseInItem.getProductId(), purchaseInItem.getWarehouseId(), count, + bizType, purchaseInItem.getInId(), purchaseInItem.getId(), purchaseIn.getNo())); + }); + } + + @Override + public void updatePurchaseInPaymentPrice(Long id, BigDecimal paymentPrice) { + ErpPurchaseInDO purchaseIn = purchaseInMapper.selectById(id); + if (purchaseIn.getPaymentPrice().equals(paymentPrice)) { + return; + } + if (paymentPrice.compareTo(purchaseIn.getTotalPrice()) > 0) { + throw exception(PURCHASE_IN_FAIL_PAYMENT_PRICE_EXCEED, paymentPrice, purchaseIn.getTotalPrice()); + } + purchaseInMapper.updateById(new ErpPurchaseInDO().setId(id).setPaymentPrice(paymentPrice)); + } + + private List validatePurchaseInItems(List list) { + // 1. 校验产品存在 + List productList = productService.validProductList( + convertSet(list, ErpPurchaseInSaveReqVO.Item::getProductId)); + Map productMap = convertMap(productList, ErpProductDO::getId); + // 2. 转化为 ErpPurchaseInItemDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, ErpPurchaseInItemDO.class, item -> { + item.setProductUnitId(productMap.get(item.getProductId()).getUnitId()); + item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount())); + if (item.getTotalPrice() == null) { + return; + } + if (item.getTaxPercent() != null) { + item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent())); + } + })); + } + + private void updatePurchaseInItemList(Long id, List newList) { + // 第一步,对比新老数据,获得添加、修改、删除的列表 + List oldList = purchaseInItemMapper.selectListByInId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + + // 第二步,批量添加、修改、删除 + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setInId(id)); + purchaseInItemMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + purchaseInItemMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + purchaseInItemMapper.deleteBatchIds(convertList(diffList.get(2), ErpPurchaseInItemDO::getId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePurchaseIn(List ids) { + // 1. 校验不处于已审批 + List purchaseIns = purchaseInMapper.selectBatchIds(ids); + if (CollUtil.isEmpty(purchaseIns)) { + return; + } + purchaseIns.forEach(purchaseIn -> { + if (ErpAuditStatus.APPROVE.getStatus().equals(purchaseIn.getStatus())) { + throw exception(PURCHASE_IN_DELETE_FAIL_APPROVE, purchaseIn.getNo()); + } + }); + + // 2. 遍历删除,并记录操作日志 + purchaseIns.forEach(purchaseIn -> { + // 2.1 删除订单 + purchaseInMapper.deleteById(purchaseIn.getId()); + // 2.2 删除订单项 + purchaseInItemMapper.deleteByInId(purchaseIn.getId()); + + // 2.3 更新采购订单的入库数量 + updatePurchaseOrderInCount(purchaseIn.getOrderId()); + }); + + } + + private ErpPurchaseInDO validatePurchaseInExists(Long id) { + ErpPurchaseInDO purchaseIn = purchaseInMapper.selectById(id); + if (purchaseIn == null) { + throw exception(PURCHASE_IN_NOT_EXISTS); + } + return purchaseIn; + } + + @Override + public ErpPurchaseInDO getPurchaseIn(Long id) { + return purchaseInMapper.selectById(id); + } + + @Override + public ErpPurchaseInDO validatePurchaseIn(Long id) { + ErpPurchaseInDO purchaseIn = validatePurchaseInExists(id); + if (ObjectUtil.notEqual(purchaseIn.getStatus(), ErpAuditStatus.APPROVE.getStatus())) { + throw exception(PURCHASE_IN_NOT_APPROVE); + } + return purchaseIn; + } + + @Override + public PageResult getPurchaseInPage(ErpPurchaseInPageReqVO pageReqVO) { + return purchaseInMapper.selectPage(pageReqVO); + } + + // ==================== 采购入库项 ==================== + + @Override + public List getPurchaseInItemListByInId(Long inId) { + return purchaseInItemMapper.selectListByInId(inId); + } + + @Override + public List getPurchaseInItemListByInIds(Collection inIds) { + if (CollUtil.isEmpty(inIds)) { + return Collections.emptyList(); + } + return purchaseInItemMapper.selectListByInIds(inIds); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseReturnServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseReturnServiceImpl.java new file mode 100644 index 0000000000..6648825b32 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseReturnServiceImpl.java @@ -0,0 +1,304 @@ +package cn.iocoder.yudao.module.erp.service.purchase; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.returns.ErpPurchaseReturnPageReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.returns.ErpPurchaseReturnSaveReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseReturnDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseReturnItemDO; +import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseReturnItemMapper; +import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseReturnMapper; +import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; +import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus; +import cn.iocoder.yudao.module.erp.enums.stock.ErpStockRecordBizTypeEnum; +import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService; +import cn.iocoder.yudao.module.erp.service.product.ErpProductService; +import cn.iocoder.yudao.module.erp.service.stock.ErpStockRecordService; +import cn.iocoder.yudao.module.erp.service.stock.bo.ErpStockRecordCreateReqBO; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; + +// TODO 芋艿:记录操作日志 + +/** + * ERP 采购退货 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ErpPurchaseReturnServiceImpl implements ErpPurchaseReturnService { + + @Resource + private ErpPurchaseReturnMapper purchaseReturnMapper; + @Resource + private ErpPurchaseReturnItemMapper purchaseReturnItemMapper; + + @Resource + private ErpNoRedisDAO noRedisDAO; + + @Resource + private ErpProductService productService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private ErpPurchaseOrderService purchaseOrderService; + @Resource + private ErpAccountService accountService; + @Resource + private ErpStockRecordService stockRecordService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createPurchaseReturn(ErpPurchaseReturnSaveReqVO createReqVO) { + // 1.1 校验采购订单已审核 + ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(createReqVO.getOrderId()); + // 1.2 校验退货项的有效性 + List purchaseReturnItems = validatePurchaseReturnItems(createReqVO.getItems()); + // 1.3 校验结算账户 + accountService.validateAccount(createReqVO.getAccountId()); + // 1.4 生成退货单号,并校验唯一性 + String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_RETURN_NO_PREFIX); + if (purchaseReturnMapper.selectByNo(no) != null) { + throw exception(PURCHASE_RETURN_NO_EXISTS); + } + + // 2.1 插入退货 + ErpPurchaseReturnDO purchaseReturn = BeanUtils.toBean(createReqVO, ErpPurchaseReturnDO.class, in -> in + .setNo(no).setStatus(ErpAuditStatus.PROCESS.getStatus())) + .setOrderNo(purchaseOrder.getNo()).setSupplierId(purchaseOrder.getSupplierId()); + calculateTotalPrice(purchaseReturn, purchaseReturnItems); + purchaseReturnMapper.insert(purchaseReturn); + // 2.2 插入退货项 + purchaseReturnItems.forEach(o -> o.setReturnId(purchaseReturn.getId())); + purchaseReturnItemMapper.insertBatch(purchaseReturnItems); + + // 3. 更新采购订单的退货数量 + updatePurchaseOrderReturnCount(createReqVO.getOrderId()); + return purchaseReturn.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePurchaseReturn(ErpPurchaseReturnSaveReqVO updateReqVO) { + // 1.1 校验存在 + ErpPurchaseReturnDO purchaseReturn = validatePurchaseReturnExists(updateReqVO.getId()); + if (ErpAuditStatus.APPROVE.getStatus().equals(purchaseReturn.getStatus())) { + throw exception(PURCHASE_RETURN_UPDATE_FAIL_APPROVE, purchaseReturn.getNo()); + } + // 1.2 校验采购订单已审核 + ErpPurchaseOrderDO purchaseOrder = purchaseOrderService.validatePurchaseOrder(updateReqVO.getOrderId()); + // 1.3 校验结算账户 + accountService.validateAccount(updateReqVO.getAccountId()); + // 1.4 校验订单项的有效性 + List purchaseReturnItems = validatePurchaseReturnItems(updateReqVO.getItems()); + + // 2.1 更新退货 + ErpPurchaseReturnDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseReturnDO.class) + .setOrderNo(purchaseOrder.getNo()).setSupplierId(purchaseOrder.getSupplierId()); + calculateTotalPrice(updateObj, purchaseReturnItems); + purchaseReturnMapper.updateById(updateObj); + // 2.2 更新退货项 + updatePurchaseReturnItemList(updateReqVO.getId(), purchaseReturnItems); + + // 3.1 更新采购订单的出库数量 + updatePurchaseOrderReturnCount(updateObj.getOrderId()); + // 3.2 注意:如果采购订单编号变更了,需要更新“老”采购订单的出库数量 + if (ObjectUtil.notEqual(purchaseReturn.getOrderId(), updateObj.getOrderId())) { + updatePurchaseOrderReturnCount(purchaseReturn.getOrderId()); + } + } + + private void calculateTotalPrice(ErpPurchaseReturnDO purchaseReturn, List purchaseReturnItems) { + purchaseReturn.setTotalCount(getSumValue(purchaseReturnItems, ErpPurchaseReturnItemDO::getCount, BigDecimal::add)); + purchaseReturn.setTotalProductPrice(getSumValue(purchaseReturnItems, ErpPurchaseReturnItemDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + purchaseReturn.setTotalTaxPrice(getSumValue(purchaseReturnItems, ErpPurchaseReturnItemDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO)); + purchaseReturn.setTotalPrice(purchaseReturn.getTotalProductPrice().add(purchaseReturn.getTotalTaxPrice())); + // 计算优惠价格 + if (purchaseReturn.getDiscountPercent() == null) { + purchaseReturn.setDiscountPercent(BigDecimal.ZERO); + } + purchaseReturn.setDiscountPrice(MoneyUtils.priceMultiplyPercent(purchaseReturn.getTotalPrice(), purchaseReturn.getDiscountPercent())); + purchaseReturn.setTotalPrice(purchaseReturn.getTotalPrice().subtract(purchaseReturn.getDiscountPrice().add(purchaseReturn.getOtherPrice()))); + } + + private void updatePurchaseOrderReturnCount(Long orderId) { + // 1.1 查询采购订单对应的采购出库单列表 + List purchaseReturns = purchaseReturnMapper.selectListByOrderId(orderId); + // 1.2 查询对应的采购订单项的退货数量 + Map returnCountMap = purchaseReturnItemMapper.selectOrderItemCountSumMapByReturnIds( + convertList(purchaseReturns, ErpPurchaseReturnDO::getId)); + // 2. 更新采购订单的出库数量 + purchaseOrderService.updatePurchaseOrderReturnCount(orderId, returnCountMap); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePurchaseReturnStatus(Long id, Integer status) { + boolean approve = ErpAuditStatus.APPROVE.getStatus().equals(status); + // 1.1 校验存在 + ErpPurchaseReturnDO purchaseReturn = validatePurchaseReturnExists(id); + // 1.2 校验状态 + if (purchaseReturn.getStatus().equals(status)) { + throw exception(approve ? PURCHASE_RETURN_APPROVE_FAIL : PURCHASE_RETURN_PROCESS_FAIL); + } + // 1.3 校验已退款 + if (!approve && purchaseReturn.getRefundPrice().compareTo(BigDecimal.ZERO) > 0) { + throw exception(PURCHASE_RETURN_PROCESS_FAIL_EXISTS_REFUND); + } + + // 2. 更新状态 + int updateCount = purchaseReturnMapper.updateByIdAndStatus(id, purchaseReturn.getStatus(), + new ErpPurchaseReturnDO().setStatus(status)); + if (updateCount == 0) { + throw exception(approve ? PURCHASE_RETURN_APPROVE_FAIL : PURCHASE_RETURN_PROCESS_FAIL); + } + + // 3. 变更库存 + List purchaseReturnItems = purchaseReturnItemMapper.selectListByReturnId(id); + Integer bizType = approve ? ErpStockRecordBizTypeEnum.PURCHASE_RETURN.getType() + : ErpStockRecordBizTypeEnum.PURCHASE_RETURN_CANCEL.getType(); + purchaseReturnItems.forEach(purchaseReturnItem -> { + BigDecimal count = approve ? purchaseReturnItem.getCount().negate() : purchaseReturnItem.getCount(); + stockRecordService.createStockRecord(new ErpStockRecordCreateReqBO( + purchaseReturnItem.getProductId(), purchaseReturnItem.getWarehouseId(), count, + bizType, purchaseReturnItem.getReturnId(), purchaseReturnItem.getId(), purchaseReturn.getNo())); + }); + } + + @Override + public void updatePurchaseReturnRefundPrice(Long id, BigDecimal refundPrice) { + ErpPurchaseReturnDO purchaseReturn = purchaseReturnMapper.selectById(id); + if (purchaseReturn.getRefundPrice().equals(refundPrice)) { + return; + } + if (refundPrice.compareTo(purchaseReturn.getTotalPrice()) > 0) { + throw exception(PURCHASE_RETURN_FAIL_REFUND_PRICE_EXCEED, refundPrice, purchaseReturn.getTotalPrice()); + } + purchaseReturnMapper.updateById(new ErpPurchaseReturnDO().setId(id).setRefundPrice(refundPrice)); + } + + private List validatePurchaseReturnItems(List list) { + // 1. 校验产品存在 + List productList = productService.validProductList( + convertSet(list, ErpPurchaseReturnSaveReqVO.Item::getProductId)); + Map productMap = convertMap(productList, ErpProductDO::getId); + // 2. 转化为 ErpPurchaseReturnItemDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, ErpPurchaseReturnItemDO.class, item -> { + item.setProductUnitId(productMap.get(item.getProductId()).getUnitId()); + item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount())); + if (item.getTotalPrice() == null) { + return; + } + if (item.getTaxPercent() != null) { + item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent())); + } + })); + } + + private void updatePurchaseReturnItemList(Long id, List newList) { + // 第一步,对比新老数据,获得添加、修改、删除的列表 + List oldList = purchaseReturnItemMapper.selectListByReturnId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + + // 第二步,批量添加、修改、删除 + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setReturnId(id)); + purchaseReturnItemMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + purchaseReturnItemMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + purchaseReturnItemMapper.deleteBatchIds(convertList(diffList.get(2), ErpPurchaseReturnItemDO::getId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePurchaseReturn(List ids) { + // 1. 校验不处于已审批 + List purchaseReturns = purchaseReturnMapper.selectBatchIds(ids); + if (CollUtil.isEmpty(purchaseReturns)) { + return; + } + purchaseReturns.forEach(purchaseReturn -> { + if (ErpAuditStatus.APPROVE.getStatus().equals(purchaseReturn.getStatus())) { + throw exception(PURCHASE_RETURN_DELETE_FAIL_APPROVE, purchaseReturn.getNo()); + } + }); + + // 2. 遍历删除,并记录操作日志 + purchaseReturns.forEach(purchaseReturn -> { + // 2.1 删除订单 + purchaseReturnMapper.deleteById(purchaseReturn.getId()); + // 2.2 删除订单项 + purchaseReturnItemMapper.deleteByReturnId(purchaseReturn.getId()); + + // 2.3 更新采购订单的出库数量 + updatePurchaseOrderReturnCount(purchaseReturn.getOrderId()); + }); + + } + + private ErpPurchaseReturnDO validatePurchaseReturnExists(Long id) { + ErpPurchaseReturnDO purchaseReturn = purchaseReturnMapper.selectById(id); + if (purchaseReturn == null) { + throw exception(PURCHASE_RETURN_NOT_EXISTS); + } + return purchaseReturn; + } + + @Override + public ErpPurchaseReturnDO getPurchaseReturn(Long id) { + return purchaseReturnMapper.selectById(id); + } + + @Override + public ErpPurchaseReturnDO validatePurchaseReturn(Long id) { + ErpPurchaseReturnDO purchaseReturn = getPurchaseReturn(id); + if (ObjectUtil.notEqual(purchaseReturn.getStatus(), ErpAuditStatus.APPROVE.getStatus())) { + throw exception(PURCHASE_RETURN_NOT_APPROVE); + } + return purchaseReturn; + } + + @Override + public PageResult getPurchaseReturnPage(ErpPurchaseReturnPageReqVO pageReqVO) { + return purchaseReturnMapper.selectPage(pageReqVO); + } + + // ==================== 采购退货项 ==================== + + @Override + public List getPurchaseReturnItemListByReturnId(Long returnId) { + return purchaseReturnItemMapper.selectListByReturnId(returnId); + } + + @Override + public List getPurchaseReturnItemListByReturnIds(Collection returnIds) { + if (CollUtil.isEmpty(returnIds)) { + return Collections.emptyList(); + } + return purchaseReturnItemMapper.selectListByReturnIds(returnIds); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOutServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOutServiceImpl.java new file mode 100644 index 0000000000..16d20dd376 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOutServiceImpl.java @@ -0,0 +1,316 @@ +package cn.iocoder.yudao.module.erp.service.sale; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.out.ErpSaleOutPageReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.out.ErpSaleOutSaveReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOutDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOutItemDO; +import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleOutItemMapper; +import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleOutMapper; +import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; +import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus; +import cn.iocoder.yudao.module.erp.enums.stock.ErpStockRecordBizTypeEnum; +import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService; +import cn.iocoder.yudao.module.erp.service.product.ErpProductService; +import cn.iocoder.yudao.module.erp.service.stock.ErpStockRecordService; +import cn.iocoder.yudao.module.erp.service.stock.bo.ErpStockRecordCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; + +// TODO 芋艿:记录操作日志 + +/** + * ERP 销售出库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ErpSaleOutServiceImpl implements ErpSaleOutService { + + @Resource + private ErpSaleOutMapper saleOutMapper; + @Resource + private ErpSaleOutItemMapper saleOutItemMapper; + + @Resource + private ErpNoRedisDAO noRedisDAO; + + @Resource + private ErpProductService productService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private ErpSaleOrderService saleOrderService; + @Resource + private ErpAccountService accountService; + @Resource + private ErpStockRecordService stockRecordService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSaleOut(ErpSaleOutSaveReqVO createReqVO) { + // 1.1 校验销售订单已审核 + ErpSaleOrderDO saleOrder = saleOrderService.validateSaleOrder(createReqVO.getOrderId()); + // 1.2 校验出库项的有效性 + List saleOutItems = validateSaleOutItems(createReqVO.getItems()); + // 1.3 校验结算账户 + accountService.validateAccount(createReqVO.getAccountId()); + // 1.4 校验销售人员 + if (createReqVO.getSaleUserId() != null) { + adminUserApi.validateUser(createReqVO.getSaleUserId()); + } + // 1.5 生成出库单号,并校验唯一性 + String no = noRedisDAO.generate(ErpNoRedisDAO.SALE_OUT_NO_PREFIX); + if (saleOutMapper.selectByNo(no) != null) { + throw exception(SALE_OUT_NO_EXISTS); + } + + // 2.1 插入出库 + ErpSaleOutDO saleOut = BeanUtils.toBean(createReqVO, ErpSaleOutDO.class, in -> in + .setNo(no).setStatus(ErpAuditStatus.PROCESS.getStatus())) + .setOrderNo(saleOrder.getNo()).setCustomerId(saleOrder.getCustomerId()); + calculateTotalPrice(saleOut, saleOutItems); + saleOutMapper.insert(saleOut); + // 2.2 插入出库项 + saleOutItems.forEach(o -> o.setOutId(saleOut.getId())); + saleOutItemMapper.insertBatch(saleOutItems); + + // 3. 更新销售订单的出库数量 + updateSaleOrderOutCount(createReqVO.getOrderId()); + return saleOut.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSaleOut(ErpSaleOutSaveReqVO updateReqVO) { + // 1.1 校验存在 + ErpSaleOutDO saleOut = validateSaleOutExists(updateReqVO.getId()); + if (ErpAuditStatus.APPROVE.getStatus().equals(saleOut.getStatus())) { + throw exception(SALE_OUT_UPDATE_FAIL_APPROVE, saleOut.getNo()); + } + // 1.2 校验销售订单已审核 + ErpSaleOrderDO saleOrder = saleOrderService.validateSaleOrder(updateReqVO.getOrderId()); + // 1.3 校验结算账户 + accountService.validateAccount(updateReqVO.getAccountId()); + // 1.4 校验销售人员 + if (updateReqVO.getSaleUserId() != null) { + adminUserApi.validateUser(updateReqVO.getSaleUserId()); + } + // 1.5 校验订单项的有效性 + List saleOutItems = validateSaleOutItems(updateReqVO.getItems()); + + // 2.1 更新出库 + ErpSaleOutDO updateObj = BeanUtils.toBean(updateReqVO, ErpSaleOutDO.class) + .setOrderNo(saleOrder.getNo()).setCustomerId(saleOrder.getCustomerId()); + calculateTotalPrice(updateObj, saleOutItems); + saleOutMapper.updateById(updateObj); + // 2.2 更新出库项 + updateSaleOutItemList(updateReqVO.getId(), saleOutItems); + + // 3.1 更新销售订单的出库数量 + updateSaleOrderOutCount(updateObj.getOrderId()); + // 3.2 注意:如果销售订单编号变更了,需要更新“老”销售订单的出库数量 + if (ObjectUtil.notEqual(saleOut.getOrderId(), updateObj.getOrderId())) { + updateSaleOrderOutCount(saleOut.getOrderId()); + } + } + + private void calculateTotalPrice(ErpSaleOutDO saleOut, List saleOutItems) { + saleOut.setTotalCount(getSumValue(saleOutItems, ErpSaleOutItemDO::getCount, BigDecimal::add)); + saleOut.setTotalProductPrice(getSumValue(saleOutItems, ErpSaleOutItemDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + saleOut.setTotalTaxPrice(getSumValue(saleOutItems, ErpSaleOutItemDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO)); + saleOut.setTotalPrice(saleOut.getTotalProductPrice().add(saleOut.getTotalTaxPrice())); + // 计算优惠价格 + if (saleOut.getDiscountPercent() == null) { + saleOut.setDiscountPercent(BigDecimal.ZERO); + } + saleOut.setDiscountPrice(MoneyUtils.priceMultiplyPercent(saleOut.getTotalPrice(), saleOut.getDiscountPercent())); + saleOut.setTotalPrice(saleOut.getTotalPrice().subtract(saleOut.getDiscountPrice().add(saleOut.getOtherPrice()))); + } + + private void updateSaleOrderOutCount(Long orderId) { + // 1.1 查询销售订单对应的销售出库单列表 + List saleOuts = saleOutMapper.selectListByOrderId(orderId); + // 1.2 查询对应的销售订单项的出库数量 + Map returnCountMap = saleOutItemMapper.selectOrderItemCountSumMapByOutIds( + convertList(saleOuts, ErpSaleOutDO::getId)); + // 2. 更新销售订单的出库数量 + saleOrderService.updateSaleOrderOutCount(orderId, returnCountMap); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSaleOutStatus(Long id, Integer status) { + boolean approve = ErpAuditStatus.APPROVE.getStatus().equals(status); + // 1.1 校验存在 + ErpSaleOutDO saleOut = validateSaleOutExists(id); + // 1.2 校验状态 + if (saleOut.getStatus().equals(status)) { + throw exception(approve ? SALE_OUT_APPROVE_FAIL : SALE_OUT_PROCESS_FAIL); + } + // 1.3 校验已退款 + if (!approve && saleOut.getReceiptPrice().compareTo(BigDecimal.ZERO) > 0) { + throw exception(SALE_OUT_PROCESS_FAIL_EXISTS_RECEIPT); + } + + // 2. 更新状态 + int updateCount = saleOutMapper.updateByIdAndStatus(id, saleOut.getStatus(), + new ErpSaleOutDO().setStatus(status)); + if (updateCount == 0) { + throw exception(approve ? SALE_OUT_APPROVE_FAIL : SALE_OUT_PROCESS_FAIL); + } + + // 3. 变更库存 + List saleOutItems = saleOutItemMapper.selectListByOutId(id); + Integer bizType = approve ? ErpStockRecordBizTypeEnum.SALE_OUT.getType() + : ErpStockRecordBizTypeEnum.SALE_OUT_CANCEL.getType(); + saleOutItems.forEach(saleOutItem -> { + BigDecimal count = approve ? saleOutItem.getCount().negate() : saleOutItem.getCount(); + stockRecordService.createStockRecord(new ErpStockRecordCreateReqBO( + saleOutItem.getProductId(), saleOutItem.getWarehouseId(), count, + bizType, saleOutItem.getOutId(), saleOutItem.getId(), saleOut.getNo())); + }); + } + + @Override + public void updateSaleInReceiptPrice(Long id, BigDecimal receiptPrice) { + ErpSaleOutDO saleOut = saleOutMapper.selectById(id); + if (saleOut.getReceiptPrice().equals(receiptPrice)) { + return; + } + if (receiptPrice.compareTo(saleOut.getTotalPrice()) > 0) { + throw exception(SALE_OUT_FAIL_RECEIPT_PRICE_EXCEED, receiptPrice, saleOut.getTotalPrice()); + } + saleOutMapper.updateById(new ErpSaleOutDO().setId(id).setReceiptPrice(receiptPrice)); + } + + private List validateSaleOutItems(List list) { + // 1. 校验产品存在 + List productList = productService.validProductList( + convertSet(list, ErpSaleOutSaveReqVO.Item::getProductId)); + Map productMap = convertMap(productList, ErpProductDO::getId); + // 2. 转化为 ErpSaleOutItemDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, ErpSaleOutItemDO.class, item -> { + item.setProductUnitId(productMap.get(item.getProductId()).getUnitId()); + item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount())); + if (item.getTotalPrice() == null) { + return; + } + if (item.getTaxPercent() != null) { + item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent())); + } + })); + } + + private void updateSaleOutItemList(Long id, List newList) { + // 第一步,对比新老数据,获得添加、修改、删除的列表 + List oldList = saleOutItemMapper.selectListByOutId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + + // 第二步,批量添加、修改、删除 + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setOutId(id)); + saleOutItemMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + saleOutItemMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + saleOutItemMapper.deleteBatchIds(convertList(diffList.get(2), ErpSaleOutItemDO::getId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSaleOut(List ids) { + // 1. 校验不处于已审批 + List saleOuts = saleOutMapper.selectBatchIds(ids); + if (CollUtil.isEmpty(saleOuts)) { + return; + } + saleOuts.forEach(saleOut -> { + if (ErpAuditStatus.APPROVE.getStatus().equals(saleOut.getStatus())) { + throw exception(SALE_OUT_DELETE_FAIL_APPROVE, saleOut.getNo()); + } + }); + + // 2. 遍历删除,并记录操作日志 + saleOuts.forEach(saleOut -> { + // 2.1 删除订单 + saleOutMapper.deleteById(saleOut.getId()); + // 2.2 删除订单项 + saleOutItemMapper.deleteByOutId(saleOut.getId()); + + // 2.3 更新销售订单的出库数量 + updateSaleOrderOutCount(saleOut.getOrderId()); + }); + + } + + private ErpSaleOutDO validateSaleOutExists(Long id) { + ErpSaleOutDO saleOut = saleOutMapper.selectById(id); + if (saleOut == null) { + throw exception(SALE_OUT_NOT_EXISTS); + } + return saleOut; + } + + @Override + public ErpSaleOutDO getSaleOut(Long id) { + return saleOutMapper.selectById(id); + } + + @Override + public ErpSaleOutDO validateSaleOut(Long id) { + ErpSaleOutDO saleOut = validateSaleOutExists(id); + if (ObjectUtil.notEqual(saleOut.getStatus(), ErpAuditStatus.APPROVE.getStatus())) { + throw exception(SALE_OUT_NOT_APPROVE); + } + return saleOut; + } + + @Override + public PageResult getSaleOutPage(ErpSaleOutPageReqVO pageReqVO) { + return saleOutMapper.selectPage(pageReqVO); + } + + // ==================== 销售出库项 ==================== + + @Override + public List getSaleOutItemListByOutId(Long outId) { + return saleOutItemMapper.selectListByOutId(outId); + } + + @Override + public List getSaleOutItemListByOutIds(Collection outIds) { + if (CollUtil.isEmpty(outIds)) { + return Collections.emptyList(); + } + return saleOutItemMapper.selectListByOutIds(outIds); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleReturnServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleReturnServiceImpl.java new file mode 100644 index 0000000000..209df114c0 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleReturnServiceImpl.java @@ -0,0 +1,316 @@ +package cn.iocoder.yudao.module.erp.service.sale; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.returns.ErpSaleReturnPageReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.returns.ErpSaleReturnSaveReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.product.ErpProductDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleReturnDO; +import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleReturnItemDO; +import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleReturnItemMapper; +import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleReturnMapper; +import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; +import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus; +import cn.iocoder.yudao.module.erp.enums.stock.ErpStockRecordBizTypeEnum; +import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService; +import cn.iocoder.yudao.module.erp.service.product.ErpProductService; +import cn.iocoder.yudao.module.erp.service.stock.ErpStockRecordService; +import cn.iocoder.yudao.module.erp.service.stock.bo.ErpStockRecordCreateReqBO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; + +// TODO 芋艿:记录操作日志 + +/** + * ERP 销售退货 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ErpSaleReturnServiceImpl implements ErpSaleReturnService { + + @Resource + private ErpSaleReturnMapper saleReturnMapper; + @Resource + private ErpSaleReturnItemMapper saleReturnItemMapper; + + @Resource + private ErpNoRedisDAO noRedisDAO; + + @Resource + private ErpProductService productService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private ErpSaleOrderService saleOrderService; + @Resource + private ErpAccountService accountService; + @Resource + private ErpStockRecordService stockRecordService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSaleReturn(ErpSaleReturnSaveReqVO createReqVO) { + // 1.1 校验销售订单已审核 + ErpSaleOrderDO saleOrder = saleOrderService.validateSaleOrder(createReqVO.getOrderId()); + // 1.2 校验退货项的有效性 + List saleReturnItems = validateSaleReturnItems(createReqVO.getItems()); + // 1.3 校验结算账户 + accountService.validateAccount(createReqVO.getAccountId()); + // 1.4 校验销售人员 + if (createReqVO.getSaleUserId() != null) { + adminUserApi.validateUser(createReqVO.getSaleUserId()); + } + // 1.5 生成退货单号,并校验唯一性 + String no = noRedisDAO.generate(ErpNoRedisDAO.SALE_RETURN_NO_PREFIX); + if (saleReturnMapper.selectByNo(no) != null) { + throw exception(SALE_RETURN_NO_EXISTS); + } + + // 2.1 插入退货 + ErpSaleReturnDO saleReturn = BeanUtils.toBean(createReqVO, ErpSaleReturnDO.class, in -> in + .setNo(no).setStatus(ErpAuditStatus.PROCESS.getStatus())) + .setOrderNo(saleOrder.getNo()).setCustomerId(saleOrder.getCustomerId()); + calculateTotalPrice(saleReturn, saleReturnItems); + saleReturnMapper.insert(saleReturn); + // 2.2 插入退货项 + saleReturnItems.forEach(o -> o.setReturnId(saleReturn.getId())); + saleReturnItemMapper.insertBatch(saleReturnItems); + + // 3. 更新销售订单的退货数量 + updateSaleOrderReturnCount(createReqVO.getOrderId()); + return saleReturn.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSaleReturn(ErpSaleReturnSaveReqVO updateReqVO) { + // 1.1 校验存在 + ErpSaleReturnDO saleReturn = validateSaleReturnExists(updateReqVO.getId()); + if (ErpAuditStatus.APPROVE.getStatus().equals(saleReturn.getStatus())) { + throw exception(SALE_RETURN_UPDATE_FAIL_APPROVE, saleReturn.getNo()); + } + // 1.2 校验销售订单已审核 + ErpSaleOrderDO saleOrder = saleOrderService.validateSaleOrder(updateReqVO.getOrderId()); + // 1.3 校验结算账户 + accountService.validateAccount(updateReqVO.getAccountId()); + // 1.4 校验销售人员 + if (updateReqVO.getSaleUserId() != null) { + adminUserApi.validateUser(updateReqVO.getSaleUserId()); + } + // 1.5 校验订单项的有效性 + List saleReturnItems = validateSaleReturnItems(updateReqVO.getItems()); + + // 2.1 更新退货 + ErpSaleReturnDO updateObj = BeanUtils.toBean(updateReqVO, ErpSaleReturnDO.class) + .setOrderNo(saleOrder.getNo()).setCustomerId(saleOrder.getCustomerId()); + calculateTotalPrice(updateObj, saleReturnItems); + saleReturnMapper.updateById(updateObj); + // 2.2 更新退货项 + updateSaleReturnItemList(updateReqVO.getId(), saleReturnItems); + + // 3.1 更新销售订单的出库数量 + updateSaleOrderReturnCount(updateObj.getOrderId()); + // 3.2 注意:如果销售订单编号变更了,需要更新“老”销售订单的出库数量 + if (ObjectUtil.notEqual(saleReturn.getOrderId(), updateObj.getOrderId())) { + updateSaleOrderReturnCount(saleReturn.getOrderId()); + } + } + + private void calculateTotalPrice(ErpSaleReturnDO saleReturn, List saleReturnItems) { + saleReturn.setTotalCount(getSumValue(saleReturnItems, ErpSaleReturnItemDO::getCount, BigDecimal::add)); + saleReturn.setTotalProductPrice(getSumValue(saleReturnItems, ErpSaleReturnItemDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); + saleReturn.setTotalTaxPrice(getSumValue(saleReturnItems, ErpSaleReturnItemDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO)); + saleReturn.setTotalPrice(saleReturn.getTotalProductPrice().add(saleReturn.getTotalTaxPrice())); + // 计算优惠价格 + if (saleReturn.getDiscountPercent() == null) { + saleReturn.setDiscountPercent(BigDecimal.ZERO); + } + saleReturn.setDiscountPrice(MoneyUtils.priceMultiplyPercent(saleReturn.getTotalPrice(), saleReturn.getDiscountPercent())); + saleReturn.setTotalPrice(saleReturn.getTotalPrice().subtract(saleReturn.getDiscountPrice().add(saleReturn.getOtherPrice()))); + } + + private void updateSaleOrderReturnCount(Long orderId) { + // 1.1 查询销售订单对应的销售出库单列表 + List saleReturns = saleReturnMapper.selectListByOrderId(orderId); + // 1.2 查询对应的销售订单项的退货数量 + Map returnCountMap = saleReturnItemMapper.selectOrderItemCountSumMapByReturnIds( + convertList(saleReturns, ErpSaleReturnDO::getId)); + // 2. 更新销售订单的出库数量 + saleOrderService.updateSaleOrderReturnCount(orderId, returnCountMap); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSaleReturnStatus(Long id, Integer status) { + boolean approve = ErpAuditStatus.APPROVE.getStatus().equals(status); + // 1.1 校验存在 + ErpSaleReturnDO saleReturn = validateSaleReturnExists(id); + // 1.2 校验状态 + if (saleReturn.getStatus().equals(status)) { + throw exception(approve ? SALE_RETURN_APPROVE_FAIL : SALE_RETURN_PROCESS_FAIL); + } + // 1.3 校验已退款 + if (!approve && saleReturn.getRefundPrice().compareTo(BigDecimal.ZERO) > 0) { + throw exception(SALE_RETURN_PROCESS_FAIL_EXISTS_REFUND); + } + + // 2. 更新状态 + int updateCount = saleReturnMapper.updateByIdAndStatus(id, saleReturn.getStatus(), + new ErpSaleReturnDO().setStatus(status)); + if (updateCount == 0) { + throw exception(approve ? SALE_RETURN_APPROVE_FAIL : SALE_RETURN_PROCESS_FAIL); + } + + // 3. 变更库存 + List saleReturnItems = saleReturnItemMapper.selectListByReturnId(id); + Integer bizType = approve ? ErpStockRecordBizTypeEnum.SALE_RETURN.getType() + : ErpStockRecordBizTypeEnum.SALE_RETURN_CANCEL.getType(); + saleReturnItems.forEach(saleReturnItem -> { + BigDecimal count = approve ? saleReturnItem.getCount() : saleReturnItem.getCount().negate(); + stockRecordService.createStockRecord(new ErpStockRecordCreateReqBO( + saleReturnItem.getProductId(), saleReturnItem.getWarehouseId(), count, + bizType, saleReturnItem.getReturnId(), saleReturnItem.getId(), saleReturn.getNo())); + }); + } + + @Override + public void updateSaleReturnRefundPrice(Long id, BigDecimal refundPrice) { + ErpSaleReturnDO saleReturn = saleReturnMapper.selectById(id); + if (saleReturn.getRefundPrice().equals(refundPrice)) { + return; + } + if (refundPrice.compareTo(saleReturn.getTotalPrice()) > 0) { + throw exception(SALE_RETURN_FAIL_REFUND_PRICE_EXCEED, refundPrice, saleReturn.getTotalPrice()); + } + saleReturnMapper.updateById(new ErpSaleReturnDO().setId(id).setRefundPrice(refundPrice)); + } + + private List validateSaleReturnItems(List list) { + // 1. 校验产品存在 + List productList = productService.validProductList( + convertSet(list, ErpSaleReturnSaveReqVO.Item::getProductId)); + Map productMap = convertMap(productList, ErpProductDO::getId); + // 2. 转化为 ErpSaleReturnItemDO 列表 + return convertList(list, o -> BeanUtils.toBean(o, ErpSaleReturnItemDO.class, item -> { + item.setProductUnitId(productMap.get(item.getProductId()).getUnitId()); + item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount())); + if (item.getTotalPrice() == null) { + return; + } + if (item.getTaxPercent() != null) { + item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent())); + } + })); + } + + private void updateSaleReturnItemList(Long id, List newList) { + // 第一步,对比新老数据,获得添加、修改、删除的列表 + List oldList = saleReturnItemMapper.selectListByReturnId(id); + List> diffList = diffList(oldList, newList, // id 不同,就认为是不同的记录 + (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); + + // 第二步,批量添加、修改、删除 + if (CollUtil.isNotEmpty(diffList.get(0))) { + diffList.get(0).forEach(o -> o.setReturnId(id)); + saleReturnItemMapper.insertBatch(diffList.get(0)); + } + if (CollUtil.isNotEmpty(diffList.get(1))) { + saleReturnItemMapper.updateBatch(diffList.get(1)); + } + if (CollUtil.isNotEmpty(diffList.get(2))) { + saleReturnItemMapper.deleteBatchIds(convertList(diffList.get(2), ErpSaleReturnItemDO::getId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSaleReturn(List ids) { + // 1. 校验不处于已审批 + List saleReturns = saleReturnMapper.selectBatchIds(ids); + if (CollUtil.isEmpty(saleReturns)) { + return; + } + saleReturns.forEach(saleReturn -> { + if (ErpAuditStatus.APPROVE.getStatus().equals(saleReturn.getStatus())) { + throw exception(SALE_RETURN_DELETE_FAIL_APPROVE, saleReturn.getNo()); + } + }); + + // 2. 遍历删除,并记录操作日志 + saleReturns.forEach(saleReturn -> { + // 2.1 删除订单 + saleReturnMapper.deleteById(saleReturn.getId()); + // 2.2 删除订单项 + saleReturnItemMapper.deleteByReturnId(saleReturn.getId()); + + // 2.3 更新销售订单的出库数量 + updateSaleOrderReturnCount(saleReturn.getOrderId()); + }); + + } + + private ErpSaleReturnDO validateSaleReturnExists(Long id) { + ErpSaleReturnDO saleReturn = saleReturnMapper.selectById(id); + if (saleReturn == null) { + throw exception(SALE_RETURN_NOT_EXISTS); + } + return saleReturn; + } + + @Override + public ErpSaleReturnDO getSaleReturn(Long id) { + return saleReturnMapper.selectById(id); + } + + @Override + public ErpSaleReturnDO validateSaleReturn(Long id) { + ErpSaleReturnDO saleReturn = validateSaleReturnExists(id); + if (ObjectUtil.notEqual(saleReturn.getStatus(), ErpAuditStatus.APPROVE.getStatus())) { + throw exception(SALE_RETURN_NOT_APPROVE); + } + return saleReturn; + } + + @Override + public PageResult getSaleReturnPage(ErpSaleReturnPageReqVO pageReqVO) { + return saleReturnMapper.selectPage(pageReqVO); + } + + // ==================== 销售退货项 ==================== + + @Override + public List getSaleReturnItemListByReturnId(Long returnId) { + return saleReturnItemMapper.selectListByReturnId(returnId); + } + + @Override + public List getSaleReturnItemListByReturnIds(Collection returnIds) { + if (CollUtil.isEmpty(returnIds)) { + return Collections.emptyList(); + } + return saleReturnItemMapper.selectListByReturnIds(returnIds); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsService.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsService.java new file mode 100644 index 0000000000..9134094879 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsService.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.erp.service.statistics; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 采购统计 Service 接口 + * + * @author 芋道源码 + */ +public interface ErpPurchaseStatisticsService { + + /** + * 获得采购金额 + * + * 计算逻辑:采购出库的金额 - 采购退货的金额 + * + * @param beginTime >= 开始时间 + * @param endTime < 结束时间 + * @return 采购金额 + */ + BigDecimal getPurchasePrice(LocalDateTime beginTime, LocalDateTime endTime); + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsServiceImpl.java new file mode 100644 index 0000000000..588ff74ba5 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpPurchaseStatisticsServiceImpl.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.erp.service.statistics; + +import cn.iocoder.yudao.module.erp.dal.mysql.statistics.ErpPurchaseStatisticsMapper; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 采购统计 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class ErpPurchaseStatisticsServiceImpl implements ErpPurchaseStatisticsService { + + @Resource + private ErpPurchaseStatisticsMapper purchaseStatisticsMapper; + + @Override + public BigDecimal getPurchasePrice(LocalDateTime beginTime, LocalDateTime endTime) { + return purchaseStatisticsMapper.getPurchasePrice(beginTime, endTime); + } + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsService.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsService.java new file mode 100644 index 0000000000..3c28bc327e --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsService.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.erp.service.statistics; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 销售统计 Service 接口 + * + * @author 芋道源码 + */ +public interface ErpSaleStatisticsService { + + /** + * 获得销售金额 + * + * 计算逻辑:销售出库的金额 - 销售退货的金额 + * + * @param beginTime >= 开始时间 + * @param endTime < 结束时间 + * @return 销售金额 + */ + BigDecimal getSalePrice(LocalDateTime beginTime, LocalDateTime endTime); + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsServiceImpl.java new file mode 100644 index 0000000000..a6664332a2 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/statistics/ErpSaleStatisticsServiceImpl.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.erp.service.statistics; + +import cn.iocoder.yudao.module.erp.dal.mysql.statistics.ErpSaleStatisticsMapper; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ERP 销售统计 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class ErpSaleStatisticsServiceImpl implements ErpSaleStatisticsService { + + @Resource + private ErpSaleStatisticsMapper saleStatisticsMapper; + + @Override + public BigDecimal getSalePrice(LocalDateTime beginTime, LocalDateTime endTime) { + return saleStatisticsMapper.getSalePrice(beginTime, endTime); + } + +} diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseService.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseService.java new file mode 100644 index 0000000000..2d6f96fb3a --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseService.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.erp.service.stock; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehouseSaveReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehousePageReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpWarehouseDO; + +import javax.validation.Valid; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * ERP 仓库 Service 接口 + * + * @author 芋道源码 + */ +public interface ErpWarehouseService { + + /** + * 创建仓库 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createWarehouse(@Valid ErpWarehouseSaveReqVO createReqVO); + + /** + * 更新ERP 仓库 + * + * @param updateReqVO 更新信息 + */ + void updateWarehouse(@Valid ErpWarehouseSaveReqVO updateReqVO); + + /** + * 更新仓库默认状态 + * + * @param id 编号 + * @param defaultStatus 默认状态 + */ + void updateWarehouseDefaultStatus(Long id, Boolean defaultStatus); + + /** + * 删除仓库 + * + * @param id 编号 + */ + void deleteWarehouse(Long id); + + /** + * 获得仓库 + * + * @param id 编号 + * @return 仓库 + */ + ErpWarehouseDO getWarehouse(Long id); + + /** + * 校验仓库列表的有效性 + * + * @param ids 编号数组 + * @return 仓库列表 + */ + List validWarehouseList(Collection ids); + + /** + * 获得指定状态的仓库列表 + * + * @param status 状态 + * @return 仓库列表 + */ + List getWarehouseListByStatus(Integer status); + + /** + * 获得仓库列表 + * + * @param ids 编号数组 + * @return 仓库列表 + */ + List getWarehouseList(Collection ids); + + /** + * 获得仓库 Map + * + * @param ids 编号数组 + * @return 仓库 Map + */ + default Map getWarehouseMap(Collection ids) { + return convertMap(getWarehouseList(ids), ErpWarehouseDO::getId); + } + + /** + * 获得仓库分页 + * + * @param pageReqVO 分页查询 + * @return 仓库分页 + */ + PageResult getWarehousePage(ErpWarehousePageReqVO pageReqVO); + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseServiceImpl.java b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseServiceImpl.java new file mode 100644 index 0000000000..9e80128dd0 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/java/cn/iocoder/yudao/module/erp/service/stock/ErpWarehouseServiceImpl.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.erp.service.stock; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehouseSaveReqVO; +import cn.iocoder.yudao.module.erp.controller.admin.stock.vo.warehouse.ErpWarehousePageReqVO; +import cn.iocoder.yudao.module.erp.dal.dataobject.stock.ErpWarehouseDO; +import cn.iocoder.yudao.module.erp.dal.mysql.stock.ErpWarehouseMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.WAREHOUSE_NOT_ENABLE; +import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.WAREHOUSE_NOT_EXISTS; + +/** + * ERP 仓库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ErpWarehouseServiceImpl implements ErpWarehouseService { + + @Resource + private ErpWarehouseMapper warehouseMapper; + + @Override + public Long createWarehouse(ErpWarehouseSaveReqVO createReqVO) { + // 插入 + ErpWarehouseDO warehouse = BeanUtils.toBean(createReqVO, ErpWarehouseDO.class); + warehouseMapper.insert(warehouse); + // 返回 + return warehouse.getId(); + } + + @Override + public void updateWarehouse(ErpWarehouseSaveReqVO updateReqVO) { + // 校验存在 + validateWarehouseExists(updateReqVO.getId()); + // 更新 + ErpWarehouseDO updateObj = BeanUtils.toBean(updateReqVO, ErpWarehouseDO.class); + warehouseMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWarehouseDefaultStatus(Long id, Boolean defaultStatus) { + // 1. 校验存在 + validateWarehouseExists(id); + + // 2.1 如果开启,则需要关闭所有其它的默认 + if (defaultStatus) { + ErpWarehouseDO warehouse = warehouseMapper.selectByDefaultStatus(); + if (warehouse != null) { + warehouseMapper.updateById(new ErpWarehouseDO().setId(warehouse.getId()).setDefaultStatus(false)); + } + } + // 2.2 更新对应的默认状态 + warehouseMapper.updateById(new ErpWarehouseDO().setId(id).setDefaultStatus(defaultStatus)); + } + + @Override + public void deleteWarehouse(Long id) { + // 校验存在 + validateWarehouseExists(id); + // 删除 + warehouseMapper.deleteById(id); + } + + private void validateWarehouseExists(Long id) { + if (warehouseMapper.selectById(id) == null) { + throw exception(WAREHOUSE_NOT_EXISTS); + } + } + + @Override + public ErpWarehouseDO getWarehouse(Long id) { + return warehouseMapper.selectById(id); + } + + @Override + public List validWarehouseList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + List list = warehouseMapper.selectBatchIds(ids); + Map warehouseMap = convertMap(list, ErpWarehouseDO::getId); + for (Long id : ids) { + ErpWarehouseDO warehouse = warehouseMap.get(id); + if (warehouseMap.get(id) == null) { + throw exception(WAREHOUSE_NOT_EXISTS); + } + if (CommonStatusEnum.isDisable(warehouse.getStatus())) { + throw exception(WAREHOUSE_NOT_ENABLE, warehouse.getName()); + } + } + return list; + } + + @Override + public List getWarehouseListByStatus(Integer status) { + return warehouseMapper.selectListByStatus(status); + } + + @Override + public List getWarehouseList(Collection ids) { + return warehouseMapper.selectBatchIds(ids); + } + + @Override + public PageResult getWarehousePage(ErpWarehousePageReqVO pageReqVO) { + return warehouseMapper.selectPage(pageReqVO); + } + +} \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml b/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml new file mode 100644 index 0000000000..e2b25992a4 --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpPurchaseStatisticsMapper.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpSaleStatisticsMapper.xml b/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpSaleStatisticsMapper.xml new file mode 100644 index 0000000000..8e74606c9e --- /dev/null +++ b/yudao-module-erp/yudao-module-erp-biz/src/main/resources/mapper/statistics/ErpSaleStatisticsMapper.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java index 471950017e..19aa4e7186 100644 --- a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java +++ b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java @@ -33,19 +33,19 @@ public interface ErrorCodeConstants { ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空"); // ========== 代码生成器 1-001-004-000 ========== - ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_003_001_000, "表定义已经存在"); - ErrorCode CODEGEN_IMPORT_TABLE_NULL = new ErrorCode(1_003_001_001, "导入的表不存在"); - ErrorCode CODEGEN_IMPORT_COLUMNS_NULL = new ErrorCode(1_003_001_002, "导入的字段不存在"); - ErrorCode CODEGEN_TABLE_NOT_EXISTS = new ErrorCode(1_003_001_004, "表定义不存在"); - ErrorCode CODEGEN_COLUMN_NOT_EXISTS = new ErrorCode(1_003_001_005, "字段义不存在"); - ErrorCode CODEGEN_SYNC_COLUMNS_NULL = new ErrorCode(1_003_001_006, "同步的字段不存在"); - ErrorCode CODEGEN_SYNC_NONE_CHANGE = new ErrorCode(1_003_001_007, "同步失败,不存在改变"); - ErrorCode CODEGEN_TABLE_INFO_TABLE_COMMENT_IS_NULL = new ErrorCode(1_003_001_008, "数据库的表注释未填写"); - ErrorCode CODEGEN_TABLE_INFO_COLUMN_COMMENT_IS_NULL = new ErrorCode(1_003_001_009, "数据库的表字段({})注释未填写"); - ErrorCode CODEGEN_MASTER_TABLE_NOT_EXISTS = new ErrorCode(1_003_001_010, "主表(id={})定义不存在,请检查"); - ErrorCode CODEGEN_SUB_COLUMN_NOT_EXISTS = new ErrorCode(1_003_001_011, "子表的字段(id={})不存在,请检查"); - ErrorCode CODEGEN_MASTER_GENERATION_FAIL_NO_SUB_TABLE = new ErrorCode(1_003_001_012, "主表生成代码失败,原因:它没有子表"); - ErrorCode CODEGEN_MASTER_GENERATION_FAIL_NO_SUB_COLUMN = new ErrorCode(1_003_001_013, "主表生成代码失败,原因:它的子表({})没有字段"); + ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_001_004_002, "表定义已经存在"); + ErrorCode CODEGEN_IMPORT_TABLE_NULL = new ErrorCode(1_001_004_001, "导入的表不存在"); + ErrorCode CODEGEN_IMPORT_COLUMNS_NULL = new ErrorCode(1_001_004_002, "导入的字段不存在"); + ErrorCode CODEGEN_TABLE_NOT_EXISTS = new ErrorCode(1_001_004_004, "表定义不存在"); + ErrorCode CODEGEN_COLUMN_NOT_EXISTS = new ErrorCode(1_001_004_005, "字段义不存在"); + ErrorCode CODEGEN_SYNC_COLUMNS_NULL = new ErrorCode(1_001_004_006, "同步的字段不存在"); + ErrorCode CODEGEN_SYNC_NONE_CHANGE = new ErrorCode(1_001_004_007, "同步失败,不存在改变"); + ErrorCode CODEGEN_TABLE_INFO_TABLE_COMMENT_IS_NULL = new ErrorCode(1_001_004_008, "数据库的表注释未填写"); + ErrorCode CODEGEN_TABLE_INFO_COLUMN_COMMENT_IS_NULL = new ErrorCode(1_001_004_009, "数据库的表字段({})注释未填写"); + ErrorCode CODEGEN_MASTER_TABLE_NOT_EXISTS = new ErrorCode(1_001_004_010, "主表(id={})定义不存在,请检查"); + ErrorCode CODEGEN_SUB_COLUMN_NOT_EXISTS = new ErrorCode(1_001_004_011, "子表的字段(id={})不存在,请检查"); + ErrorCode CODEGEN_MASTER_GENERATION_FAIL_NO_SUB_TABLE = new ErrorCode(1_001_004_012, "主表生成代码失败,原因:它没有子表"); + ErrorCode CODEGEN_MASTER_GENERATION_FAIL_NO_SUB_COLUMN = new ErrorCode(1_001_004_013, "主表生成代码失败,原因:它的子表({})没有字段"); // ========== 文件配置 1-001-006-000 ========== ErrorCode FILE_CONFIG_NOT_EXISTS = new ErrorCode(1_001_006_000, "文件配置不存在"); diff --git a/yudao-module-infra/yudao-module-infra-biz/pom.xml b/yudao-module-infra/yudao-module-infra-biz/pom.xml index 50b99a487f..2f1adccebd 100644 --- a/yudao-module-infra/yudao-module-infra-biz/pom.xml +++ b/yudao-module-infra/yudao-module-infra-biz/pom.xml @@ -110,8 +110,21 @@ - cn.iocoder.boot - yudao-spring-boot-starter-file + commons-net + commons-net + + + com.jcraft + jsch + + + io.minio + minio + + + + org.apache.tika + tika-core diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java index e3bb6fe889..e344f82e15 100755 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java index c30a9d5c1b..5d2d75cc4b 100755 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java @@ -1,12 +1,20 @@ package cn.iocoder.yudao.module.infra.dal.dataobject.file; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; -import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.db.DBFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.enums.FileStorageEnum; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; -import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler; +import com.fasterxml.jackson.core.type.TypeReference; import lombok.*; /** @@ -52,7 +60,42 @@ public class FileConfigDO extends BaseDO { /** * 支付渠道配置 */ - @TableField(typeHandler = JacksonTypeHandler.class) + @TableField(typeHandler = FileClientConfigTypeHandler.class) private FileClientConfig config; + public static class FileClientConfigTypeHandler extends AbstractJsonTypeHandler { + + @Override + protected Object parse(String json) { + FileClientConfig config = JsonUtils.parseObjectQuietly(json, new TypeReference() {}); + if (config != null) { + return config; + } + + // 兼容老版本的包路径 + String className = JsonUtils.parseObject(json, "@class", String.class); + className = StrUtil.subAfter(className, ".", true); + switch (className) { + case "DBFileClientConfig": + return JsonUtils.parseObject2(json, DBFileClientConfig.class); + case "FtpFileClientConfig": + return JsonUtils.parseObject2(json, FtpFileClientConfig.class); + case "LocalFileClientConfig": + return JsonUtils.parseObject2(json, LocalFileClientConfig.class); + case "SftpFileClientConfig": + return JsonUtils.parseObject2(json, SftpFileClientConfig.class); + case "S3FileClientConfig": + return JsonUtils.parseObject2(json, S3FileClientConfig.class); + default: + throw new IllegalArgumentException("未知的 FileClientConfig 类型:" + json); + } + } + + @Override + protected String toJson(Object obj) { + return JsonUtils.toJsonString(obj); + } + + } + } diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java index e19a1ce9f6..eda8a72649 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.infra.dal.dataobject.file; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.infra.framework.file.core.client.db.DBFileClient; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; @@ -10,7 +11,7 @@ import lombok.*; /** * 文件内容表 * - * 专门用于存储 {@link cn.iocoder.yudao.framework.file.core.client.db.DBFileClient} 的文件内容 + * 专门用于存储 {@link DBFileClient} 的文件内容 * * @author 芋道源码 */ diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentDAOImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentDAOImpl.java deleted file mode 100644 index 2492c803d0..0000000000 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentDAOImpl.java +++ /dev/null @@ -1,46 +0,0 @@ -package cn.iocoder.yudao.module.infra.dal.mysql.file; - -import cn.hutool.core.collection.CollUtil; -import cn.iocoder.yudao.framework.file.core.client.db.DBFileContentFrameworkDAO; -import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import org.springframework.stereotype.Repository; - -import javax.annotation.Resource; -import java.util.List; -import java.util.Optional; - -@Repository -public class FileContentDAOImpl implements DBFileContentFrameworkDAO { - - @Resource - private FileContentMapper fileContentMapper; - - @Override - public void insert(Long configId, String path, byte[] content) { - FileContentDO entity = new FileContentDO().setConfigId(configId) - .setPath(path).setContent(content); - fileContentMapper.insert(entity); - } - - @Override - public void delete(Long configId, String path) { - fileContentMapper.delete(buildQuery(configId, path)); - } - - @Override - public byte[] selectContent(Long configId, String path) { - List list = fileContentMapper.selectList( - buildQuery(configId, path).select(FileContentDO::getContent).orderByDesc(FileContentDO::getId)); - return Optional.ofNullable(CollUtil.getFirst(list)) - .map(FileContentDO::getContent) - .orElse(null); - } - - private LambdaQueryWrapper buildQuery(Long configId, String path) { - return new LambdaQueryWrapper() - .eq(FileContentDO::getConfigId, configId) - .eq(FileContentDO::getPath, path); - } - -} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java index 501979dbe9..3d6ec5155f 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java @@ -1,9 +1,25 @@ package cn.iocoder.yudao.module.infra.dal.mysql.file; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import java.util.List; + @Mapper public interface FileContentMapper extends BaseMapper { + + default void deleteByConfigIdAndPath(Long configId, String path) { + this.delete(new LambdaQueryWrapper() + .eq(FileContentDO::getConfigId, configId) + .eq(FileContentDO::getPath, path)); + } + + default List selectListByConfigIdAndPath(Long configId, String path) { + return selectList(new LambdaQueryWrapper() + .eq(FileContentDO::getConfigId, configId) + .eq(FileContentDO::getPath, path)); + } + } diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java new file mode 100644 index 0000000000..e0d3f8e74f --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.infra.framework.file.config; + +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactory; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactoryImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 文件配置类 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class YudaoFileAutoConfiguration { + + @Bean + public FileClientFactory fileClientFactory() { + return new FileClientFactoryImpl(); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/AbstractFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java similarity index 95% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/AbstractFileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java index b21ccc0e1c..3c7883b83d 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/AbstractFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.file.core.client; +package cn.iocoder.yudao.module.infra.framework.file.core.client; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java similarity index 87% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index 2944ca72cc..053b3c5101 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -1,6 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client; +package cn.iocoder.yudao.module.infra.framework.file.core.client; -import cn.iocoder.yudao.framework.file.core.client.s3.FilePresignedUrlRespDTO; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; /** * 文件客户端 diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientConfig.java similarity index 87% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientConfig.java index 9461c05d86..ed092759a6 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientConfig.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.file.core.client; +package cn.iocoder.yudao.module.infra.framework.file.core.client; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactory.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactory.java similarity index 67% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactory.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactory.java index db90f48927..e4c84ed10e 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactory.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactory.java @@ -1,4 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client; +package cn.iocoder.yudao.module.infra.framework.file.core.client; + +import cn.iocoder.yudao.module.infra.framework.file.core.enums.FileStorageEnum; public interface FileClientFactory { @@ -14,7 +16,7 @@ public interface FileClientFactory { * 创建文件客户端 * * @param configId 配置编号 - * @param storage 存储器的枚举 {@link cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum} + * @param storage 存储器的枚举 {@link FileStorageEnum} * @param config 文件配置 */ void createOrUpdateFileClient(Long configId, Integer storage, Config config); diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactoryImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactoryImpl.java similarity index 92% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactoryImpl.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactoryImpl.java index 980f8a3102..2bd54648fe 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactoryImpl.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClientFactoryImpl.java @@ -1,8 +1,8 @@ -package cn.iocoder.yudao.framework.file.core.client; +package cn.iocoder.yudao.module.infra.framework.file.core.client; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ReflectUtil; -import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum; +import cn.iocoder.yudao.module.infra.framework.file.core.enums.FileStorageEnum; import lombok.extern.slf4j.Slf4j; import java.util.concurrent.ConcurrentHashMap; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java new file mode 100644 index 0000000000..0a050d3251 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.client.db; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO; +import cn.iocoder.yudao.module.infra.dal.mysql.file.FileContentMapper; +import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; + +import java.util.Comparator; +import java.util.List; + +/** + * 基于 DB 存储的文件客户端的配置类 + * + * @author 芋道源码 + */ +public class DBFileClient extends AbstractFileClient { + + private FileContentMapper fileContentMapper; + + public DBFileClient(Long id, DBFileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + fileContentMapper = SpringUtil.getBean(FileContentMapper.class); + } + + @Override + public String upload(byte[] content, String path, String type) { + FileContentDO contentDO = new FileContentDO().setConfigId(getId()) + .setPath(path).setContent(content); + fileContentMapper.insert(contentDO); + // 拼接返回路径 + return super.formatFileUrl(config.getDomain(), path); + } + + @Override + public void delete(String path) { + fileContentMapper.deleteByConfigIdAndPath(getId(), path); + } + + @Override + public byte[] getContent(String path) { + List list = fileContentMapper.selectListByConfigIdAndPath(getId(), path); + if (CollUtil.isEmpty(list)) { + return null; + } + // 排序后,拿 id 最大的,即最后上传的 + list.sort(Comparator.comparing(FileContentDO::getId)); + return CollUtil.getLast(list).getContent(); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClientConfig.java similarity index 74% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClientConfig.java index 65d837b440..11830e4a21 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClientConfig.java @@ -1,6 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client.db; +package cn.iocoder.yudao.module.infra.framework.file.core.client.db; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import lombok.Data; import org.hibernate.validator.constraints.URL; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java similarity index 94% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java index 796044f3f8..062d838183 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.file.core.client.ftp; +package cn.iocoder.yudao.module.infra.framework.file.core.client.ftp; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.CharsetUtil; @@ -6,7 +6,7 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.extra.ftp.Ftp; import cn.hutool.extra.ftp.FtpException; import cn.hutool.extra.ftp.FtpMode; -import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClientConfig.java similarity index 88% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClientConfig.java index 5ccf86cd4d..d3ba00c269 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClientConfig.java @@ -1,6 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client.ftp; +package cn.iocoder.yudao.module.infra.framework.file.core.client.ftp; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import lombok.Data; import org.hibernate.validator.constraints.URL; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java similarity index 88% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java index cac13f1fb0..a9196903ea 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java @@ -1,7 +1,7 @@ -package cn.iocoder.yudao.framework.file.core.client.local; +package cn.iocoder.yudao.module.infra.framework.file.core.client.local; import cn.hutool.core.io.FileUtil; -import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.File; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClientConfig.java similarity index 77% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClientConfig.java index 1f595ed893..b4320135d8 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClientConfig.java @@ -1,6 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client.local; +package cn.iocoder.yudao.module.infra.framework.file.core.client.local; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import lombok.Data; import org.hibernate.validator.constraints.URL; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/FilePresignedUrlRespDTO.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java similarity index 86% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/FilePresignedUrlRespDTO.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java index 6048494ed7..6a1258e9e0 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/FilePresignedUrlRespDTO.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.file.core.client.s3; +package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java similarity index 89% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index e7b470badc..67995caa28 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,18 +1,15 @@ -package cn.iocoder.yudao.framework.file.core.client.s3; +package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; -import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import io.minio.*; import io.minio.http.Method; import java.io.ByteArrayInputStream; import java.util.concurrent.TimeUnit; -import static cn.iocoder.yudao.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_ALIYUN; -import static cn.iocoder.yudao.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_TENCENT; - /** * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 *

@@ -76,15 +73,15 @@ public class S3FileClient extends AbstractFileClient { */ private String buildRegion() { // 阿里云必须有 region,否则会报错 - if (config.getEndpoint().contains(ENDPOINT_ALIYUN)) { + if (config.getEndpoint().contains(S3FileClientConfig.ENDPOINT_ALIYUN)) { return StrUtil.subBefore(config.getEndpoint(), '.', false) .replaceAll("-internal", "")// 去除内网 Endpoint 的后缀 .replaceAll("https://", ""); } // 腾讯云必须有 region,否则会报错 - if (config.getEndpoint().contains(ENDPOINT_TENCENT)) { + if (config.getEndpoint().contains(S3FileClientConfig.ENDPOINT_TENCENT)) { return StrUtil.subAfter(config.getEndpoint(), "cos.", false) - .replaceAll("." + ENDPOINT_TENCENT, ""); // 去除 Endpoint + .replaceAll("." + S3FileClientConfig.ENDPOINT_TENCENT, ""); // 去除 Endpoint } return null; } diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java similarity index 94% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java index 0c46e8aa65..fccfa387f6 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java @@ -1,7 +1,7 @@ -package cn.iocoder.yudao.framework.file.core.client.s3; +package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import org.hibernate.validator.constraints.URL; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClient.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java similarity index 91% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClient.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java index facddcea07..3ebe782159 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java @@ -1,9 +1,9 @@ -package cn.iocoder.yudao.framework.file.core.client.sftp; +package cn.iocoder.yudao.module.infra.framework.file.core.client.sftp; import cn.hutool.core.io.FileUtil; import cn.hutool.extra.ssh.Sftp; import cn.iocoder.yudao.framework.common.util.io.FileUtils; -import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.File; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientConfig.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClientConfig.java similarity index 86% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientConfig.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClientConfig.java index 1a976f5d9f..5af39614d8 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientConfig.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClientConfig.java @@ -1,6 +1,6 @@ -package cn.iocoder.yudao.framework.file.core.client.sftp; +package cn.iocoder.yudao.module.infra.framework.file.core.client.sftp; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; import lombok.Data; import org.hibernate.validator.constraints.URL; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/enums/FileStorageEnum.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/enums/FileStorageEnum.java new file mode 100644 index 0000000000..866c43b655 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/enums/FileStorageEnum.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.db.DBFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.db.DBFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileClientConfig; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件存储器枚举 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Getter +public enum FileStorageEnum { + + DB(1, DBFileClientConfig.class, DBFileClient.class), + + LOCAL(10, LocalFileClientConfig.class, LocalFileClient.class), + FTP(11, FtpFileClientConfig.class, FtpFileClient.class), + SFTP(12, SftpFileClientConfig.class, SftpFileClient.class), + + S3(20, S3FileClientConfig.class, S3FileClient.class), + ; + + /** + * 存储器 + */ + private final Integer storage; + + /** + * 配置类 + */ + private final Class configClass; + /** + * 客户端类 + */ + private final Class clientClass; + + public static FileStorageEnum getByStorage(Integer storage) { + return ArrayUtil.firstMatch(o -> o.getStorage().equals(storage), values()); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/utils/FileTypeUtils.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java similarity index 95% rename from yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/utils/FileTypeUtils.java rename to yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java index 8b99227b1e..ea71f58810 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/utils/FileTypeUtils.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.framework.file.core.utils; +package cn.iocoder.yudao.module.infra.framework.file.core.utils; import com.alibaba.ttl.TransmittableThreadLocal; import lombok.SneakyThrows; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/package-info.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/package-info.java new file mode 100644 index 0000000000..04b68677b9 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/package-info.java @@ -0,0 +1,12 @@ +/** + * 文件客户端,支持多种存储器 + * + * 1. local:本地磁盘 + * 2. ftp:FTP 服务器 + * 3. sftp:SFTP 服务器 + * 4. db:数据库 + * 5. s3:支持 S3 协议的云存储服务,例如说 MinIO、阿里云、华为云、腾讯云、七牛云等等 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.infra.framework.file; \ No newline at end of file diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java index e430880982..23a7228cc1 100755 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.module.infra.service.file; import cn.iocoder.yudao.framework.common.pojo.PageResult; -import cn.iocoder.yudao.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigSaveReqVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java index 94ec1d9b92..7d84bffc9f 100755 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java @@ -6,10 +6,10 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; -import cn.iocoder.yudao.framework.file.core.client.FileClient; -import cn.iocoder.yudao.framework.file.core.client.FileClientConfig; -import cn.iocoder.yudao.framework.file.core.client.FileClientFactory; -import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactory; +import cn.iocoder.yudao.module.infra.framework.file.core.enums.FileStorageEnum; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigSaveReqVO; import cn.iocoder.yudao.module.infra.convert.file.FileConfigConvert; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 05e38e957f..8efa5fe641 100644 --- a/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -5,9 +5,9 @@ import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.io.FileUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.framework.file.core.client.FileClient; -import cn.iocoder.yudao.framework.file.core.client.s3.FilePresignedUrlRespDTO; -import cn.iocoder.yudao.framework.file.core.utils.FileTypeUtils; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; +import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/ftp/FtpFileClientTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/ftp/FtpFileClientTest.java new file mode 100644 index 0000000000..b8876f7fc4 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/ftp/FtpFileClientTest.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.ftp; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.extra.ftp.FtpMode; +import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClientConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class FtpFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + FtpFileClientConfig config = new FtpFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/home/ftp"); + config.setHost("kanchai.club"); + config.setPort(221); + config.setUsername(""); + config.setPassword(""); + config.setMode(FtpMode.Passive.name()); + FtpFileClient client = new FtpFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + if (false) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes); + } + if (false) { + client.delete(path); + } + } + +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java new file mode 100644 index 0000000000..7c622a5306 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.local; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class LocalFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + LocalFileClientConfig config = new LocalFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/Users/yunai/file_test"); + LocalFileClient client = new LocalFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + client.delete(path); + } + +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java new file mode 100644 index 0000000000..1933e98587 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.s3; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import javax.validation.Validation; + +public class S3FileClientTest { + + @Test + @Disabled // MinIO,如果要集成测试,可以注释本行 + public void testMinIO() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey("admin"); + config.setAccessSecret("password"); + config.setBucket("yudaoyuanma"); + config.setDomain(null); + // 默认 9000 endpoint + config.setEndpoint("http://127.0.0.1:9000"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 阿里云 OSS,如果要集成测试,可以注释本行 + public void testAliyun() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey(System.getenv("ALIYUN_ACCESS_KEY")); + config.setAccessSecret(System.getenv("ALIYUN_SECRET_KEY")); + config.setBucket("yunai-aoteman"); + config.setDomain(null); // 如果有自定义域名,则可以设置。http://ali-oss.iocoder.cn + // 默认北京的 endpoint + config.setEndpoint("oss-cn-beijing.aliyuncs.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 腾讯云 COS,如果要集成测试,可以注释本行 + public void testQCloud() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey(System.getenv("QCLOUD_ACCESS_KEY")); + config.setAccessSecret(System.getenv("QCLOUD_SECRET_KEY")); + config.setBucket("aoteman-1255880240"); + config.setDomain(null); // 如果有自定义域名,则可以设置。http://tengxun-oss.iocoder.cn + // 默认上海的 endpoint + config.setEndpoint("cos.ap-shanghai.myqcloud.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 七牛云存储,如果要集成测试,可以注释本行 + public void testQiniu() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 +// config.setAccessKey(System.getenv("QINIU_ACCESS_KEY")); +// config.setAccessSecret(System.getenv("QINIU_SECRET_KEY")); + config.setAccessKey("b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8"); + config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP"); + config.setBucket("ruoyi-vue-pro"); + config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn + // 默认上海的 endpoint + config.setEndpoint("s3-cn-south-1.qiniucs.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 华为云存储,如果要集成测试,可以注释本行 + public void testHuaweiCloud() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 +// config.setAccessKey(System.getenv("HUAWEI_CLOUD_ACCESS_KEY")); +// config.setAccessSecret(System.getenv("HUAWEI_CLOUD_SECRET_KEY")); + config.setBucket("yudao"); + config.setDomain(null); // 如果有自定义域名,则可以设置。 + // 默认上海的 endpoint + config.setEndpoint("obs.cn-east-3.myhuaweicloud.com"); + + // 执行上传 + testExecuteUpload(config); + } + + private void testExecuteUpload(S3FileClientConfig config) throws Exception { + // 校验配置 + ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config); + // 创建 Client + S3FileClient client = new S3FileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + // 读取文件 + if (true) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes.length); + } + // 删除文件 + if (false) { + client.delete(path); + } + } + +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/sftp/SftpFileClientTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/sftp/SftpFileClientTest.java new file mode 100644 index 0000000000..1e00cf196f --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/sftp/SftpFileClientTest.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.sftp; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileClientConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class SftpFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + SftpFileClientConfig config = new SftpFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/home/ftp"); + config.setHost("kanchai.club"); + config.setPort(222); + config.setUsername(""); + config.setPassword(""); + SftpFileClient client = new SftpFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + if (false) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes); + } + if (false) { + client.delete(path); + } + } + +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java new file mode 100644 index 0000000000..c3987a829c --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImplTest.java @@ -0,0 +1,558 @@ +package cn.iocoder.yudao.module.infra.service.codegen; + +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.infra.controller.admin.codegen.vo.CodegenCreateListReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.codegen.vo.CodegenUpdateReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.codegen.vo.column.CodegenColumnSaveReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.codegen.vo.table.CodegenTablePageReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.codegen.vo.table.DatabaseTableRespVO; +import cn.iocoder.yudao.module.infra.dal.dataobject.codegen.CodegenColumnDO; +import cn.iocoder.yudao.module.infra.dal.dataobject.codegen.CodegenTableDO; +import cn.iocoder.yudao.module.infra.dal.mysql.codegen.CodegenColumnMapper; +import cn.iocoder.yudao.module.infra.dal.mysql.codegen.CodegenTableMapper; +import cn.iocoder.yudao.module.infra.enums.codegen.CodegenFrontTypeEnum; +import cn.iocoder.yudao.module.infra.enums.codegen.CodegenSceneEnum; +import cn.iocoder.yudao.module.infra.enums.codegen.CodegenTemplateTypeEnum; +import cn.iocoder.yudao.module.infra.framework.codegen.config.CodegenProperties; +import cn.iocoder.yudao.module.infra.service.codegen.inner.CodegenBuilder; +import cn.iocoder.yudao.module.infra.service.codegen.inner.CodegenEngine; +import cn.iocoder.yudao.module.infra.service.db.DatabaseTableService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.generator.config.po.TableField; +import com.baomidou.mybatisplus.generator.config.po.TableInfo; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import javax.annotation.Resource; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link CodegenServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(CodegenServiceImpl.class) +public class CodegenServiceImplTest extends BaseDbUnitTest { + + @Resource + private CodegenServiceImpl codegenService; + + @Resource + private CodegenTableMapper codegenTableMapper; + @Resource + private CodegenColumnMapper codegenColumnMapper; + + @MockBean + private DatabaseTableService databaseTableService; + + @MockBean + private AdminUserApi userApi; + + @MockBean + private CodegenBuilder codegenBuilder; + @MockBean + private CodegenEngine codegenEngine; + + @MockBean + private CodegenProperties codegenProperties; + + @Test + public void testCreateCodegenList() { + // 准备参数 + Long userId = randomLongId(); + CodegenCreateListReqVO reqVO = randomPojo(CodegenCreateListReqVO.class, + o -> o.setDataSourceConfigId(1L).setTableNames(Collections.singletonList("t_yunai"))); + // mock 方法(TableInfo) + TableInfo tableInfo = mock(TableInfo.class); + when(databaseTableService.getTable(eq(1L), eq("t_yunai"))) + .thenReturn(tableInfo); + when(tableInfo.getComment()).thenReturn("芋艿"); + // mock 方法(TableInfo fields) + TableField field01 = mock(TableField.class); + when(field01.getComment()).thenReturn("主键"); + TableField field02 = mock(TableField.class); + when(field02.getComment()).thenReturn("名字"); + List fields = Arrays.asList(field01, field02); + when(tableInfo.getFields()).thenReturn(fields); + // mock 方法(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class); + when(codegenBuilder.buildTable(same(tableInfo))).thenReturn(table); + // mock 方法(AdminUserRespDTO) + AdminUserRespDTO user = randomPojo(AdminUserRespDTO.class, o -> o.setNickname("芋头")); + when(userApi.getUser(eq(userId))).thenReturn(user); + // mock 方法(CodegenColumnDO) + List columns = randomPojoList(CodegenColumnDO.class); + when(codegenBuilder.buildColumns(eq(table.getId()), same(fields))) + .thenReturn(columns); + // mock 方法(CodegenProperties) + when(codegenProperties.getFrontType()).thenReturn(CodegenFrontTypeEnum.VUE3.getType()); + + // 调用 + List result = codegenService.createCodegenList(userId, reqVO); + // 断言 + assertEquals(1, result.size()); + // 断言(CodegenTableDO) + CodegenTableDO dbTable = codegenTableMapper.selectList().get(0); + assertPojoEquals(table, dbTable); + assertEquals(1L, dbTable.getDataSourceConfigId()); + assertEquals(CodegenSceneEnum.ADMIN.getScene(), dbTable.getScene()); + assertEquals(CodegenFrontTypeEnum.VUE3.getType(), dbTable.getFrontType()); + assertEquals("芋头", dbTable.getAuthor()); + // 断言(CodegenColumnDO) + List dbColumns = codegenColumnMapper.selectList(); + assertEquals(columns.size(), dbColumns.size()); + assertTrue(dbColumns.get(0).getPrimaryKey()); + for (int i = 0; i < dbColumns.size(); i++) { + assertPojoEquals(columns.get(i), dbColumns.get(i)); + } + } + + @Test + public void testValidateTableInfo() { + // 情况一 + assertServiceException(() -> codegenService.validateTableInfo(null), + CODEGEN_IMPORT_TABLE_NULL); + // 情况二 + TableInfo tableInfo = mock(TableInfo.class); + assertServiceException(() -> codegenService.validateTableInfo(tableInfo), + CODEGEN_TABLE_INFO_TABLE_COMMENT_IS_NULL); + // 情况三 + when(tableInfo.getComment()).thenReturn("芋艿"); + assertServiceException(() -> codegenService.validateTableInfo(tableInfo), + CODEGEN_IMPORT_COLUMNS_NULL); + // 情况四 + TableField field = mock(TableField.class); + when(field.getName()).thenReturn("name"); + when(tableInfo.getFields()).thenReturn(Collections.singletonList(field)); + assertServiceException(() -> codegenService.validateTableInfo(tableInfo), + CODEGEN_TABLE_INFO_COLUMN_COMMENT_IS_NULL, field.getName()); + } + + @Test + public void testUpdateCodegen_notExists() { + // 准备参数 + CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class); + // mock 方法 + + // 调用,并断言 + assertServiceException(() -> codegenService.updateCodegen(updateReqVO), + CODEGEN_TABLE_NOT_EXISTS); + } + + @Test + public void testUpdateCodegen_sub_masterNotExists() { + // mock 数据 + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setTemplateType(CodegenTemplateTypeEnum.SUB.getType()) + .setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table); + // 准备参数 + CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class, + o -> o.getTable().setId(table.getId()) + .setTemplateType(CodegenTemplateTypeEnum.SUB.getType())); + + // 调用,并断言 + assertServiceException(() -> codegenService.updateCodegen(updateReqVO), + CODEGEN_MASTER_TABLE_NOT_EXISTS, updateReqVO.getTable().getMasterTableId()); + } + + @Test + public void testUpdateCodegen_sub_columnNotExists() { + // mock 数据 + CodegenTableDO subTable = randomPojo(CodegenTableDO.class, + o -> o.setTemplateType(CodegenTemplateTypeEnum.SUB.getType()) + .setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(subTable); + // mock 数据(master) + CodegenTableDO masterTable = randomPojo(CodegenTableDO.class, + o -> o.setTemplateType(CodegenTemplateTypeEnum.MASTER_ERP.getType()) + .setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(masterTable); + // 准备参数 + CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class, + o -> o.getTable().setId(subTable.getId()) + .setTemplateType(CodegenTemplateTypeEnum.SUB.getType()) + .setMasterTableId(masterTable.getId())); + + // 调用,并断言 + assertServiceException(() -> codegenService.updateCodegen(updateReqVO), + CODEGEN_SUB_COLUMN_NOT_EXISTS, updateReqVO.getTable().getSubJoinColumnId()); + } + + @Test + public void testUpdateCodegen_success() { + // mock 数据 + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setTemplateType(CodegenTemplateTypeEnum.ONE.getType()) + .setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table); + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column01); + CodegenColumnDO column02 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column02); + // 准备参数 + CodegenUpdateReqVO updateReqVO = randomPojo(CodegenUpdateReqVO.class, + o -> o.getTable().setId(table.getId()) + .setTemplateType(CodegenTemplateTypeEnum.ONE.getType()) + .setScene(CodegenSceneEnum.ADMIN.getScene())); + CodegenColumnSaveReqVO columnVO01 = randomPojo(CodegenColumnSaveReqVO.class, + o -> o.setId(column01.getId()).setTableId(table.getId())); + CodegenColumnSaveReqVO columnVO02 = randomPojo(CodegenColumnSaveReqVO.class, + o -> o.setId(column02.getId()).setTableId(table.getId())); + updateReqVO.setColumns(Arrays.asList(columnVO01, columnVO02)); + + // 调用 + codegenService.updateCodegen(updateReqVO); + // 断言 + CodegenTableDO dbTable = codegenTableMapper.selectById(table.getId()); + assertPojoEquals(updateReqVO.getTable(), dbTable); + List dbColumns = codegenColumnMapper.selectList(); + assertEquals(2, dbColumns.size()); + assertPojoEquals(columnVO01, dbColumns.get(0)); + assertPojoEquals(columnVO02, dbColumns.get(1)); + } + + @Test + @Disabled // TODO @芋艿:这个单测会随机性失败,需要定位下; + public void testSyncCodegenFromDB() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, o -> o.setTableName("t_yunai") + .setDataSourceConfigId(1L).setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table); + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId()) + .setColumnName("id")); + codegenColumnMapper.insert(column01); + CodegenColumnDO column02 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId()) + .setColumnName("name")); + codegenColumnMapper.insert(column02); + // 准备参数 + Long tableId = table.getId(); + // mock 方法(TableInfo) + TableInfo tableInfo = mock(TableInfo.class); + when(databaseTableService.getTable(eq(1L), eq("t_yunai"))) + .thenReturn(tableInfo); + when(tableInfo.getComment()).thenReturn("芋艿"); + // mock 方法(TableInfo fields) + TableField field01 = mock(TableField.class); + when(field01.getComment()).thenReturn("主键"); + TableField field03 = mock(TableField.class); + when(field03.getComment()).thenReturn("分类"); + List fields = Arrays.asList(field01, field03); + when(tableInfo.getFields()).thenReturn(fields); + when(databaseTableService.getTable(eq(1L), eq("t_yunai"))) + .thenReturn(tableInfo); + // mock 方法(CodegenTableDO) + List newColumns = randomPojoList(CodegenColumnDO.class); + when(codegenBuilder.buildColumns(eq(table.getId()), argThat(tableFields -> { + assertEquals(2, tableFields.size()); + assertSame(tableInfo.getFields(), tableFields); + return true; + }))).thenReturn(newColumns); + + // 调用 + codegenService.syncCodegenFromDB(tableId); + // 断言 + List dbColumns = codegenColumnMapper.selectList(); + assertEquals(newColumns.size(), dbColumns.size()); + assertPojoEquals(newColumns.get(0), dbColumns.get(0)); + assertPojoEquals(newColumns.get(1), dbColumns.get(1)); + } + + @Test + public void testDeleteCodegen_notExists() { + assertServiceException(() -> codegenService.deleteCodegen(randomLongId()), + CODEGEN_TABLE_NOT_EXISTS); + } + + @Test + public void testDeleteCodegen_success() { + // mock 数据 + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table); + CodegenColumnDO column = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column); + // 准备参数 + Long tableId = table.getId(); + + // 调用 + codegenService.deleteCodegen(tableId); + // 断言 + assertNull(codegenTableMapper.selectById(tableId)); + assertEquals(0, codegenColumnMapper.selectList().size()); + } + + @Test + public void testGetCodegenTableList() { + // mock 数据 + CodegenTableDO table01 = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table01); + CodegenTableDO table02 = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(table02); + // 准备参数 + Long dataSourceConfigId = table01.getDataSourceConfigId(); + + // 调用 + List result = codegenService.getCodegenTableList(dataSourceConfigId); + // 断言 + assertEquals(1, result.size()); + assertPojoEquals(table01, result.get(0)); + } + + @Test + public void testGetCodegenTablePage() { + // mock 数据 + CodegenTableDO tableDO = randomPojo(CodegenTableDO.class, o -> { + o.setTableName("t_yunai"); + o.setTableComment("芋艿"); + o.setClassName("SystemYunai"); + o.setCreateTime(buildTime(2021, 3, 10)); + }).setScene(CodegenSceneEnum.ADMIN.getScene()); + codegenTableMapper.insert(tableDO); + // 测试 tableName 不匹配 + codegenTableMapper.insert(cloneIgnoreId(tableDO, o -> o.setTableName(randomString()))); + // 测试 tableComment 不匹配 + codegenTableMapper.insert(cloneIgnoreId(tableDO, o -> o.setTableComment(randomString()))); + // 测试 className 不匹配 + codegenTableMapper.insert(cloneIgnoreId(tableDO, o -> o.setClassName(randomString()))); + // 测试 createTime 不匹配 + codegenTableMapper.insert(cloneIgnoreId(tableDO, logDO -> logDO.setCreateTime(buildTime(2021, 4, 10)))); + // 准备参数 + CodegenTablePageReqVO reqVO = new CodegenTablePageReqVO(); + reqVO.setTableName("yunai"); + reqVO.setTableComment("芋"); + reqVO.setClassName("Yunai"); + reqVO.setCreateTime(buildBetweenTime(2021, 3, 1, 2021, 3, 31)); + + // 调用 + PageResult pageResult = codegenService.getCodegenTablePage(reqVO); + // 断言,只查到了一条符合条件的 + assertEquals(1, pageResult.getTotal()); + assertEquals(1, pageResult.getList().size()); + assertPojoEquals(tableDO, pageResult.getList().get(0)); + } + + @Test + public void testGetCodegenTable() { + // mock 数据 + CodegenTableDO tableDO = randomPojo(CodegenTableDO.class, o -> o.setScene(CodegenSceneEnum.ADMIN.getScene())); + codegenTableMapper.insert(tableDO); + // 准备参数 + Long id = tableDO.getId(); + + // 调用 + CodegenTableDO result = codegenService.getCodegenTable(id); + // 断言 + assertPojoEquals(tableDO, result); + } + + @Test + public void testGetCodegenColumnListByTableId() { + // mock 数据 + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class); + codegenColumnMapper.insert(column01); + CodegenColumnDO column02 = randomPojo(CodegenColumnDO.class); + codegenColumnMapper.insert(column02); + // 准备参数 + Long tableId = column01.getTableId(); + + // 调用 + List result = codegenService.getCodegenColumnListByTableId(tableId); + // 断言 + assertEquals(1, result.size()); + assertPojoEquals(column01, result.get(0)); + } + + @Test + public void testGenerationCodes_tableNotExists() { + assertServiceException(() -> codegenService.generationCodes(randomLongId()), + CODEGEN_TABLE_NOT_EXISTS); + } + + @Test + public void testGenerationCodes_columnNotExists() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.MASTER_NORMAL.getType())); + codegenTableMapper.insert(table); + // 准备参数 + Long tableId = table.getId(); + + // 调用,并断言 + assertServiceException(() -> codegenService.generationCodes(tableId), + CODEGEN_COLUMN_NOT_EXISTS); + } + + @Test + public void testGenerationCodes_sub_tableNotExists() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.MASTER_NORMAL.getType())); + codegenTableMapper.insert(table); + // mock 数据(CodegenColumnDO) + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column01); + // 准备参数 + Long tableId = table.getId(); + + // 调用,并断言 + assertServiceException(() -> codegenService.generationCodes(tableId), + CODEGEN_MASTER_GENERATION_FAIL_NO_SUB_TABLE); + } + + @Test + public void testGenerationCodes_sub_columnNotExists() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.MASTER_NORMAL.getType())); + codegenTableMapper.insert(table); + // mock 数据(CodegenColumnDO) + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column01); + // mock 数据(sub CodegenTableDO) + CodegenTableDO subTable = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.SUB.getType()) + .setMasterTableId(table.getId())); + codegenTableMapper.insert(subTable); + // 准备参数 + Long tableId = table.getId(); + + // 调用,并断言 + assertServiceException(() -> codegenService.generationCodes(tableId), + CODEGEN_SUB_COLUMN_NOT_EXISTS, subTable.getId()); + } + + @Test + public void testGenerationCodes_one_success() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.ONE.getType())); + codegenTableMapper.insert(table); + // mock 数据(CodegenColumnDO) + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column01); + CodegenColumnDO column02 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column02); + // mock 执行生成 + Map codes = MapUtil.of(randomString(), randomString()); + when(codegenEngine.execute(eq(table), argThat(columns -> { + assertEquals(2, columns.size()); + assertEquals(column01, columns.get(0)); + assertEquals(column02, columns.get(1)); + return true; + }), isNull(), isNull())).thenReturn(codes); + // 准备参数 + Long tableId = table.getId(); + + // 调用 + Map result = codegenService.generationCodes(tableId); + // 断言 + assertSame(codes, result); + } + + @Test + public void testGenerationCodes_master_success() { + // mock 数据(CodegenTableDO) + CodegenTableDO table = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.MASTER_NORMAL.getType())); + codegenTableMapper.insert(table); + // mock 数据(CodegenColumnDO) + CodegenColumnDO column01 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column01); + CodegenColumnDO column02 = randomPojo(CodegenColumnDO.class, o -> o.setTableId(table.getId())); + codegenColumnMapper.insert(column02); + // mock 数据(sub CodegenTableDO) + CodegenTableDO subTable = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTemplateType(CodegenTemplateTypeEnum.SUB.getType()) + .setMasterTableId(table.getId()) + .setSubJoinColumnId(1024L)); + codegenTableMapper.insert(subTable); + // mock 数据(sub CodegenColumnDO) + CodegenColumnDO subColumn01 = randomPojo(CodegenColumnDO.class, o -> o.setId(1024L).setTableId(subTable.getId())); + codegenColumnMapper.insert(subColumn01); + // mock 执行生成 + Map codes = MapUtil.of(randomString(), randomString()); + when(codegenEngine.execute(eq(table), argThat(columns -> { + assertEquals(2, columns.size()); + assertEquals(column01, columns.get(0)); + assertEquals(column02, columns.get(1)); + return true; + }), argThat(tables -> { + assertEquals(1, tables.size()); + assertPojoEquals(subTable, tables.get(0)); + return true; + }), argThat(columns -> { + assertEquals(1, columns.size()); + assertPojoEquals(subColumn01, columns.size()); + return true; + }))).thenReturn(codes); + // 准备参数 + Long tableId = table.getId(); + + // 调用 + Map result = codegenService.generationCodes(tableId); + // 断言 + assertSame(codes, result); + } + + @Test + public void testGetDatabaseTableList() { + // 准备参数 + Long dataSourceConfigId = randomLongId(); + String name = randomString(); + String comment = randomString(); + // mock 方法 + TableInfo tableInfo01 = mock(TableInfo.class); + when(tableInfo01.getName()).thenReturn("t_yunai"); + when(tableInfo01.getComment()).thenReturn("芋艿"); + TableInfo tableInfo02 = mock(TableInfo.class); + when(tableInfo02.getName()).thenReturn("t_yunai_02"); + when(tableInfo02.getComment()).thenReturn("芋艿_02"); + when(databaseTableService.getTableList(eq(dataSourceConfigId), eq(name), eq(comment))) + .thenReturn(ListUtil.toList(tableInfo01, tableInfo02)); + // mock 数据 + CodegenTableDO tableDO = randomPojo(CodegenTableDO.class, + o -> o.setScene(CodegenSceneEnum.ADMIN.getScene()) + .setTableName("t_yunai_02") + .setDataSourceConfigId(dataSourceConfigId)); + codegenTableMapper.insert(tableDO); + + // 调用 + List result = codegenService.getDatabaseTableList(dataSourceConfigId, name, comment); + // 断言 + assertEquals(1, result.size()); + assertEquals("t_yunai", result.get(0).getName()); + assertEquals("芋艿", result.get(0).getComment()); + } + +} \ No newline at end of file diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java new file mode 100755 index 0000000000..ba0c8b15fd --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java @@ -0,0 +1,281 @@ +package cn.iocoder.yudao.module.infra.service.file; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactory; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig; +import cn.iocoder.yudao.module.infra.framework.file.core.enums.FileStorageEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigSaveReqVO; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO; +import cn.iocoder.yudao.module.infra.dal.mysql.file.FileConfigMapper; +import lombok.Data; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import javax.annotation.Resource; +import javax.validation.Validator; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Map; + +import static cn.hutool.core.util.RandomUtil.randomEle; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_DELETE_FAIL_MASTER; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link FileConfigServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(FileConfigServiceImpl.class) +public class FileConfigServiceImplTest extends BaseDbUnitTest { + + @Resource + private FileConfigServiceImpl fileConfigService; + + @Resource + private FileConfigMapper fileConfigMapper; + + @MockBean + private Validator validator; + @MockBean + private FileClientFactory fileClientFactory; + + @Test + public void testCreateFileConfig_success() { + // 准备参数 + Map config = MapUtil.builder().put("basePath", "/yunai") + .put("domain", "https://www.iocoder.cn").build(); + FileConfigSaveReqVO reqVO = randomPojo(FileConfigSaveReqVO.class, + o -> o.setStorage(FileStorageEnum.LOCAL.getStorage()).setConfig(config)) + .setId(null); // 避免 id 被赋值 + + // 调用 + Long fileConfigId = fileConfigService.createFileConfig(reqVO); + // 断言 + assertNotNull(fileConfigId); + // 校验记录的属性是否正确 + FileConfigDO fileConfig = fileConfigMapper.selectById(fileConfigId); + assertPojoEquals(reqVO, fileConfig, "id", "config"); + assertFalse(fileConfig.getMaster()); + assertEquals("/yunai", ((LocalFileClientConfig) fileConfig.getConfig()).getBasePath()); + assertEquals("https://www.iocoder.cn", ((LocalFileClientConfig) fileConfig.getConfig()).getDomain()); + // 验证 cache + assertNull(fileConfigService.getClientCache().getIfPresent(fileConfigId)); + } + + @Test + public void testUpdateFileConfig_success() { + // mock 数据 + FileConfigDO dbFileConfig = randomPojo(FileConfigDO.class, o -> o.setStorage(FileStorageEnum.LOCAL.getStorage()) + .setConfig(new LocalFileClientConfig().setBasePath("/yunai").setDomain("https://www.iocoder.cn"))); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + // 准备参数 + FileConfigSaveReqVO reqVO = randomPojo(FileConfigSaveReqVO.class, o -> { + o.setId(dbFileConfig.getId()); // 设置更新的 ID + o.setStorage(FileStorageEnum.LOCAL.getStorage()); + Map config = MapUtil.builder().put("basePath", "/yunai2") + .put("domain", "https://doc.iocoder.cn").build(); + o.setConfig(config); + }); + + // 调用 + fileConfigService.updateFileConfig(reqVO); + // 校验是否更新正确 + FileConfigDO fileConfig = fileConfigMapper.selectById(reqVO.getId()); // 获取最新的 + assertPojoEquals(reqVO, fileConfig, "config"); + assertEquals("/yunai2", ((LocalFileClientConfig) fileConfig.getConfig()).getBasePath()); + assertEquals("https://doc.iocoder.cn", ((LocalFileClientConfig) fileConfig.getConfig()).getDomain()); + // 验证 cache + assertNull(fileConfigService.getClientCache().getIfPresent(fileConfig.getId())); + } + + @Test + public void testUpdateFileConfig_notExists() { + // 准备参数 + FileConfigSaveReqVO reqVO = randomPojo(FileConfigSaveReqVO.class); + + // 调用, 并断言异常 + assertServiceException(() -> fileConfigService.updateFileConfig(reqVO), FILE_CONFIG_NOT_EXISTS); + } + + @Test + public void testUpdateFileConfigMaster_success() { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + FileConfigDO masterFileConfig = randomFileConfigDO().setMaster(true); + fileConfigMapper.insert(masterFileConfig);// @Sql: 先插入出一条存在的数据 + + // 调用 + fileConfigService.updateFileConfigMaster(dbFileConfig.getId()); + // 断言数据 + assertTrue(fileConfigMapper.selectById(dbFileConfig.getId()).getMaster()); + assertFalse(fileConfigMapper.selectById(masterFileConfig.getId()).getMaster()); + // 验证 cache + assertNull(fileConfigService.getClientCache().getIfPresent(0L)); + } + + @Test + public void testUpdateFileConfigMaster_notExists() { + // 调用, 并断言异常 + assertServiceException(() -> fileConfigService.updateFileConfigMaster(randomLongId()), FILE_CONFIG_NOT_EXISTS); + } + + @Test + public void testDeleteFileConfig_success() { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + // 准备参数 + Long id = dbFileConfig.getId(); + + // 调用 + fileConfigService.deleteFileConfig(id); + // 校验数据不存在了 + assertNull(fileConfigMapper.selectById(id)); + // 验证 cache + assertNull(fileConfigService.getClientCache().getIfPresent(id)); + } + + @Test + public void testDeleteFileConfig_notExists() { + // 准备参数 + Long id = randomLongId(); + + // 调用, 并断言异常 + assertServiceException(() -> fileConfigService.deleteFileConfig(id), FILE_CONFIG_NOT_EXISTS); + } + + @Test + public void testDeleteFileConfig_master() { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(true); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + // 准备参数 + Long id = dbFileConfig.getId(); + + // 调用, 并断言异常 + assertServiceException(() -> fileConfigService.deleteFileConfig(id), FILE_CONFIG_DELETE_FAIL_MASTER); + } + + @Test + public void testGetFileConfigPage() { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setName("芋道源码") + .setStorage(FileStorageEnum.LOCAL.getStorage()); + dbFileConfig.setCreateTime(LocalDateTimeUtil.parse("2020-01-23", DatePattern.NORM_DATE_PATTERN));// 等会查询到 + fileConfigMapper.insert(dbFileConfig); + // 测试 name 不匹配 + fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setName("源码"))); + // 测试 storage 不匹配 + fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setStorage(FileStorageEnum.DB.getStorage()))); + // 测试 createTime 不匹配 + fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setCreateTime(LocalDateTimeUtil.parse("2020-11-23", DatePattern.NORM_DATE_PATTERN)))); + // 准备参数 + FileConfigPageReqVO reqVO = new FileConfigPageReqVO(); + reqVO.setName("芋道"); + reqVO.setStorage(FileStorageEnum.LOCAL.getStorage()); + reqVO.setCreateTime((new LocalDateTime[]{buildTime(2020, 1, 1), + buildTime(2020, 1, 24)})); + + // 调用 + PageResult pageResult = fileConfigService.getFileConfigPage(reqVO); + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(1, pageResult.getList().size()); + assertPojoEquals(dbFileConfig, pageResult.getList().get(0)); + } + + @Test + public void testFileConfig() throws Exception { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + // 准备参数 + Long id = dbFileConfig.getId(); + // mock 获得 Client + FileClient fileClient = mock(FileClient.class); + when(fileClientFactory.getFileClient(eq(id))).thenReturn(fileClient); + when(fileClient.upload(any(), any(), any())).thenReturn("https://www.iocoder.cn"); + + // 调用,并断言 + assertEquals("https://www.iocoder.cn", fileConfigService.testFileConfig(id)); + } + + @Test + public void testGetFileConfig() { + // mock 数据 + FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false); + fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据 + // 准备参数 + Long id = dbFileConfig.getId(); + + // 调用,并断言 + assertPojoEquals(dbFileConfig, fileConfigService.getFileConfig(id)); + } + + @Test + public void testGetFileClient() { + // mock 数据 + FileConfigDO fileConfig = randomFileConfigDO().setMaster(false); + fileConfigMapper.insert(fileConfig); + // 准备参数 + Long id = fileConfig.getId(); + // mock 获得 Client + FileClient fileClient = new LocalFileClient(id, new LocalFileClientConfig()); + when(fileClientFactory.getFileClient(eq(id))).thenReturn(fileClient); + + // 调用,并断言 + assertSame(fileClient, fileConfigService.getFileClient(id)); + // 断言缓存 + verify(fileClientFactory).createOrUpdateFileClient(eq(id), eq(fileConfig.getStorage()), + eq(fileConfig.getConfig())); + } + + @Test + public void testGetMasterFileClient() { + // mock 数据 + FileConfigDO fileConfig = randomFileConfigDO().setMaster(true); + fileConfigMapper.insert(fileConfig); + // 准备参数 + Long id = fileConfig.getId(); + // mock 获得 Client + FileClient fileClient = new LocalFileClient(id, new LocalFileClientConfig()); + when(fileClientFactory.getFileClient(eq(fileConfig.getId()))).thenReturn(fileClient); + + // 调用,并断言 + assertSame(fileClient, fileConfigService.getMasterFileClient()); + // 断言缓存 + verify(fileClientFactory).createOrUpdateFileClient(eq(fileConfig.getId()), eq(fileConfig.getStorage()), + eq(fileConfig.getConfig())); + } + + private FileConfigDO randomFileConfigDO() { + return randomPojo(FileConfigDO.class).setStorage(randomEle(FileStorageEnum.values()).getStorage()) + .setConfig(new EmptyFileClientConfig()); + } + + @Data + public static class EmptyFileClientConfig implements FileClientConfig, Serializable { + + } + +} diff --git a/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java new file mode 100644 index 0000000000..dff95c96cb --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java @@ -0,0 +1,143 @@ +package cn.iocoder.yudao.module.infra.service.file; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.framework.test.core.util.AssertUtils; +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; +import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import javax.annotation.Resource; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.*; + +@Import({FileServiceImpl.class}) +public class FileServiceImplTest extends BaseDbUnitTest { + + @Resource + private FileService fileService; + + @Resource + private FileMapper fileMapper; + + @MockBean + private FileConfigService fileConfigService; + + @Test + public void testGetFilePage() { + // mock 数据 + FileDO dbFile = randomPojo(FileDO.class, o -> { // 等会查询到 + o.setPath("yunai"); + o.setType("image/jpg"); + o.setCreateTime(buildTime(2021, 1, 15)); + }); + fileMapper.insert(dbFile); + // 测试 path 不匹配 + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setPath("tudou"))); + // 测试 type 不匹配 + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> { + o.setType("image/png"); + })); + // 测试 createTime 不匹配 + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> { + o.setCreateTime(buildTime(2020, 1, 15)); + })); + // 准备参数 + FilePageReqVO reqVO = new FilePageReqVO(); + reqVO.setPath("yunai"); + reqVO.setType("jp"); + reqVO.setCreateTime((new LocalDateTime[]{buildTime(2021, 1, 10), buildTime(2021, 1, 20)})); + + // 调用 + PageResult pageResult = fileService.getFilePage(reqVO); + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(1, pageResult.getList().size()); + AssertUtils.assertPojoEquals(dbFile, pageResult.getList().get(0)); + } + + @Test + public void testCreateFile_success() throws Exception { + // 准备参数 + String path = randomString(); + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + // mock Master 文件客户端 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), same(path), eq("image/jpeg"))).thenReturn(url); + when(client.getId()).thenReturn(10L); + String name = "单测文件名"; + // 调用 + String result = fileService.createFile(name, path, content); + // 断言 + assertEquals(result, url); + // 校验数据 + FileDO file = fileMapper.selectOne(FileDO::getPath, path); + assertEquals(10L, file.getConfigId()); + assertEquals(path, file.getPath()); + assertEquals(url, file.getUrl()); + assertEquals("image/jpeg", file.getType()); + assertEquals(content.length, file.getSize()); + } + + @Test + public void testDeleteFile_success() throws Exception { + // mock 数据 + FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("tudou.jpg")); + fileMapper.insert(dbFile);// @Sql: 先插入出一条存在的数据 + // mock Master 文件客户端 + FileClient client = mock(FileClient.class); + when(fileConfigService.getFileClient(eq(10L))).thenReturn(client); + // 准备参数 + Long id = dbFile.getId(); + + // 调用 + fileService.deleteFile(id); + // 校验数据不存在了 + assertNull(fileMapper.selectById(id)); + // 校验调用 + verify(client).delete(eq("tudou.jpg")); + } + + @Test + public void testDeleteFile_notExists() { + // 准备参数 + Long id = randomLongId(); + + // 调用, 并断言异常 + assertServiceException(() -> fileService.deleteFile(id), FILE_NOT_EXISTS); + } + + @Test + public void testGetFileContent() throws Exception { + // 准备参数 + Long configId = 10L; + String path = "tudou.jpg"; + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getFileClient(eq(10L))).thenReturn(client); + byte[] content = new byte[]{}; + when(client.getContent(eq("tudou.jpg"))).thenReturn(content); + + // 调用 + byte[] result = fileService.getFileContent(configId, path); + // 断言 + assertSame(result, content); + } + +} diff --git a/yudao-module-mall/yudao-module-product-biz/pom.xml b/yudao-module-mall/yudao-module-product-biz/pom.xml new file mode 100644 index 0000000000..7cf3364328 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/pom.xml @@ -0,0 +1,67 @@ + + + + cn.iocoder.boot + yudao-module-mall + ${revision} + + 4.0.0 + yudao-module-product-biz + jar + + ${project.artifactId} + + product 模块,主要实现商品相关功能 + 例如:品牌、商品分类、spu、sku等功能。 + + + + + cn.iocoder.boot + yudao-module-product-api + ${revision} + + + cn.iocoder.boot + yudao-module-member-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/comment/ProductCommentMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/comment/ProductCommentMapper.java new file mode 100644 index 0000000000..47421d3153 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/comment/ProductCommentMapper.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.product.dal.mysql.comment; + +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.product.controller.admin.comment.vo.ProductCommentPageReqVO; +import cn.iocoder.yudao.module.product.controller.app.comment.vo.AppCommentPageReqVO; +import cn.iocoder.yudao.module.product.dal.dataobject.comment.ProductCommentDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ProductCommentMapper extends BaseMapperX { + + default PageResult selectPage(ProductCommentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ProductCommentDO::getUserNickname, reqVO.getUserNickname()) + .eqIfPresent(ProductCommentDO::getOrderId, reqVO.getOrderId()) + .eqIfPresent(ProductCommentDO::getSpuId, reqVO.getSpuId()) + .eqIfPresent(ProductCommentDO::getScores, reqVO.getScores()) + .eqIfPresent(ProductCommentDO::getReplyStatus, reqVO.getReplyStatus()) + .betweenIfPresent(ProductCommentDO::getCreateTime, reqVO.getCreateTime()) + .likeIfPresent(ProductCommentDO::getSpuName, reqVO.getSpuName()) + .orderByDesc(ProductCommentDO::getId)); + } + + static void appendTabQuery(LambdaQueryWrapperX queryWrapper, Integer type) { + // 构建好评查询语句:好评计算 总评 >= 4 + if (ObjectUtil.equal(type, AppCommentPageReqVO.GOOD_COMMENT)) { + queryWrapper.ge(ProductCommentDO::getScores, 4); + } + // 构建中评查询语句:中评计算 总评 >= 3 且 总评 < 4 + if (ObjectUtil.equal(type, AppCommentPageReqVO.MEDIOCRE_COMMENT)) { + queryWrapper.ge(ProductCommentDO::getScores, 3); + queryWrapper.lt(ProductCommentDO::getScores, 4); + } + // 构建差评查询语句:差评计算 总评 < 3 + if (ObjectUtil.equal(type, AppCommentPageReqVO.NEGATIVE_COMMENT)) { + queryWrapper.lt(ProductCommentDO::getScores, 3); + } + } + + default PageResult selectPage(AppCommentPageReqVO reqVO, Boolean visible) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eqIfPresent(ProductCommentDO::getSpuId, reqVO.getSpuId()) + .eqIfPresent(ProductCommentDO::getVisible, visible); + // 构建评价查询语句 + appendTabQuery(queryWrapper, reqVO.getType()); + // 按评价时间排序最新的显示在前面 + queryWrapper.orderByDesc(ProductCommentDO::getCreateTime); + return selectPage(reqVO, queryWrapper); + } + + default ProductCommentDO selectByUserIdAndOrderItemId(Long userId, Long orderItemId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ProductCommentDO::getUserId, userId) + .eq(ProductCommentDO::getOrderItemId, orderItemId)); + } + +} diff --git a/yudao-module-mall/yudao-module-promotion-biz/pom.xml b/yudao-module-mall/yudao-module-promotion-biz/pom.xml new file mode 100644 index 0000000000..f3d1340ba9 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/pom.xml @@ -0,0 +1,82 @@ + + + + cn.iocoder.boot + yudao-module-mall + ${revision} + + 4.0.0 + jar + yudao-module-promotion-biz + + ${project.artifactId} + + + promotion 模块,主要实现营销相关功能 + 例如:营销活动、banner 广告、优惠券、优惠码等功能。 + + + + + cn.iocoder.boot + yudao-module-promotion-api + ${revision} + + + cn.iocoder.boot + yudao-module-product-api + ${revision} + + + cn.iocoder.boot + yudao-module-trade-api + ${revision} + + + cn.iocoder.boot + yudao-module-member-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + diff --git a/yudao-module-mall/yudao-module-statistics-biz/pom.xml b/yudao-module-mall/yudao-module-statistics-biz/pom.xml new file mode 100644 index 0000000000..77c7b1bff9 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/pom.xml @@ -0,0 +1,95 @@ + + + + cn.iocoder.boot + yudao-module-mall + ${revision} + + 4.0.0 + yudao-module-statistics-biz + jar + + ${project.artifactId} + + statistics 模块,主要实现统计相关功能 + 例如:统计商品、会员、交易等功能。 + + + + + cn.iocoder.boot + yudao-module-statistics-api + ${revision} + + + cn.iocoder.boot + yudao-module-promotion-api + ${revision} + + + cn.iocoder.boot + yudao-module-product-api + ${revision} + + + cn.iocoder.boot + yudao-module-trade-api + ${revision} + + + cn.iocoder.boot + yudao-module-member-api + ${revision} + + + cn.iocoder.boot + yudao-module-pay-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-ip + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/member/MemberStatisticsController.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/member/MemberStatisticsController.java new file mode 100644 index 0000000000..9b0f7158b1 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/member/MemberStatisticsController.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.statistics.controller.admin.member; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.NumberUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.member.vo.*; +import cn.iocoder.yudao.module.statistics.convert.member.MemberStatisticsConvert; +import cn.iocoder.yudao.module.statistics.service.infra.ApiAccessLogStatisticsService; +import cn.iocoder.yudao.module.statistics.service.member.MemberStatisticsService; +import cn.iocoder.yudao.module.statistics.service.trade.TradeOrderStatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 会员统计") +@RestController +@RequestMapping("/statistics/member") +@Validated +@Slf4j +public class MemberStatisticsController { + + @Resource + private MemberStatisticsService memberStatisticsService; + @Resource + private TradeOrderStatisticsService tradeOrderStatisticsService; + @Resource + private ApiAccessLogStatisticsService apiAccessLogStatisticsService; + + @GetMapping("/summary") + @Operation(summary = "获得会员统计(实时统计)") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult getMemberSummary() { + return success(memberStatisticsService.getMemberSummary()); + } + + @GetMapping("/analyse") + @Operation(summary = "获得会员分析数据") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult getMemberAnalyse(MemberAnalyseReqVO reqVO) { + // 1. 查询数据 + LocalDateTime beginTime = ArrayUtil.get(reqVO.getTimes(), 0); + LocalDateTime endTime = ArrayUtil.get(reqVO.getTimes(), 1); + // 1.1 查询分析对照数据 + DataComparisonRespVO comparisonData = memberStatisticsService.getMemberAnalyseComparisonData(beginTime, endTime); + // TODO @疯狂:这个可能有点特殊,要按照 create_time 来查询;不然它的漏斗就不统一;因为是访问数量 > 今日下单人 > 今日支付人;是一个统一的维度; + // 1.2 查询成交用户数量 + Integer payUserCount = tradeOrderStatisticsService.getPayUserCount(beginTime, endTime); + // 1.3 计算客单价 + int atv = 0; + if (payUserCount != null && payUserCount > 0) { + // TODO @疯狂:类似上面的 payUserCount + Integer payPrice = tradeOrderStatisticsService.getOrderPayPrice(beginTime, endTime); + atv = NumberUtil.div(payPrice, payUserCount).intValue(); + } + // 1.4 查询访客数量 + Integer visitUserCount = apiAccessLogStatisticsService.getIpCount(UserTypeEnum.MEMBER.getValue(), beginTime, endTime); + // 1.5 下单用户数量 + Integer orderUserCount = tradeOrderStatisticsService.getOrderUserCount(beginTime, endTime); + + // 2. 拼接返回 + return success(MemberStatisticsConvert.INSTANCE.convert(visitUserCount, orderUserCount, payUserCount, atv, comparisonData)); + } + + @GetMapping("/area-statistics-list") + @Operation(summary = "按照省份,获得会员统计列表") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult> getMemberAreaStatisticsList() { + return success(memberStatisticsService.getMemberAreaStatisticsList()); + } + + @GetMapping("/sex-statistics-list") + @Operation(summary = "按照性别,获得会员统计列表") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult> getMemberSexStatisticsList() { + return success(memberStatisticsService.getMemberSexStatisticsList()); + } + + @GetMapping("/terminal-statistics-list") + @Operation(summary = "按照终端,获得会员统计列表") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult> getMemberTerminalStatisticsList() { + return success(memberStatisticsService.getMemberTerminalStatisticsList()); + } + + // TODO @疯狂:要注意 date 的排序; + @GetMapping("/user-count-comparison") + @Operation(summary = "获得用户数量对照") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult> getUserCountComparison() { + return success(memberStatisticsService.getUserCountComparison()); + } + + @GetMapping("/register-count-list") + @Operation(summary = "获得会员注册数量列表") + @PreAuthorize("@ss.hasPermission('statistics:member:query')") + public CommonResult> getMemberRegisterCountList(MemberAnalyseReqVO reqVO) { + return success(memberStatisticsService.getMemberRegisterCountList( + ArrayUtil.get(reqVO.getTimes(), 0), ArrayUtil.get(reqVO.getTimes(), 1))); + } + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/infra/ApiAccessLogStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/infra/ApiAccessLogStatisticsMapper.java new file mode 100644 index 0000000000..5f76b58fd1 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/infra/ApiAccessLogStatisticsMapper.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.infra; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +// TODO @芋艿:api 访问日志,现在会清理,可能要单独有个偏业务的访问表; +/** + * API 访问日志的统计 Mapper + * + * @author owen + */ +@Mapper +@SuppressWarnings("rawtypes") +public interface ApiAccessLogStatisticsMapper extends BaseMapperX { + + Integer selectIpCountByUserTypeAndCreateTimeBetween(@Param("userType") Integer userType, + @Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Integer selectUserCountByUserTypeAndCreateTimeBetween(@Param("userType") Integer userType, + @Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/member/MemberStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/member/MemberStatisticsMapper.java new file mode 100644 index 0000000000..595e93d9e8 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/member/MemberStatisticsMapper.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.member; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberRegisterCountRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberSexStatisticsRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.member.vo.MemberTerminalStatisticsRespVO; +import cn.iocoder.yudao.module.statistics.service.member.bo.MemberAreaStatisticsRespBO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 会员信息的统计 Mapper + * + * @author owen + */ +@Mapper +@SuppressWarnings("rawtypes") +public interface MemberStatisticsMapper extends BaseMapperX { + + List selectSummaryListByAreaId(); + + List selectSummaryListBySex(); + + List selectSummaryListByRegisterTerminal(); + + Integer selectUserCount(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 获得用户的每天注册数量列表 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 每天注册数量列表 + */ + List selectListByCreateTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/pay/PayWalletStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/pay/PayWalletStatisticsMapper.java new file mode 100644 index 0000000000..b9b38fbbec --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/pay/PayWalletStatisticsMapper.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.pay; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.statistics.service.pay.bo.RechargeSummaryRespBO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.WalletSummaryRespBO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 支付钱包的统计 Mapper + * + * @author owen + */ +@Mapper +@SuppressWarnings("rawtypes") +public interface PayWalletStatisticsMapper extends BaseMapperX { + + WalletSummaryRespBO selectRechargeSummaryByPayTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime, + @Param("payStatus") Boolean payStatus); + + WalletSummaryRespBO selectRechargeSummaryByRefundTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime, + @Param("refundStatus") Integer refundStatus); + + Integer selectPriceSummaryByBizTypeAndCreateTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime, + @Param("bizType") Integer bizType); + + RechargeSummaryRespBO selectRechargeSummaryGroupByWalletId(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime, + @Param("payStatus") Boolean payStatus); + + Integer selectRechargePriceSummary(@Param("payStatus") Integer payStatus); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/AfterSaleStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/AfterSaleStatisticsMapper.java new file mode 100644 index 0000000000..7de0cb07b2 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/AfterSaleStatisticsMapper.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.trade; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.statistics.dal.dataobject.trade.TradeStatisticsDO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.AfterSaleSummaryRespBO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 售后订单的统计 Mapper + * + * @author owen + */ +@Mapper +public interface AfterSaleStatisticsMapper extends BaseMapperX { + + AfterSaleSummaryRespBO selectSummaryByRefundTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Long selectCountByStatus(@Param("status") Integer status); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/BrokerageStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/BrokerageStatisticsMapper.java new file mode 100644 index 0000000000..94ad068121 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/BrokerageStatisticsMapper.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.trade; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.statistics.dal.dataobject.trade.TradeStatisticsDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; + +/** + * 订单分销的统计 Mapper + * + * @author owen + */ +@Mapper +public interface BrokerageStatisticsMapper extends BaseMapperX { + + Integer selectSummaryPriceByStatusAndUnfreezeTimeBetween(@Param("bizType") Integer bizType, + @Param("status") Integer status, + @Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Long selectWithdrawCountByStatus(@Param("status") Integer status); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeOrderStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeOrderStatisticsMapper.java new file mode 100644 index 0000000000..1d7ffb6441 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeOrderStatisticsMapper.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.trade; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeOrderSummaryRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeOrderTrendRespVO; +import cn.iocoder.yudao.module.statistics.dal.dataobject.trade.TradeStatisticsDO; +import cn.iocoder.yudao.module.statistics.service.member.bo.MemberAreaStatisticsRespBO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 交易订单的统计 Mapper + * + * @author owen + */ +@Mapper +public interface TradeOrderStatisticsMapper extends BaseMapperX { + + List selectSummaryListByAreaId(); + + Integer selectCountByCreateTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Integer selectCountByPayTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Integer selectSummaryPriceByPayTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Integer selectUserCountByCreateTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Integer selectUserCountByPayTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 按照支付时间统计订单(按天分组) + * + * @param beginTime 支付起始时间 + * @param endTime 支付截止时间 + * @return 订单统计列表 + */ + List selectListByPayTimeBetweenAndGroupByDay(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + /** + * 按照支付时间统计订单(按月分组) + * + * @param beginTime 支付起始时间 + * @param endTime 支付截止时间 + * @return 订单统计列表 + */ + List selectListByPayTimeBetweenAndGroupByMonth(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + Long selectCountByStatusAndDeliveryType(@Param("status") Integer status, @Param("deliveryType") Integer deliveryType); + + TradeOrderSummaryRespVO selectPaySummaryByStatusAndPayTimeBetween(@Param("status") Integer status, + @Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeStatisticsMapper.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeStatisticsMapper.java new file mode 100644 index 0000000000..67d2d50073 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/trade/TradeStatisticsMapper.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.statistics.dal.mysql.trade; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeTrendSummaryRespVO; +import cn.iocoder.yudao.module.statistics.dal.dataobject.trade.TradeStatisticsDO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.TradeSummaryRespBO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 交易统计 Mapper + * + * @author owen + */ +@Mapper +public interface TradeStatisticsMapper extends BaseMapperX { + + TradeSummaryRespBO selectOrderCreateCountSumAndOrderPayPriceSumByTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + TradeTrendSummaryRespVO selectVoByTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + default List selectListByTimeBetween(LocalDateTime beginTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .between(TradeStatisticsDO::getTime, beginTime, endTime)); + } + + Integer selectExpensePriceByTimeBetween(@Param("beginTime") LocalDateTime beginTime, + @Param("endTime") LocalDateTime endTime); + + default TradeStatisticsDO selectByTimeBetween(LocalDateTime beginTime, LocalDateTime endTime) { + return selectOne(new LambdaQueryWrapperX() + .between(TradeStatisticsDO::getTime, beginTime, endTime)); + } + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/infra/ApiAccessLogStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/infra/ApiAccessLogStatisticsService.java new file mode 100644 index 0000000000..6c200fdefa --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/infra/ApiAccessLogStatisticsService.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.statistics.service.infra; + +import java.time.LocalDateTime; + +/** + * API 访问日志的统计 Service 接口 + * + * @author owen + */ +public interface ApiAccessLogStatisticsService { + + /** + * 获取活跃用户数量 + * + * @param userType 用户类型 + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 活跃用户数量 + */ + Integer getUserCount(Integer userType, LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取访问用户数量 + * + * @param userType 用户类型 + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 访问用户数量 + */ + Integer getIpCount(Integer userType, LocalDateTime beginTime, LocalDateTime endTime); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/member/MemberStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/member/MemberStatisticsService.java new file mode 100644 index 0000000000..253cba68a6 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/member/MemberStatisticsService.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.statistics.service.member; + +import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.member.vo.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 会员信息的统计 Service 接口 + * + * @author owen + */ +public interface MemberStatisticsService { + + /** + * 获取会员统计(实时统计) + * + * @return 会员统计 + */ + MemberSummaryRespVO getMemberSummary(); + + /** + * 获取会员分析对照数据 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 会员分析对照数据 + */ + DataComparisonRespVO getMemberAnalyseComparisonData(LocalDateTime beginTime, + LocalDateTime endTime); + + /** + * 按照省份,获得会员统计列表 + * + * @return 会员统计列表 + */ + List getMemberAreaStatisticsList(); + + /** + * 按照性别,获得会员统计列表 + * + * @return 会员统计列表 + */ + List getMemberSexStatisticsList(); + + /** + * 按照终端,获得会员统计列表 + * + * @return 会员统计列表 + */ + List getMemberTerminalStatisticsList(); + + /** + * 获取用户注册数量列表 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 注册数量列表 + */ + List getMemberRegisterCountList(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获得用户数量量统计对照 + * + * @return 用户数量量统计对照 + */ + DataComparisonRespVO getUserCountComparison(); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/pay/PayWalletStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/pay/PayWalletStatisticsService.java new file mode 100644 index 0000000000..d43a576824 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/pay/PayWalletStatisticsService.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.statistics.service.pay; + +import cn.iocoder.yudao.module.statistics.service.pay.bo.RechargeSummaryRespBO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.WalletSummaryRespBO; + +import java.time.LocalDateTime; + +/** + * 钱包的统计 Service 接口 + * + * @author owen + */ +public interface PayWalletStatisticsService { + + /** + * 获取钱包统计 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 钱包统计 + */ + WalletSummaryRespBO getWalletSummary(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取钱包充值统计 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 钱包充值统计 + */ + RechargeSummaryRespBO getUserRechargeSummary(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取充值金额合计 + * + * @return 充值金额合计 + */ + Integer getRechargePriceSummary(); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/AfterSaleStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/AfterSaleStatisticsService.java new file mode 100644 index 0000000000..f584c3dc76 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/AfterSaleStatisticsService.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.statistics.service.trade; + +import cn.iocoder.yudao.module.statistics.service.trade.bo.AfterSaleSummaryRespBO; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleStatusEnum; + +import java.time.LocalDateTime; + +/** + * 售后统计 Service 接口 + * + * @author owen + */ +public interface AfterSaleStatisticsService { + + /** + * 获取售后单统计 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 售后统计结果 + */ + AfterSaleSummaryRespBO getAfterSaleSummary(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取指定状态的售后订单数量 + * + * @param status 售后状态 + * @return 售后订单数量 + */ + Long getCountByStatus(AfterSaleStatusEnum status); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/BrokerageStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/BrokerageStatisticsService.java new file mode 100644 index 0000000000..6845b608ac --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/BrokerageStatisticsService.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.statistics.service.trade; + +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageWithdrawStatusEnum; + +import java.time.LocalDateTime; + +/** + * 分销统计 Service 接口 + * + * @author owen + */ +public interface BrokerageStatisticsService { + + /** + * 获取已结算的佣金金额 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 已结算的佣金金额 + */ + Integer getBrokerageSettlementPriceSummary(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取指定状态的提现记录数量 + * + * @param status 提现记录状态 + * @return 提现记录数量 + */ + Long getWithdrawCountByStatus(BrokerageWithdrawStatusEnum status); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeOrderStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeOrderStatisticsService.java new file mode 100644 index 0000000000..2197515c42 --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeOrderStatisticsService.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.statistics.service.trade; + +import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.*; +import cn.iocoder.yudao.module.statistics.service.member.bo.MemberAreaStatisticsRespBO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.TradeOrderSummaryRespBO; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 交易订单的统计 Service 接口 + * + * @author owen + */ +public interface TradeOrderStatisticsService { + + /** + * 获取订单统计 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 订单统计结果 + */ + TradeOrderSummaryRespBO getOrderSummary(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取地区订单统计 + * + * @return 订单统计结果 + */ + List getSummaryListByAreaId(); + + /** + * 获取下单用户数量 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 下单用户数量 + */ + Integer getOrderUserCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取支付用户数量 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 支付用户数量 + */ + Integer getPayUserCount(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取支付金额 + * + * @param beginTime 起始时间 + * @param endTime 截止时间 + * @return 支付用户金额 + */ + Integer getOrderPayPrice(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 根据订单状态、物流类型,获得交易订单数量 + * + * @return 订单数量 + */ + Long getCountByStatusAndDeliveryType(Integer status, Integer deliveryType); + + /** + * 交易订单销售额对照 + * + * @return 销售额对照 + */ + DataComparisonRespVO getOrderComparison(); + + /** + * 获得订单量趋势统计 + * + * @param reqVO 统计参数 + * @return 订单量趋势统计 + */ + List> getOrderCountTrendComparison(TradeOrderTrendReqVO reqVO); + +} diff --git a/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsService.java b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsService.java new file mode 100644 index 0000000000..e175985fbe --- /dev/null +++ b/yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsService.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.statistics.service.trade; + +import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO; +import cn.iocoder.yudao.module.statistics.controller.admin.trade.vo.TradeTrendSummaryRespVO; +import cn.iocoder.yudao.module.statistics.dal.dataobject.trade.TradeStatisticsDO; +import cn.iocoder.yudao.module.statistics.service.trade.bo.TradeSummaryRespBO; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 交易统计 Service 接口 + * + * @author owen + */ +public interface TradeStatisticsService { + + /** + * 获得交易状况统计对照 + * + * @return 统计数据对照 + */ + DataComparisonRespVO getTradeStatisticsAnalyse( + LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获得交易状况统计 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 统计数据对照 + */ + Integer getExpensePrice(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获得交易状况明细 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 统计数据列表 + */ + List getTradeStatisticsList(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 统计指定天数的交易数据 + * + * @return 统计结果 + */ + String statisticsTrade(Integer days); + + /** + * 统计指定日期的交易数据 + * + * @param days 增加的天数 + * @return 交易数据 + */ + TradeSummaryRespBO getTradeSummaryByDays(int days); + + /** + * 统计指定月份的交易数据 + * + * @param months 增加的月数 + * @return 交易数据 + */ + TradeSummaryRespBO getTradeSummaryByMonths(int months); + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/pom.xml b/yudao-module-mall/yudao-module-trade-biz/pom.xml new file mode 100644 index 0000000000..0ecadd7d35 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/pom.xml @@ -0,0 +1,101 @@ + + + + cn.iocoder.boot + yudao-module-mall + ${revision} + + 4.0.0 + yudao-module-trade-biz + jar + + ${project.artifactId} + + trade 模块,主要实现交易相关功能 + 例如:订单、退款、购物车等功能。 + + + + + cn.iocoder.boot + yudao-module-trade-api + ${revision} + + + cn.iocoder.boot + yudao-module-product-api + ${revision} + + + cn.iocoder.boot + yudao-module-pay-api + ${revision} + + + cn.iocoder.boot + yudao-module-promotion-api + ${revision} + + + cn.iocoder.boot + yudao-module-member-api + ${revision} + + + cn.iocoder.boot + yudao-module-system-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-ip + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/brokerage/BrokerageWithdrawConvert.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/brokerage/BrokerageWithdrawConvert.java new file mode 100644 index 0000000000..64e8ccc64a --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/brokerage/BrokerageWithdrawConvert.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.trade.convert.brokerage; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawPageReqVO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawRespVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.withdraw.AppBrokerageWithdrawCreateReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.withdraw.AppBrokerageWithdrawPageReqVO; +import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.withdraw.AppBrokerageWithdrawRespVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageWithdrawDO; +import cn.iocoder.yudao.module.trade.enums.DictTypeConstants; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 佣金提现 Convert + * + * @author 芋道源码 + */ +@Mapper +public interface BrokerageWithdrawConvert { + + BrokerageWithdrawConvert INSTANCE = Mappers.getMapper(BrokerageWithdrawConvert.class); + + BrokerageWithdrawDO convert(AppBrokerageWithdrawCreateReqVO createReqVO, Long userId, Integer feePrice); + + BrokerageWithdrawRespVO convert(BrokerageWithdrawDO bean); + + List convertList(List list); + + PageResult convertPage(PageResult page); + + default PageResult convertPage(PageResult pageResult, Map userMap) { + PageResult result = convertPage(pageResult); + for (BrokerageWithdrawRespVO vo : result.getList()) { + vo.setUserNickname(Optional.ofNullable(userMap.get(vo.getUserId())).map(MemberUserRespDTO::getNickname).orElse(null)); + } + return result; + } + + PageResult convertPage02(PageResult pageResult); + + default PageResult convertPage03(PageResult pageResult) { + PageResult result = convertPage02(pageResult); + for (AppBrokerageWithdrawRespVO vo : result.getList()) { + vo.setStatusName(DictFrameworkUtils.getDictDataLabel(DictTypeConstants.BROKERAGE_WITHDRAW_STATUS, vo.getStatus())); + } + return result; + } + + BrokerageWithdrawPageReqVO convert(AppBrokerageWithdrawPageReqVO pageReqVO, Long userId); +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java new file mode 100644 index 0000000000..ed556c6984 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java @@ -0,0 +1,288 @@ +package cn.iocoder.yudao.module.trade.convert.order; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils; +import cn.iocoder.yudao.module.member.api.address.dto.MemberAddressRespDTO; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderCreateReqDTO; +import cn.iocoder.yudao.module.pay.enums.DictTypeConstants; +import cn.iocoder.yudao.module.product.api.comment.dto.ProductCommentCreateReqDTO; +import cn.iocoder.yudao.module.product.api.property.dto.ProductPropertyValueDetailRespDTO; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuUpdateStockReqDTO; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.promotion.api.combination.dto.CombinationRecordCreateReqDTO; +import cn.iocoder.yudao.module.trade.api.order.dto.TradeOrderRespDTO; +import cn.iocoder.yudao.module.trade.controller.admin.base.member.user.MemberUserRespVO; +import cn.iocoder.yudao.module.trade.controller.admin.base.product.property.ProductPropertyValueDetailRespVO; +import cn.iocoder.yudao.module.trade.controller.admin.order.vo.*; +import cn.iocoder.yudao.module.trade.controller.app.base.property.AppProductPropertyValueDetailRespVO; +import cn.iocoder.yudao.module.trade.controller.app.order.vo.*; +import cn.iocoder.yudao.module.trade.controller.app.order.vo.item.AppTradeOrderItemCommentCreateReqVO; +import cn.iocoder.yudao.module.trade.controller.app.order.vo.item.AppTradeOrderItemRespVO; +import cn.iocoder.yudao.module.trade.dal.dataobject.cart.CartDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.delivery.DeliveryExpressDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderLogDO; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderItemAfterSaleStatusEnum; +import cn.iocoder.yudao.module.trade.framework.delivery.core.client.dto.ExpressTrackRespDTO; +import cn.iocoder.yudao.module.trade.framework.order.config.TradeOrderProperties; +import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageAddReqBO; +import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO; +import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +import org.mapstruct.Named; +import org.mapstruct.factory.Mappers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime; + +@Mapper +public interface TradeOrderConvert { + + TradeOrderConvert INSTANCE = Mappers.getMapper(TradeOrderConvert.class); + + @Mappings({ + @Mapping(target = "id", ignore = true), + @Mapping(source = "userId", target = "userId"), + @Mapping(source = "createReqVO.couponId", target = "couponId"), + @Mapping(target = "remark", ignore = true), + @Mapping(source = "createReqVO.remark", target = "userRemark"), + @Mapping(source = "calculateRespBO.price.totalPrice", target = "totalPrice"), + @Mapping(source = "calculateRespBO.price.discountPrice", target = "discountPrice"), + @Mapping(source = "calculateRespBO.price.deliveryPrice", target = "deliveryPrice"), + @Mapping(source = "calculateRespBO.price.couponPrice", target = "couponPrice"), + @Mapping(source = "calculateRespBO.price.pointPrice", target = "pointPrice"), + @Mapping(source = "calculateRespBO.price.vipPrice", target = "vipPrice"), + @Mapping(source = "calculateRespBO.price.payPrice", target = "payPrice") + }) + TradeOrderDO convert(Long userId, AppTradeOrderCreateReqVO createReqVO, TradePriceCalculateRespBO calculateRespBO); + + TradeOrderRespDTO convert(TradeOrderDO orderDO); + + default List convertList(TradeOrderDO tradeOrderDO, TradePriceCalculateRespBO calculateRespBO) { + return CollectionUtils.convertList(calculateRespBO.getItems(), item -> { + TradeOrderItemDO orderItem = convert(item); + orderItem.setOrderId(tradeOrderDO.getId()); + orderItem.setUserId(tradeOrderDO.getUserId()); + orderItem.setAfterSaleStatus(TradeOrderItemAfterSaleStatusEnum.NONE.getStatus()); + orderItem.setCommentStatus(false); + return orderItem; + }); + } + + TradeOrderItemDO convert(TradePriceCalculateRespBO.OrderItem item); + + default ProductSkuUpdateStockReqDTO convert(List list) { + List items = CollectionUtils.convertList(list, item -> + new ProductSkuUpdateStockReqDTO.Item().setId(item.getSkuId()).setIncrCount(item.getCount())); + return new ProductSkuUpdateStockReqDTO(items); + } + + default ProductSkuUpdateStockReqDTO convertNegative(List list) { + List items = CollectionUtils.convertList(list, item -> + new ProductSkuUpdateStockReqDTO.Item().setId(item.getSkuId()).setIncrCount(-item.getCount())); + return new ProductSkuUpdateStockReqDTO(items); + } + + default PayOrderCreateReqDTO convert(TradeOrderDO order, List orderItems, + TradeOrderProperties orderProperties) { + PayOrderCreateReqDTO createReqDTO = new PayOrderCreateReqDTO() + .setAppId(orderProperties.getAppId()).setUserIp(order.getUserIp()); + // 商户相关字段 + createReqDTO.setMerchantOrderId(String.valueOf(order.getId())); + String subject = orderItems.get(0).getSpuName(); + subject = StrUtils.maxLength(subject, PayOrderCreateReqDTO.SUBJECT_MAX_LENGTH); // 避免超过 32 位 + createReqDTO.setSubject(subject); + createReqDTO.setBody(subject); // TODO 芋艿:临时写死 + // 订单相关字段 + createReqDTO.setPrice(order.getPayPrice()).setExpireTime(addTime(orderProperties.getPayExpireTime())); + return createReqDTO; + } + + default PageResult convertPage(PageResult pageResult, + List orderItems, + Map memberUserMap) { + Map> orderItemMap = convertMultiMap(orderItems, TradeOrderItemDO::getOrderId); + // 转化 List + List orderVOs = CollectionUtils.convertList(pageResult.getList(), order -> { + List xOrderItems = orderItemMap.get(order.getId()); + TradeOrderPageItemRespVO orderVO = convert(order, xOrderItems); + // 处理收货地址 + orderVO.setReceiverAreaName(AreaUtils.format(order.getReceiverAreaId())); + // 增加用户信息 + orderVO.setUser(convertUser(memberUserMap.get(orderVO.getUserId()))); + // 增加推广人信息 + orderVO.setBrokerageUser(convertUser(memberUserMap.get(orderVO.getBrokerageUserId()))); + return orderVO; + }); + return new PageResult<>(orderVOs, pageResult.getTotal()); + } + + MemberUserRespVO convertUser(MemberUserRespDTO memberUserRespDTO); + + TradeOrderPageItemRespVO convert(TradeOrderDO order, List items); + + ProductPropertyValueDetailRespVO convert(ProductPropertyValueDetailRespDTO bean); + + default TradeOrderDetailRespVO convert(TradeOrderDO order, List orderItems, + List orderLogs, + MemberUserRespDTO user, MemberUserRespDTO brokerageUser) { + TradeOrderDetailRespVO orderVO = convert2(order, orderItems); + // 处理收货地址 + orderVO.setReceiverAreaName(AreaUtils.format(order.getReceiverAreaId())); + // 处理用户信息 + orderVO.setUser(convert(user)); + orderVO.setBrokerageUser(convert(brokerageUser)); + // 处理日志 + orderVO.setLogs(convertList03(orderLogs)); + return orderVO; + } + List convertList03(List orderLogs); + + TradeOrderDetailRespVO convert2(TradeOrderDO order, List items); + + MemberUserRespVO convert(MemberUserRespDTO bean); + + default PageResult convertPage02(PageResult pageResult, + List orderItems) { + Map> orderItemMap = convertMultiMap(orderItems, TradeOrderItemDO::getOrderId); + // 转化 List + List orderVOs = CollectionUtils.convertList(pageResult.getList(), order -> { + List xOrderItems = orderItemMap.get(order.getId()); + return convert02(order, xOrderItems); + }); + return new PageResult<>(orderVOs, pageResult.getTotal()); + } + + AppTradeOrderPageItemRespVO convert02(TradeOrderDO order, List items); + + AppProductPropertyValueDetailRespVO convert02(ProductPropertyValueDetailRespDTO bean); + + default AppTradeOrderDetailRespVO convert02(TradeOrderDO order, List orderItems, + TradeOrderProperties tradeOrderProperties, + DeliveryExpressDO express) { + AppTradeOrderDetailRespVO orderVO = convert3(order, orderItems); + orderVO.setPayExpireTime(addTime(tradeOrderProperties.getPayExpireTime())); + if (StrUtil.isNotEmpty(order.getPayChannelCode())) { + orderVO.setPayChannelName(DictFrameworkUtils.getDictDataLabel(DictTypeConstants.CHANNEL_CODE, order.getPayChannelCode())); + } + // 处理收货地址 + orderVO.setReceiverAreaName(AreaUtils.format(order.getReceiverAreaId())); + if (express != null) { + orderVO.setLogisticsId(express.getId()).setLogisticsName(express.getName()); + } + return orderVO; + } + + AppTradeOrderDetailRespVO convert3(TradeOrderDO order, List items); + + AppTradeOrderItemRespVO convert03(TradeOrderItemDO bean); + + @Mappings({ + @Mapping(target = "skuId", source = "tradeOrderItemDO.skuId"), + @Mapping(target = "orderId", source = "tradeOrderItemDO.orderId"), + @Mapping(target = "orderItemId", source = "tradeOrderItemDO.id"), + @Mapping(target = "descriptionScores", source = "createReqVO.descriptionScores"), + @Mapping(target = "benefitScores", source = "createReqVO.benefitScores"), + @Mapping(target = "content", source = "createReqVO.content"), + @Mapping(target = "picUrls", source = "createReqVO.picUrls"), + @Mapping(target = "anonymous", source = "createReqVO.anonymous"), + @Mapping(target = "userId", source = "tradeOrderItemDO.userId") + }) + ProductCommentCreateReqDTO convert04(AppTradeOrderItemCommentCreateReqVO createReqVO, TradeOrderItemDO tradeOrderItemDO); + + TradePriceCalculateReqBO convert(AppTradeOrderSettlementReqVO settlementReqVO); + + default TradePriceCalculateReqBO convert(Long userId, AppTradeOrderSettlementReqVO settlementReqVO, + List cartList) { + TradePriceCalculateReqBO reqBO = new TradePriceCalculateReqBO().setUserId(userId) + .setItems(new ArrayList<>(settlementReqVO.getItems().size())) + .setCouponId(settlementReqVO.getCouponId()).setPointStatus(settlementReqVO.getPointStatus()) + // 物流信息 + .setDeliveryType(settlementReqVO.getDeliveryType()).setAddressId(settlementReqVO.getAddressId()) + .setPickUpStoreId(settlementReqVO.getPickUpStoreId()) + // 各种活动 + .setSeckillActivityId(settlementReqVO.getSeckillActivityId()) + .setBargainRecordId(settlementReqVO.getBargainRecordId()) + .setCombinationActivityId(settlementReqVO.getCombinationActivityId()) + .setCombinationHeadId(settlementReqVO.getCombinationHeadId()); + // 商品项的构建 + Map cartMap = convertMap(cartList, CartDO::getId); + for (AppTradeOrderSettlementReqVO.Item item : settlementReqVO.getItems()) { + // 情况一:skuId + count + if (item.getSkuId() != null) { + reqBO.getItems().add(new TradePriceCalculateReqBO.Item().setSkuId(item.getSkuId()).setCount(item.getCount()) + .setSelected(true)); // true 的原因,下单一定选中 + continue; + } + // 情况二:cartId + CartDO cart = cartMap.get(item.getCartId()); + if (cart == null) { + continue; + } + reqBO.getItems().add(new TradePriceCalculateReqBO.Item().setSkuId(cart.getSkuId()).setCount(cart.getCount()) + .setCartId(item.getCartId()).setSelected(true)); // true 的原因,下单一定选中 + } + return reqBO; + } + + default AppTradeOrderSettlementRespVO convert(TradePriceCalculateRespBO calculate, MemberAddressRespDTO address) { + AppTradeOrderSettlementRespVO respVO = convert0(calculate, address); + if (address != null) { + respVO.getAddress().setAreaName(AreaUtils.format(address.getAreaId())); + } + return respVO; + } + + AppTradeOrderSettlementRespVO convert0(TradePriceCalculateRespBO calculate, MemberAddressRespDTO address); + + List convertList02(List list); + + TradeOrderDO convert(TradeOrderUpdateAddressReqVO reqVO); + + TradeOrderDO convert(TradeOrderUpdatePriceReqVO reqVO); + + TradeOrderDO convert(TradeOrderRemarkReqVO reqVO); + + default BrokerageAddReqBO convert(MemberUserRespDTO user, TradeOrderItemDO item, + ProductSpuRespDTO spu, ProductSkuRespDTO sku) { + BrokerageAddReqBO bo = new BrokerageAddReqBO().setBizId(String.valueOf(item.getId())).setSourceUserId(item.getUserId()) + .setBasePrice(item.getPayPrice() * item.getCount()) + .setTitle(StrUtil.format("{}成功购买{}", user.getNickname(), item.getSpuName())) + .setFirstFixedPrice(0).setSecondFixedPrice(0); + if (BooleanUtil.isTrue(spu.getSubCommissionType())) { + bo.setFirstFixedPrice(sku.getFirstBrokeragePrice()).setSecondFixedPrice(sku.getSecondBrokeragePrice()); + } + return bo; + } + + @Named("convertList04") + List convertList04(List list); + + @Mappings({ + @Mapping(target = "activityId", source = "order.combinationActivityId"), + @Mapping(target = "spuId", source = "item.spuId"), + @Mapping(target = "skuId", source = "item.skuId"), + @Mapping(target = "count", source = "item.count"), + @Mapping(target = "orderId", source = "order.id"), + @Mapping(target = "userId", source = "order.userId"), + @Mapping(target = "headId", source = "order.combinationHeadId"), + @Mapping(target = "combinationPrice", source = "item.payPrice"), + }) + CombinationRecordCreateReqDTO convert(TradeOrderDO order, TradeOrderItemDO item); + +} diff --git a/yudao-module-pay/pom.xml b/yudao-module-pay/pom.xml new file mode 100644 index 0000000000..85bcb1e2c7 --- /dev/null +++ b/yudao-module-pay/pom.xml @@ -0,0 +1,25 @@ + + + + cn.iocoder.boot + yudao + ${revision} + + 4.0.0 + yudao-module-pay + pom + + yudao-module-pay-api + yudao-module-pay-biz + yudao-spring-boot-starter-biz-pay + + + ${project.artifactId} + + pay 模块,我们放支付业务,提供业务的支付能力。 + 例如说:商户、应用、支付、退款等等 + + + diff --git a/yudao-module-pay/yudao-module-pay-biz/pom.xml b/yudao-module-pay/yudao-module-pay-biz/pom.xml new file mode 100644 index 0000000000..de7afb1734 --- /dev/null +++ b/yudao-module-pay/yudao-module-pay-biz/pom.xml @@ -0,0 +1,84 @@ + + + + cn.iocoder.boot + yudao-module-pay + ${revision} + + 4.0.0 + yudao-module-pay-biz + jar + + ${project.artifactId} + + pay 模块,我们放支付业务,提供业务的支付能力。 + 例如说:商户、应用、支付、退款等等 + + + + + cn.iocoder.boot + yudao-module-pay-api + ${revision} + + + cn.iocoder.boot + yudao-module-member-api + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-operatelog + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-pay + ${revision} + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-job + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + diff --git a/yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/convert/wallet/PayWalletRechargeConvert.java b/yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/convert/wallet/PayWalletRechargeConvert.java new file mode 100644 index 0000000000..dabb8b52b3 --- /dev/null +++ b/yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/convert/wallet/PayWalletRechargeConvert.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pay.convert.wallet; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; +import cn.iocoder.yudao.module.pay.controller.app.wallet.vo.recharge.AppPayWalletRechargeCreateRespVO; +import cn.iocoder.yudao.module.pay.controller.app.wallet.vo.recharge.AppPayWalletRechargeRespVO; +import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO; +import cn.iocoder.yudao.module.pay.enums.DictTypeConstants; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface PayWalletRechargeConvert { + + PayWalletRechargeConvert INSTANCE = Mappers.getMapper(PayWalletRechargeConvert.class); + + @Mapping(target = "totalPrice", expression = "java( payPrice + bonusPrice)") + PayWalletRechargeDO convert(Long walletId, Integer payPrice, Integer bonusPrice, Long packageId); + + AppPayWalletRechargeCreateRespVO convert(PayWalletRechargeDO bean); + + default PageResult convertPage(PageResult pageResult, + List payOrderList) { + PageResult voPageResult = BeanUtils.toBean(pageResult, AppPayWalletRechargeRespVO.class); + Map payOrderMap = CollectionUtils.convertMap(payOrderList, PayOrderDO::getId); + voPageResult.getList().forEach(recharge -> { + recharge.setPayChannelName(DictFrameworkUtils.getDictDataLabel( + DictTypeConstants.CHANNEL_CODE, recharge.getPayChannelCode())); + MapUtils.findAndThen(payOrderMap, recharge.getPayOrderId(), + order -> recharge.setPayOrderChannelOrderNo(order.getChannelOrderNo())); + }); + return voPageResult; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/pom.xml b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/pom.xml similarity index 63% rename from yudao-framework/yudao-spring-boot-starter-file/pom.xml rename to yudao-module-pay/yudao-spring-boot-starter-biz-pay/pom.xml index 93fad220b5..93193d008e 100644 --- a/yudao-framework/yudao-spring-boot-starter-file/pom.xml +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/pom.xml @@ -3,22 +3,18 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - yudao-framework cn.iocoder.boot + yudao-module-pay ${revision} 4.0.0 - yudao-spring-boot-starter-file + yudao-spring-boot-starter-biz-pay ${project.artifactId} - 文件客户端,支持多种存储器 - 1. file:本地磁盘 - 2. ftp:FTP 服务器 - 2. sftp:SFTP 服务器 - 4. db:数据库 - 5. s3:支持 S3 协议的云存储服务,例如说 MinIO、阿里云、华为云、腾讯云、七牛云等等 + 支付拓展,接入国内多个支付渠道 + 1. 支付宝,基于官方 SDK 接入 + 2. 微信支付,基于 weixin-java-pay 接入 - https://github.com/YunaiV/ruoyi-vue-pro @@ -52,26 +48,29 @@ jackson-core - - commons-net - commons-net - - - com.jcraft - jsch - - - - org.apache.tika - tika-core - - - io.minio - minio + com.alipay.sdk + alipay-sdk-java + 4.35.79.ALL + + + org.bouncycastle + bcprov-jdk15on + + + + + com.github.binarywang + weixin-java-pay + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/config/YudaoPayAutoConfiguration.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/config/YudaoPayAutoConfiguration.java new file mode 100644 index 0000000000..e57f4ac702 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/config/YudaoPayAutoConfiguration.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.framework.pay.config; + +import cn.iocoder.yudao.framework.pay.core.client.PayClientFactory; +import cn.iocoder.yudao.framework.pay.core.client.impl.PayClientFactoryImpl; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * 支付配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration +public class YudaoPayAutoConfiguration { + + @Bean + public PayClientFactory payClientFactory() { + return new PayClientFactoryImpl(); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClient.java new file mode 100644 index 0000000000..86e3566b2b --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClient.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.framework.pay.core.client; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; + +import java.util.Map; + +/** + * 支付客户端,用于对接各支付渠道的 SDK,实现发起支付、退款等功能 + * + * @author 芋道源码 + */ +public interface PayClient { + + /** + * 获得渠道编号 + * + * @return 渠道编号 + */ + Long getId(); + + // ============ 支付相关 ========== + + /** + * 调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 支付订单信息 + */ + PayOrderRespDTO unifiedOrder(PayOrderUnifiedReqDTO reqDTO); + + /** + * 解析 order 回调数据 + * + * @param params HTTP 回调接口 content type 为 application/x-www-form-urlencoded 的所有参数 + * @param body HTTP 回调接口的 request body + * @return 支付订单信息 + */ + PayOrderRespDTO parseOrderNotify(Map params, String body); + + /** + * 获得支付订单信息 + * + * @param outTradeNo 外部订单号 + * @return 支付订单信息 + */ + PayOrderRespDTO getOrder(String outTradeNo); + + // ============ 退款相关 ========== + + /** + * 调用支付渠道,进行退款 + * + * @param reqDTO 统一退款请求信息 + * @return 退款信息 + */ + PayRefundRespDTO unifiedRefund(PayRefundUnifiedReqDTO reqDTO); + + /** + * 解析 refund 回调数据 + * + * @param params HTTP 回调接口 content type 为 application/x-www-form-urlencoded 的所有参数 + * @param body HTTP 回调接口的 request body + * @return 支付订单信息 + */ + PayRefundRespDTO parseRefundNotify(Map params, String body); + + /** + * 获得退款订单信息 + * + * @param outTradeNo 外部订单号 + * @param outRefundNo 外部退款号 + * @return 退款订单信息 + */ + PayRefundRespDTO getRefund(String outTradeNo, String outRefundNo); + + /** + * 调用渠道,进行转账 + * + * @param reqDTO 统一转账请求信息 + * @return 转账信息 + */ + PayTransferRespDTO unifiedTransfer(PayTransferUnifiedReqDTO reqDTO); + + /** + * 获得转账订单信息 + * + * @param outTradeNo 外部订单号 + * @param type 转账类型 + * @return 转账信息 + */ + PayTransferRespDTO getTransfer(String outTradeNo, PayTransferTypeEnum type); +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientConfig.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientConfig.java new file mode 100644 index 0000000000..5a38430342 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientConfig.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.framework.pay.core.client; + +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.Validator; +import java.util.Set; + +/** + * 支付客户端的配置,本质是支付渠道的配置 + * 每个不同的渠道,需要不同的配置,通过子类来定义 + * + * @author 芋道源码 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +// @JsonTypeInfo 注解的作用,Jackson 多态 +// 1. 序列化到时数据库时,增加 @class 属性。 +// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型 +public interface PayClientConfig { + + /** + * 参数校验 + * + * @param validator 校验对象 + */ + void validate(Validator validator); + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientFactory.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientFactory.java new file mode 100644 index 0000000000..53f1a8c069 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/PayClientFactory.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.framework.pay.core.client; + +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; + +/** + * 支付客户端的工厂接口 + * + * @author 芋道源码 + */ +public interface PayClientFactory { + + /** + * 获得支付客户端 + * + * @param channelId 渠道编号 + * @return 支付客户端 + */ + PayClient getPayClient(Long channelId); + + /** + * 创建支付客户端 + * + * @param channelId 渠道编号 + * @param channelCode 渠道编码 + * @param config 支付配置 + */ + void createOrUpdatePayClient(Long channelId, String channelCode, + Config config); + + /** + * 注册支付客户端 Class,用于模块中实现的 PayClient + * + * @param channel 支付渠道的编码的枚举 + * @param payClientClass 支付客户端 class + */ + void registerPayClientClass(PayChannelEnum channel, Class payClientClass); + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderRespDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderRespDTO.java new file mode 100644 index 0000000000..82050a6fcb --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderRespDTO.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.order; + +import cn.iocoder.yudao.framework.pay.core.client.exception.PayException; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 渠道支付订单 Response DTO + * + * @author 芋道源码 + */ +@Data +public class PayOrderRespDTO { + + /** + * 支付状态 + * + * 枚举:{@link PayOrderStatusRespEnum} + */ + private Integer status; + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + private String outTradeNo; + + /** + * 支付渠道编号 + */ + private String channelOrderNo; + /** + * 支付渠道用户编号 + */ + private String channelUserId; + + /** + * 支付成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的同步/异步通知结果 + */ + private Object rawData; + + // ========== 主动发起支付时,会返回的字段 ========== + + /** + * 展示模式 + * + * 枚举 {@link PayOrderDisplayModeEnum} 类 + */ + private String displayMode; + /** + * 展示内容 + */ + private String displayContent; + + /** + * 调用渠道的错误码 + * + * 注意:这里返回的是业务异常,而是不系统异常。 + * 如果是系统异常,则会抛出 {@link PayException} + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + public PayOrderRespDTO() { + } + + /** + * 创建【WAITING】状态的订单返回 + */ + public static PayOrderRespDTO waitingOf(String displayMode, String displayContent, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.WAITING.getStatus(); + respDTO.displayMode = displayMode; + respDTO.displayContent = displayContent; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的订单返回 + */ + public static PayOrderRespDTO successOf(String channelOrderNo, String channelUserId, LocalDateTime successTime, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelOrderNo = channelOrderNo; + respDTO.channelUserId = channelUserId; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建指定状态的订单返回,适合支付渠道回调时 + */ + public static PayOrderRespDTO of(Integer status, String channelOrderNo, String channelUserId, LocalDateTime successTime, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = status; + respDTO.channelOrderNo = channelOrderNo; + respDTO.channelUserId = channelUserId; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【CLOSED】状态的订单返回,适合调用支付渠道失败时 + */ + public static PayOrderRespDTO closedOf(String channelErrorCode, String channelErrorMsg, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.CLOSED.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java new file mode 100644 index 0000000000..f269d2f8fe --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.order; + +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import lombok.Data; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 统一下单 Request DTO + * + * @author 芋道源码 + */ +@Data +public class PayOrderUnifiedReqDTO { + + /** + * 用户 IP + */ + @NotEmpty(message = "用户 IP 不能为空") + private String userIp; + + // ========== 商户相关字段 ========== + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + @NotEmpty(message = "外部订单编号不能为空") + private String outTradeNo; + /** + * 商品标题 + */ + @NotEmpty(message = "商品标题不能为空") + @Length(max = 32, message = "商品标题不能超过 32") + private String subject; + /** + * 商品描述信息 + */ + @Length(max = 128, message = "商品描述信息长度不能超过128") + private String body; + /** + * 支付结果的 notify 回调地址 + */ + @NotEmpty(message = "支付结果的回调地址不能为空") + @URL(message = "支付结果的 notify 回调地址必须是 URL 格式") + private String notifyUrl; + /** + * 支付结果的 return 回调地址 + */ + @URL(message = "支付结果的 return 回调地址必须是 URL 格式") + private String returnUrl; + + // ========== 订单相关字段 ========== + + /** + * 支付金额,单位:分 + */ + @NotNull(message = "支付金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer price; + + /** + * 支付过期时间 + */ + @NotNull(message = "支付过期时间不能为空") + private LocalDateTime expireTime; + + // ========== 拓展参数 ========== + /** + * 支付渠道的额外参数 + * + * 例如说,微信公众号需要传递 openid 参数 + */ + private Map channelExtras; + + /** + * 展示模式 + * + * 如果不传递,则每个支付渠道使用默认的方式 + * + * 枚举 {@link PayOrderDisplayModeEnum} + */ + private String displayMode; + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundRespDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundRespDTO.java new file mode 100644 index 0000000000..3184f278de --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundRespDTO.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.refund; + +import cn.iocoder.yudao.framework.pay.core.client.exception.PayException; +import cn.iocoder.yudao.framework.pay.core.enums.refund.PayRefundStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 渠道退款订单 Response DTO + * + * @author jason + */ +@Data +public class PayRefundRespDTO { + + /** + * 退款状态 + * + * 枚举 {@link PayRefundStatusRespEnum} + */ + private Integer status; + + /** + * 外部退款号 + * + * 对应 PayRefundDO 的 no 字段 + */ + private String outRefundNo; + + /** + * 渠道退款单号 + * + * 对应 PayRefundDO.channelRefundNo 字段 + */ + private String channelRefundNo; + + /** + * 退款成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的异步通知结果 + */ + private Object rawData; + + /** + * 调用渠道的错误码 + * + * 注意:这里返回的是业务异常,而是不系统异常。 + * 如果是系统异常,则会抛出 {@link PayException} + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + private PayRefundRespDTO() { + } + + /** + * 创建【WAITING】状态的退款返回 + */ + public static PayRefundRespDTO waitingOf(String channelRefundNo, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.WAITING.getStatus(); + respDTO.channelRefundNo = channelRefundNo; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的退款返回 + */ + public static PayRefundRespDTO successOf(String channelRefundNo, LocalDateTime successTime, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelRefundNo = channelRefundNo; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【FAILURE】状态的退款返回 + */ + public static PayRefundRespDTO failureOf(String outRefundNo, Object rawData) { + return failureOf(null, null, + outRefundNo, rawData); + } + + /** + * 创建【FAILURE】状态的退款返回 + */ + public static PayRefundRespDTO failureOf(String channelErrorCode, String channelErrorMsg, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.FAILURE.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java new file mode 100644 index 0000000000..4f5e203d2c --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.refund; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 统一 退款 Request DTO + * + * @author jason + */ +@Accessors(chain = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Data +public class PayRefundUnifiedReqDTO { + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + @NotEmpty(message = "外部订单编号不能为空") + private String outTradeNo; + + /** + * 外部退款号 + * + * 对应 PayRefundDO 的 no 字段 + */ + @NotEmpty(message = "退款请求单号不能为空") + private String outRefundNo; + + /** + * 退款原因 + */ + @NotEmpty(message = "退款原因不能为空") + private String reason; + + /** + * 支付金额,单位:分 + * + * 目前微信支付在退款的时候,必须传递该字段 + */ + @NotNull(message = "支付金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer payPrice; + /** + * 退款金额,单位:分 + */ + @NotNull(message = "退款金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer refundPrice; + + /** + * 退款结果的 notify 回调地址 + */ + @NotEmpty(message = "支付结果的回调地址不能为空") + @URL(message = "支付结果的 notify 回调地址必须是 URL 格式") + private String notifyUrl; + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java new file mode 100644 index 0000000000..0f9b48240c --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.transfer; + +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 统一转账 Response DTO + * + * @author jason + */ +@Data +public class PayTransferRespDTO { + + /** + * 转账状态 + * + * 关联 {@link PayTransferStatusRespEnum#getStatus()} + */ + private Integer status; + + /** + * 外部转账单号 + * + */ + private String outTransferNo; + + /** + * 支付渠道编号 + */ + private String channelTransferNo; + + /** + * 支付成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的返回结果 + */ + private Object rawData; + + /** + * 调用渠道的错误码 + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + /** + * 创建【WAITING】状态的转账返回 + */ + public static PayTransferRespDTO waitingOf(String channelTransferNo, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.WAITING.getStatus(); + respDTO.channelTransferNo = channelTransferNo; + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【IN_PROGRESS】状态的转账返回 + */ + public static PayTransferRespDTO dealingOf(String channelTransferNo, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.IN_PROGRESS.getStatus(); + respDTO.channelTransferNo = channelTransferNo; + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【CLOSED】状态的转账返回 + */ + public static PayTransferRespDTO closedOf(String channelErrorCode, String channelErrorMsg, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.CLOSED.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的转账返回 + */ + public static PayTransferRespDTO successOf(String channelTransferNo, LocalDateTime successTime, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelTransferNo = channelTransferNo; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java new file mode 100644 index 0000000000..9a13ddaf10 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.framework.pay.core.client.dto.transfer; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Map; + +import static cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum.*; + +/** + * 统一转账 Request DTO + * + * @author jason + */ +@Data +public class PayTransferUnifiedReqDTO { + + /** + * 转账类型 + * + * 关联 {@link PayTransferTypeEnum#getType()} + */ + @NotNull(message = "转账类型不能为空") + @InEnum(PayTransferTypeEnum.class) + private Integer type; + + /** + * 用户 IP + */ + @NotEmpty(message = "用户 IP 不能为空") + private String userIp; + + @NotEmpty(message = "外部转账单编号不能为空") + private String outTransferNo; + + /** + * 转账金额,单位:分 + */ + @NotNull(message = "转账金额不能为空") + @Min(value = 1, message = "转账金额必须大于零") + private Integer price; + + /** + * 转账标题 + */ + @NotEmpty(message = "转账标题不能为空") + @Length(max = 128, message = "转账标题不能超过 128") + private String subject; + + /** + * 收款人姓名 + */ + @NotBlank(message = "收款人姓名不能为空", groups = {Alipay.class}) + private String userName; + + /** + * 支付宝登录号 + */ + @NotBlank(message = "支付宝登录号不能为空", groups = {Alipay.class}) + private String alipayLogonId; + + /** + * 微信 openId + */ + @NotBlank(message = "微信 openId 不能为空", groups = {WxPay.class}) + private String openid; + + /** + * 支付渠道的额外参数 + */ + private Map channelExtras; +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/exception/PayException.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/exception/PayException.java new file mode 100644 index 0000000000..75f4c39750 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/exception/PayException.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.framework.pay.core.client.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 支付系统异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PayException extends RuntimeException { + + public PayException(Throwable cause) { + super(cause); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java new file mode 100644 index 0000000000..82d68b58f6 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java @@ -0,0 +1,250 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.pay.core.client.PayClient; +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.exception.PayException; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_IMPLEMENTED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 支付客户端的抽象类,提供模板方法,减少子类的冗余代码 + * + * @author 芋道源码 + */ +@Slf4j +public abstract class AbstractPayClient implements PayClient { + + /** + * 渠道编号 + */ + private final Long channelId; + /** + * 渠道编码 + */ + @SuppressWarnings("FieldCanBeLocal") + private final String channelCode; + /** + * 支付配置 + */ + protected Config config; + + public AbstractPayClient(Long channelId, String channelCode, Config config) { + this.channelId = channelId; + this.channelCode = channelCode; + this.config = config; + } + + /** + * 初始化 + */ + public final void init() { + doInit(); + log.debug("[init][客户端({}) 初始化完成]", getId()); + } + + /** + * 自定义初始化 + */ + protected abstract void doInit(); + + public final void refresh(Config config) { + // 判断是否更新 + if (config.equals(this.config)) { + return; + } + log.info("[refresh][客户端({})发生变化,重新初始化]", getId()); + this.config = config; + // 初始化 + this.init(); + } + + @Override + public Long getId() { + return channelId; + } + + // ============ 支付相关 ========== + + @Override + public final PayOrderRespDTO unifiedOrder(PayOrderUnifiedReqDTO reqDTO) { + ValidationUtils.validate(reqDTO); + // 执行统一下单 + PayOrderRespDTO resp; + try { + resp = doUnifiedOrder(reqDTO); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedOrder][客户端({}) request({}) 发起支付异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + + protected abstract PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) + throws Throwable; + + @Override + public final PayOrderRespDTO parseOrderNotify(Map params, String body) { + try { + return doParseOrderNotify(params, body); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[parseOrderNotify][客户端({}) params({}) body({}) 解析失败]", + getId(), params, body, ex); + throw buildPayException(ex); + } + } + + protected abstract PayOrderRespDTO doParseOrderNotify(Map params, String body) + throws Throwable; + + @Override + public final PayOrderRespDTO getOrder(String outTradeNo) { + try { + return doGetOrder(outTradeNo); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[getOrder][客户端({}) outTradeNo({}) 查询支付单异常]", + getId(), outTradeNo, ex); + throw buildPayException(ex); + } + } + + protected abstract PayOrderRespDTO doGetOrder(String outTradeNo) + throws Throwable; + + // ============ 退款相关 ========== + + @Override + public final PayRefundRespDTO unifiedRefund(PayRefundUnifiedReqDTO reqDTO) { + ValidationUtils.validate(reqDTO); + // 执行统一退款 + PayRefundRespDTO resp; + try { + resp = doUnifiedRefund(reqDTO); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedRefund][客户端({}) request({}) 发起退款异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + + protected abstract PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable; + + @Override + public final PayRefundRespDTO parseRefundNotify(Map params, String body) { + try { + return doParseRefundNotify(params, body); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[parseRefundNotify][客户端({}) params({}) body({}) 解析失败]", + getId(), params, body, ex); + throw buildPayException(ex); + } + } + + protected abstract PayRefundRespDTO doParseRefundNotify(Map params, String body) + throws Throwable; + + @Override + public final PayRefundRespDTO getRefund(String outTradeNo, String outRefundNo) { + try { + return doGetRefund(outTradeNo, outRefundNo); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[getRefund][客户端({}) outTradeNo({}) outRefundNo({}) 查询退款单异常]", + getId(), outTradeNo, outRefundNo, ex); + throw buildPayException(ex); + } + } + + protected abstract PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) + throws Throwable; + + @Override + public final PayTransferRespDTO unifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + validatePayTransferReqDTO(reqDTO); + PayTransferRespDTO resp; + try { + resp = doUnifiedTransfer(reqDTO); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedTransfer][客户端({}) request({}) 发起转账异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + private void validatePayTransferReqDTO(PayTransferUnifiedReqDTO reqDTO) { + PayTransferTypeEnum transferType = PayTransferTypeEnum.typeOf(reqDTO.getType()); + switch (transferType) { + case ALIPAY_BALANCE: { + ValidationUtils.validate(reqDTO, PayTransferTypeEnum.Alipay.class); + break; + } + case WX_BALANCE: { + ValidationUtils.validate(reqDTO, PayTransferTypeEnum.WxPay.class); + break; + } + default: { + throw exception(NOT_IMPLEMENTED); + } + } + } + + @Override + public final PayTransferRespDTO getTransfer(String outTradeNo, PayTransferTypeEnum type) { + try { + return doGetTransfer(outTradeNo, type); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[getTransfer][客户端({}) outTradeNo({}) type({}) 查询转账单异常]", + getId(), outTradeNo, type, ex); + throw buildPayException(ex); + } + } + + protected abstract PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) + throws Throwable; + + protected abstract PayTransferRespDTO doGetTransfer(String outTradeNo, PayTransferTypeEnum type) + throws Throwable; + + // ========== 各种工具方法 ========== + + private PayException buildPayException(Throwable ex) { + if (ex instanceof PayException) { + return (PayException) ex; + } + throw new PayException(ex); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/NonePayClientConfig.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/NonePayClientConfig.java new file mode 100644 index 0000000000..48319036c7 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/NonePayClientConfig.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl; + +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; + +/** + * 无需任何配置 PayClientConfig 实现类 + * + * @author jason + */ +@Data +public class NonePayClientConfig implements PayClientConfig { + + /** + * 配置名称 + *

+ * 如果不加任何属性,JsonUtils.parseObject2 解析会报错,所以暂时加个名称 + */ + private String name; + + public NonePayClientConfig(){ + this.name = "none-config"; + } + + @Override + public void validate(Validator validator) { + // 无任何配置不需要校验 + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImpl.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImpl.java new file mode 100644 index 0000000000..0b39587abb --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImpl.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ReflectUtil; +import cn.iocoder.yudao.framework.pay.core.client.PayClient; +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.PayClientFactory; +import cn.iocoder.yudao.framework.pay.core.client.impl.alipay.*; +import cn.iocoder.yudao.framework.pay.core.client.impl.mock.MockPayClient; +import cn.iocoder.yudao.framework.pay.core.client.impl.weixin.*; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum.*; + +/** + * 支付客户端的工厂实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class PayClientFactoryImpl implements PayClientFactory { + + /** + * 支付客户端 Map + * + * key:渠道编号 + */ + private final ConcurrentMap> clients = new ConcurrentHashMap<>(); + + /** + * 支付客户端 Class Map + */ + private final Map> clientClass = new ConcurrentHashMap<>(); + + public PayClientFactoryImpl() { + // 微信支付客户端 + clientClass.put(WX_PUB, WxPubPayClient.class); + clientClass.put(WX_LITE, WxLitePayClient.class); + clientClass.put(WX_APP, WxAppPayClient.class); + clientClass.put(WX_BAR, WxBarPayClient.class); + clientClass.put(WX_NATIVE, WxNativePayClient.class); + // 支付包支付客户端 + clientClass.put(ALIPAY_WAP, AlipayWapPayClient.class); + clientClass.put(ALIPAY_QR, AlipayQrPayClient.class); + clientClass.put(ALIPAY_APP, AlipayAppPayClient.class); + clientClass.put(ALIPAY_PC, AlipayPcPayClient.class); + clientClass.put(ALIPAY_BAR, AlipayBarPayClient.class); + // Mock 支付客户端 + clientClass.put(MOCK, MockPayClient.class); + } + + @Override + public void registerPayClientClass(PayChannelEnum channel, Class payClientClass) { + clientClass.put(channel, payClientClass); + } + + @Override + public PayClient getPayClient(Long channelId) { + AbstractPayClient client = clients.get(channelId); + if (client == null) { + log.error("[getPayClient][渠道编号({}) 找不到客户端]", channelId); + } + return client; + } + + @Override + @SuppressWarnings("unchecked") + public void createOrUpdatePayClient(Long channelId, String channelCode, + Config config) { + AbstractPayClient client = (AbstractPayClient) clients.get(channelId); + if (client == null) { + client = this.createPayClient(channelId, channelCode, config); + client.init(); + clients.put(client.getId(), client); + } else { + client.refresh(config); + } + } + + @SuppressWarnings("unchecked") + private AbstractPayClient createPayClient(Long channelId, String channelCode, + Config config) { + PayChannelEnum channelEnum = PayChannelEnum.getByCode(channelCode); + Assert.notNull(channelEnum, String.format("支付渠道(%s) 为空", channelCode)); + Class payClientClass = clientClass.get(channelEnum); + Assert.notNull(payClientClass, String.format("支付渠道(%s) Class 为空", channelCode)); + return (AbstractPayClient) ReflectUtil.newInstance(payClientClass, channelId, config); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java new file mode 100644 index 0000000000..4dcf236755 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java @@ -0,0 +1,342 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.impl.AbstractPayClient; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayConfig; +import com.alipay.api.AlipayResponse; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.domain.*; +import com.alipay.api.internal.util.AlipaySignature; +import com.alipay.api.request.*; +import com.alipay.api.response.*; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import static cn.hutool.core.date.DatePattern.NORM_DATETIME_FORMATTER; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝抽象类,实现支付宝统一的接口、以及部分实现(退款) + * + * @author jason + */ +@Slf4j +public abstract class AbstractAlipayPayClient extends AbstractPayClient { + + @Getter // 仅用于单测场景 + protected DefaultAlipayClient client; + + public AbstractAlipayPayClient(Long channelId, String channelCode, AlipayPayClientConfig config) { + super(channelId, channelCode, config); + } + + @Override + @SneakyThrows + protected void doInit() { + AlipayConfig alipayConfig = new AlipayConfig(); + BeanUtil.copyProperties(config, alipayConfig, false); + this.client = new DefaultAlipayClient(alipayConfig); + } + + // ============ 支付相关 ========== + + /** + * 构造支付关闭的 {@link PayOrderRespDTO} 对象 + * + * @return 支付关闭的 {@link PayOrderRespDTO} 对象 + */ + protected PayOrderRespDTO buildClosedPayOrderRespDTO(PayOrderUnifiedReqDTO reqDTO, AlipayResponse response) { + Assert.isFalse(response.isSuccess()); + return PayOrderRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + reqDTO.getOutTradeNo(), response); + } + + @Override + public PayOrderRespDTO doParseOrderNotify(Map params, String body) throws Throwable { + // 1. 校验回调数据 + Map bodyObj = HttpUtil.decodeParamMap(body, StandardCharsets.UTF_8); + AlipaySignature.rsaCheckV1(bodyObj, config.getAlipayPublicKey(), + StandardCharsets.UTF_8.name(), config.getSignType()); + + // 2. 解析订单的状态 + // 额外说明:支付宝不仅仅支付成功会回调,再各种触发支付单数据变化时,都会进行回调,所以这里 status 的解析会写的比较复杂 + Integer status = parseStatus(bodyObj.get("trade_status")); + // 特殊逻辑: 支付宝没有退款成功的状态,所以,如果有退款金额,我们认为是退款成功 + if (MapUtil.getDouble(bodyObj, "refund_fee", 0D) > 0) { + status = PayOrderStatusRespEnum.REFUND.getStatus(); + } + Assert.notNull(status, (Supplier) () -> { + throw new IllegalArgumentException(StrUtil.format("body({}) 的 trade_status 不正确", body)); + }); + return PayOrderRespDTO.of(status, bodyObj.get("trade_no"), bodyObj.get("seller_id"), parseTime(params.get("gmt_payment")), + bodyObj.get("out_trade_no"), body); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) throws Throwable { + // 1.1 构建 AlipayTradeRefundModel 请求 + AlipayTradeQueryModel model = new AlipayTradeQueryModel(); + model.setOutTradeNo(outTradeNo); + // 1.2 构建 AlipayTradeQueryRequest 请求 + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + request.setBizModel(model); + AlipayTradeQueryResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { // 不成功,例如说订单不存在 + return PayOrderRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + outTradeNo, response); + } + // 2.2 解析订单的状态 + Integer status = parseStatus(response.getTradeStatus()); + Assert.notNull(status, () -> { + throw new IllegalArgumentException(StrUtil.format("body({}) 的 trade_status 不正确", response.getBody())); + }); + return PayOrderRespDTO.of(status, response.getTradeNo(), response.getBuyerUserId(), LocalDateTimeUtil.of(response.getSendPayDate()), + outTradeNo, response); + } + + private static Integer parseStatus(String tradeStatus) { + return Objects.equals("WAIT_BUYER_PAY", tradeStatus) ? PayOrderStatusRespEnum.WAITING.getStatus() + : ObjectUtils.equalsAny(tradeStatus, "TRADE_FINISHED", "TRADE_SUCCESS") ? PayOrderStatusRespEnum.SUCCESS.getStatus() + : Objects.equals("TRADE_CLOSED", tradeStatus) ? PayOrderStatusRespEnum.CLOSED.getStatus() : null; + } + + // ============ 退款相关 ========== + + /** + * 支付宝统一的退款接口 alipay.trade.refund + * + * @param reqDTO 退款请求 request DTO + * @return 退款请求 Response + */ + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeRefundModel 请求 + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setOutRequestNo(reqDTO.getOutRefundNo()); + model.setRefundAmount(formatAmount(reqDTO.getRefundPrice())); + model.setRefundReason(reqDTO.getReason()); + // 1.2 构建 AlipayTradePayRequest 请求 + AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); + request.setBizModel(model); + + // 2.1 执行请求 + AlipayTradeRefundResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { + // 当出现 ACQ.SYSTEM_ERROR, 退款可能成功也可能失败。 返回 WAIT 状态. 后续 job 会轮询 + if (ObjectUtils.equalsAny(response.getSubCode(), "ACQ.SYSTEM_ERROR", "SYSTEM_ERROR")) { + return PayRefundRespDTO.waitingOf(null, reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(response.getSubCode(), response.getSubMsg(), reqDTO.getOutRefundNo(), response); + } + // 2.2 创建返回结果 + // 支付宝只要退款调用返回 success,就认为退款成功,不需要回调。具体可见 parseNotify 方法的说明。 + // 另外,支付宝没有退款单号,所以不用设置 + return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()), + reqDTO.getOutRefundNo(), response); + } + + @Override + public PayRefundRespDTO doParseRefundNotify(Map params, String body) { + // 补充说明:支付宝退款时,没有回调,这点和微信支付是不同的。并且,退款分成部分退款、和全部退款。 + // ① 部分退款:是会有回调,但是它回调的是订单状态的同步回调,不是退款订单的回调 + // ② 全部退款:Wap 支付有订单状态的同步回调,但是 PC/扫码又没有 + // 所以,这里在解析时,即使是退款导致的订单状态同步,我们也忽略不做为“退款同步”,而是订单的回调。 + // 实际上,支付宝退款只要发起成功,就可以认为退款成功,不需要等待回调。 + throw new UnsupportedOperationException("支付宝无退款回调"); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) throws AlipayApiException { + // 1.1 构建 AlipayTradeFastpayRefundQueryModel 请求 + AlipayTradeFastpayRefundQueryModel model = new AlipayTradeFastpayRefundQueryModel(); + model.setOutTradeNo(outTradeNo); + model.setOutRequestNo(outRefundNo); + model.setQueryOptions(Collections.singletonList("gmt_refund_pay")); + // 1.2 构建 AlipayTradeFastpayRefundQueryRequest 请求 + AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); + request.setBizModel(model); + + // 2.1 执行请求 + AlipayTradeFastpayRefundQueryResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { + // 明确不存在的情况,应该就是失败,可进行关闭 + if (ObjectUtils.equalsAny(response.getSubCode(), "TRADE_NOT_EXIST", "ACQ.TRADE_NOT_EXIST")) { + return PayRefundRespDTO.failureOf(outRefundNo, response); + } + // 可能存在“ACQ.SYSTEM_ERROR”系统错误等情况,所以返回 WAIT 继续等待 + return PayRefundRespDTO.waitingOf(null, outRefundNo, response); + } + // 2.2 创建返回结果 + if (Objects.equals(response.getRefundStatus(), "REFUND_SUCCESS")) { + return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()), + outRefundNo, response); + } + return PayRefundRespDTO.waitingOf(null, outRefundNo, response); + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 校验公钥类型 必须使用公钥证书模式 + if (!Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + throw exception0(ERROR_CONFIGURATION.getCode(), "支付宝单笔转账必须使用公钥证书模式"); + } + // 1.2 构建 AlipayFundTransUniTransferModel + AlipayFundTransUniTransferModel model = new AlipayFundTransUniTransferModel(); + // ① 通用的参数 + model.setTransAmount(formatAmount(reqDTO.getPrice())); // 转账金额 + model.setOrderTitle(reqDTO.getSubject()); // 转账业务的标题,用于在支付宝用户的账单里显示。 + model.setOutBizNo(reqDTO.getOutTransferNo()); + model.setProductCode("TRANS_ACCOUNT_NO_PWD"); // 销售产品码。单笔无密转账固定为 TRANS_ACCOUNT_NO_PWD + model.setBizScene("DIRECT_TRANSFER"); // 业务场景 单笔无密转账固定为 DIRECT_TRANSFER + if (reqDTO.getChannelExtras() != null) { + model.setBusinessParams(JsonUtils.toJsonString(reqDTO.getChannelExtras())); + } + // ② 个性化的参数 + Participant payeeInfo = new Participant(); + PayTransferTypeEnum transferType = PayTransferTypeEnum.typeOf(reqDTO.getType()); + switch (transferType) { + // TODO @jason:是不是不用传递 transferType 参数哈?因为应该已经明确是支付宝啦? + // @芋艿。 是不是还要考虑转账到银行卡。所以传 transferType 但是转账到银行卡不知道要如何测试?? + case ALIPAY_BALANCE: { + payeeInfo.setIdentityType("ALIPAY_LOGON_ID"); + payeeInfo.setIdentity(reqDTO.getAlipayLogonId()); // 支付宝登录号 + payeeInfo.setName(reqDTO.getUserName()); // 支付宝账号姓名 + model.setPayeeInfo(payeeInfo); + break; + } + case BANK_CARD: { + payeeInfo.setIdentityType("BANKCARD_ACCOUNT"); + // TODO 待实现 + throw exception(NOT_IMPLEMENTED); + } + default: { + throw exception0(BAD_REQUEST.getCode(), "不正确的转账类型: {}", transferType); + } + } + // 1.3 构建 AlipayFundTransUniTransferRequest + AlipayFundTransUniTransferRequest request = new AlipayFundTransUniTransferRequest(); + request.setBizModel(model); + // 执行请求 + AlipayFundTransUniTransferResponse response = client.certificateExecute(request); + // 处理结果 + if (!response.isSuccess()) { + // 当出现 SYSTEM_ERROR, 转账可能成功也可能失败。 返回 WAIT 状态. 后续 job 会轮询,或相同 outBizNo 重新发起转账 + // 发现 outBizNo 相同 两次请求参数相同. 会返回 "PAYMENT_INFO_INCONSISTENCY", 不知道哪里的问题. 暂时返回 WAIT. 后续job 会轮询 + if (ObjectUtils.equalsAny(response.getSubCode(),"PAYMENT_INFO_INCONSISTENCY", "SYSTEM_ERROR", "ACQ.SYSTEM_ERROR")) { + return PayTransferRespDTO.waitingOf(null, reqDTO.getOutTransferNo(), response); + } + return PayTransferRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + reqDTO.getOutTransferNo(), response); + } else { + if (ObjectUtils.equalsAny(response.getStatus(), "REFUND", "FAIL")) { // 转账到银行卡会出现 "REFUND" "FAIL" + return PayTransferRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + reqDTO.getOutTransferNo(), response); + } + if (Objects.equals(response.getStatus(), "DEALING")) { // 转账到银行卡会出现 "DEALING" 处理中 + return PayTransferRespDTO.dealingOf(response.getOrderId(), reqDTO.getOutTransferNo(), response); + } + return PayTransferRespDTO.successOf(response.getOrderId(), parseTime(response.getTransDate()), + response.getOutBizNo(), response); + } + + } + + @Override + protected PayTransferRespDTO doGetTransfer(String outTradeNo, PayTransferTypeEnum type) throws Throwable { + // 1.1 构建 AlipayFundTransCommonQueryModel + AlipayFundTransCommonQueryModel model = new AlipayFundTransCommonQueryModel(); + model.setProductCode(type == PayTransferTypeEnum.BANK_CARD ? "TRANS_BANKCARD_NO_PWD" : "TRANS_ACCOUNT_NO_PWD"); + model.setBizScene("DIRECT_TRANSFER"); //业务场景 + model.setOutBizNo(outTradeNo); + // 1.2 构建 AlipayFundTransCommonQueryRequest + AlipayFundTransCommonQueryRequest request = new AlipayFundTransCommonQueryRequest(); + request.setBizModel(model); + + // 2.1 执行请求 + AlipayFundTransCommonQueryResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + // 2.2 处理返回结果 + if (response.isSuccess()) { + if (ObjectUtils.equalsAny(response.getStatus(), "REFUND", "FAIL")) { // 转账到银行卡会出现 "REFUND" "FAIL" + return PayTransferRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + outTradeNo, response); + } + if (Objects.equals(response.getStatus(), "DEALING")) { // 转账到银行卡会出现 "DEALING" 处理中 + return PayTransferRespDTO.dealingOf(response.getOrderId(), outTradeNo, response); + } + return PayTransferRespDTO.successOf(response.getOrderId(), parseTime(response.getPayDate()), + response.getOutBizNo(), response); + } else { + // 当出现 SYSTEM_ERROR, 转账可能成功也可能失败。 返回 WAIT 状态. 后续 job 会轮询, 或相同 outBizNo 重新发起转账 + // 当出现 ORDER_NOT_EXIST 可能是转账还在处理中,也可能是转账处理失败. 返回 WAIT 状态. 后续 job 会轮询, 或相同 outBizNo 重新发起转账 + if (ObjectUtils.equalsAny(response.getSubCode(), "ORDER_NOT_EXIST", "SYSTEM_ERROR", "ACQ.SYSTEM_ERROR")) { + return PayTransferRespDTO.waitingOf(null, outTradeNo, response); + } + return PayTransferRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + outTradeNo, response); + } + } + + // ========== 各种工具方法 ========== + + protected String formatAmount(Integer amount) { + return String.valueOf(amount / 100.0); + } + + protected String formatTime(LocalDateTime time) { + return LocalDateTimeUtil.format(time, NORM_DATETIME_FORMATTER); + } + + protected LocalDateTime parseTime(String str) { + return LocalDateTimeUtil.parse(str, NORM_DATETIME_FORMATTER); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java new file mode 100644 index 0000000000..4e5a37e9de --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeAppPayModel; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.response.AlipayTradeAppPayResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 支付宝【App 支付】的 PayClient 实现类 + * + * 文档:App 支付 + * + * // TODO 芋艿:未详细测试,因为手头没 App + * + * @author 芋道源码 + */ +@Slf4j +public class AlipayAppPayClient extends AbstractAlipayPayClient { + + public AlipayAppPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_APP.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeAppPayModel 请求 + AlipayTradeAppPayModel model = new AlipayTradeAppPayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody() + "test"); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setTimeExpire(formatTime(reqDTO.getExpireTime())); + model.setProductCode("QUICK_MSECURITY_PAY"); // 销售产品码:无线快捷支付产品 + // ② 个性化的参数【无】 + // ③ 支付宝扫码支付只有一种展示 + String displayMode = PayOrderDisplayModeEnum.APP.getMode(); + + // 1.2 构建 AlipayTradePrecreateRequest 请求 + AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradeAppPayResponse response = client.sdkExecute(request); + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java new file mode 100644 index 0000000000..1f90d6b58e --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePayModel; +import com.alipay.api.request.AlipayTradePayRequest; +import com.alipay.api.response.AlipayTradePayResponse; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝【条码支付】的 PayClient 实现类 + * + * 文档:当面付 + * + * @author 芋道源码 + */ +@Slf4j +public class AlipayBarPayClient extends AbstractAlipayPayClient { + + public AlipayBarPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_BAR.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + String authCode = MapUtil.getStr(reqDTO.getChannelExtras(), "auth_code"); + if (StrUtil.isEmpty(authCode)) { + throw exception0(BAD_REQUEST.getCode(), "条形码不能为空"); + } + + // 1.1 构建 AlipayTradePayModel 请求 + AlipayTradePayModel model = new AlipayTradePayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setScene("bar_code"); // 当面付条码支付场景 + // ② 个性化的参数 + model.setAuthCode(authCode); + // ③ 支付宝条码支付只有一种展示 + String displayMode = PayOrderDisplayModeEnum.BAR_CODE.getMode(); + + // 1.2 构建 AlipayTradePayRequest 请求 + AlipayTradePayRequest request = new AlipayTradePayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePayResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + if ("10000".equals(response.getCode())) { // 免密支付 + LocalDateTime successTime = LocalDateTimeUtil.of(response.getGmtPayment()); + return PayOrderRespDTO.successOf(response.getTradeNo(), response.getBuyerUserId(), successTime, + response.getOutTradeNo(), response) + .setDisplayMode(displayMode).setDisplayContent(""); + } + // 大额支付,需要用户输入密码,所以返回 waiting。此时,前端一般会进行轮询 + return PayOrderRespDTO.waitingOf(displayMode, "", + reqDTO.getOutTradeNo(), response); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java new file mode 100644 index 0000000000..066ff0122d --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.ConstraintViolation; +import javax.validation.Validator; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.Set; + +/** + * 支付宝的 PayClientConfig 实现类 + * 属性主要来自 {@link com.alipay.api.AlipayConfig} 的必要属性 + * + * @author 芋道源码 + */ +@Data +public class AlipayPayClientConfig implements PayClientConfig { + + /** + * 公钥类型 - 公钥模式 + */ + public static final Integer MODE_PUBLIC_KEY = 1; + /** + * 公钥类型 - 证书模式 + */ + public static final Integer MODE_CERTIFICATE = 2; + + /** + * 签名算法类型 - RSA + */ + public static final String SIGN_TYPE_DEFAULT = "RSA2"; + + /** + * 网关地址 + * + * 1. 生产环境 + * 2. 沙箱环境 + */ + @NotBlank(message = "网关地址不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String serverUrl; + + /** + * 开放平台上创建的应用的 ID + */ + @NotBlank(message = "开放平台上创建的应用的 ID不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String appId; + + /** + * 签名算法类型,推荐:RSA2 + *

+ * {@link #SIGN_TYPE_DEFAULT} + */ + @NotBlank(message = "签名算法类型不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String signType; + + /** + * 公钥类型 + * 1. {@link #MODE_PUBLIC_KEY} 情况,privateKey + alipayPublicKey + * 2. {@link #MODE_CERTIFICATE} 情况,appCertContent + alipayPublicCertContent + rootCertContent + */ + @NotNull(message = "公钥类型不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private Integer mode; + + // ========== 公钥模式 ========== + /** + * 商户私钥 + */ + @NotBlank(message = "商户私钥不能为空", groups = {ModePublicKey.class}) + private String privateKey; + + /** + * 支付宝公钥字符串 + */ + @NotBlank(message = "支付宝公钥字符串不能为空", groups = {ModePublicKey.class}) + private String alipayPublicKey; + + // ========== 证书模式 ========== + /** + * 指定商户公钥应用证书内容字符串 + */ + @NotBlank(message = "指定商户公钥应用证书内容不能为空", groups = {ModeCertificate.class}) + private String appCertContent; + /** + * 指定支付宝公钥证书内容字符串 + */ + @NotBlank(message = "指定支付宝公钥证书内容不能为空", groups = {ModeCertificate.class}) + private String alipayPublicCertContent; + /** + * 指定根证书内容字符串 + */ + @NotBlank(message = "指定根证书内容字符串不能为空", groups = {ModeCertificate.class}) + private String rootCertContent; + + public interface ModePublicKey { + } + + public interface ModeCertificate { + } + + @Override + public void validate(Validator validator) { + ValidationUtils.validate(validator, this, + MODE_PUBLIC_KEY.equals(this.getMode()) ? ModePublicKey.class : ModeCertificate.class); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java new file mode 100644 index 0000000000..6dbd19bef3 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePagePayModel; +import com.alipay.api.request.AlipayTradePagePayRequest; +import com.alipay.api.response.AlipayTradePagePayResponse; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/** + * 支付宝【PC 网站】的 PayClient 实现类 + * + * 文档:电脑网站支付 + * + * @author XGD + */ +@Slf4j +public class AlipayPcPayClient extends AbstractAlipayPayClient { + + public AlipayPcPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_PC.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradePagePayModel 请求 + AlipayTradePagePayModel model = new AlipayTradePagePayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setTimeExpire(formatTime(reqDTO.getExpireTime())); + model.setProductCode("FAST_INSTANT_TRADE_PAY"); // 销售产品码. 目前 PC 支付场景下仅支持 FAST_INSTANT_TRADE_PAY + // ② 个性化的参数 + // 如果想弄更多个性化的参数,可参考 https://www.pingxx.com/api/支付渠道 extra 参数说明.html 的 alipay_pc_direct 部分进行拓展 + model.setQrPayMode("2"); // 跳转模式 - 订单码,效果参见:https://help.pingxx.com/article/1137360/ + // ③ 支付宝 PC 支付有两种展示模式:FORM、URL + String displayMode = ObjectUtil.defaultIfNull(reqDTO.getDisplayMode(), + PayOrderDisplayModeEnum.URL.getMode()); + + // 1.2 构建 AlipayTradePagePayRequest 请求 + AlipayTradePagePayRequest request = new AlipayTradePagePayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePagePayResponse response; + if (Objects.equals(displayMode, PayOrderDisplayModeEnum.FORM.getMode())) { + response = client.pageExecute(request, Method.POST.name()); // 需要特殊使用 POST 请求 + } else { + response = client.pageExecute(request, Method.GET.name()); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java new file mode 100644 index 0000000000..bb3ad17713 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +import static cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝【扫码支付】的 PayClient 实现类 + * + * 文档:扫码支付 + * + * @author 芋道源码 + */ +@Slf4j +public class AlipayQrPayClient extends AbstractAlipayPayClient { + + public AlipayQrPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_QR.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradePrecreateModel 请求 + AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setProductCode("FACE_TO_FACE_PAYMENT"); // 销售产品码. 目前扫码支付场景下仅支持 FACE_TO_FACE_PAYMENT + // ② 个性化的参数【无】 + // ③ 支付宝扫码支付只有一种展示,考虑到前端可能希望二维码扫描后,手机打开 + String displayMode = PayOrderDisplayModeEnum.QR_CODE.getMode(); + + // 1.2 构建 AlipayTradePrecreateRequest 请求 + AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePrecreateResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getQrCode(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java new file mode 100644 index 0000000000..f9dccf5a7e --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeWapPayModel; +import com.alipay.api.request.AlipayTradeWapPayRequest; +import com.alipay.api.response.AlipayTradeWapPayResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 支付宝【Wap 网站】的 PayClient 实现类 + * + * 文档:手机网站支付接口 + * + * @author 芋道源码 + */ +@Slf4j +public class AlipayWapPayClient extends AbstractAlipayPayClient { + + public AlipayWapPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_WAP.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeWapPayModel 请求 + AlipayTradeWapPayModel model = new AlipayTradeWapPayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setProductCode("QUICK_WAP_PAY"); // 销售产品码. 目前 Wap 支付场景下仅支持 QUICK_WAP_PAY + // ② 个性化的参数【无】 + // ③ 支付宝 Wap 支付只有一种展示:URL + String displayMode = PayOrderDisplayModeEnum.URL.getMode(); + + // 1.2 构建 AlipayTradeWapPayRequest 请求 + AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + model.setQuitUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradeWapPayResponse response = client.pageExecute(request, Method.GET.name()); + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/mock/MockPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/mock/MockPayClient.java new file mode 100644 index 0000000000..1ad1ad7134 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/mock/MockPayClient.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.mock; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.impl.AbstractPayClient; +import cn.iocoder.yudao.framework.pay.core.client.impl.NonePayClientConfig; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 模拟支付的 PayClient 实现类 + * + * 模拟支付返回结果都是成功,方便大家日常流畅 + * + * @author jason + */ +public class MockPayClient extends AbstractPayClient { + + private static final String MOCK_RESP_SUCCESS_DATA = "MOCK_SUCCESS"; + + public MockPayClient(Long channelId, NonePayClientConfig config) { + super(channelId, PayChannelEnum.MOCK.getCode(), config); + } + + @Override + protected void doInit() { + } + + @Override + protected PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) { + return PayOrderRespDTO.successOf("MOCK-P-" + reqDTO.getOutTradeNo(), "", LocalDateTime.now(), + reqDTO.getOutTradeNo(), MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) { + return PayOrderRespDTO.successOf("MOCK-P-" + outTradeNo, "", LocalDateTime.now(), + outTradeNo, MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) { + return PayRefundRespDTO.successOf("MOCK-R-" + reqDTO.getOutRefundNo(), LocalDateTime.now(), + reqDTO.getOutRefundNo(), MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) { + return PayRefundRespDTO.successOf("MOCK-R-" + outRefundNo, LocalDateTime.now(), + outRefundNo, MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doParseRefundNotify(Map params, String body) { + throw new UnsupportedOperationException("模拟支付无退款回调"); + } + + @Override + protected PayOrderRespDTO doParseOrderNotify(Map params, String body) { + throw new UnsupportedOperationException("模拟支付无支付回调"); + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + throw new UnsupportedOperationException("待实现"); + } + + @Override + protected PayTransferRespDTO doGetTransfer(String outTradeNo, PayTransferTypeEnum type) { + throw new UnsupportedOperationException("待实现"); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java new file mode 100644 index 0000000000..a8c50cf1c4 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java @@ -0,0 +1,484 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.codec.Base64; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.date.TemporalAccessorUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.io.FileUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.impl.AbstractPayClient; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import cn.iocoder.yudao.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import com.github.binarywang.wxpay.bean.notify.WxPayNotifyV3Result; +import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyResult; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyV3Result; +import com.github.binarywang.wxpay.bean.request.*; +import com.github.binarywang.wxpay.bean.result.*; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.Objects; + +import static cn.hutool.core.date.DatePattern.*; +import static cn.iocoder.yudao.framework.pay.core.client.impl.weixin.WxPayClientConfig.API_VERSION_V2; + +/** + * 微信支付抽象类,实现微信统一的接口、以及部分实现(退款) + * + * @author 遇到源码 + */ +@Slf4j +public abstract class AbstractWxPayClient extends AbstractPayClient { + + protected WxPayService client; + + public AbstractWxPayClient(Long channelId, String channelCode, WxPayClientConfig config) { + super(channelId, channelCode, config); + } + + /** + * 初始化 client 客户端 + * + * @param tradeType 交易类型 + */ + protected void doInit(String tradeType) { + // 创建 config 配置 + WxPayConfig payConfig = new WxPayConfig(); + BeanUtil.copyProperties(config, payConfig, "keyContent", "privateKeyContent", "privateCertContent"); + payConfig.setTradeType(tradeType); + // weixin-pay-java 无法设置内容,只允许读取文件,所以这里要创建临时文件来解决 + if (Base64.isBase64(config.getKeyContent())) { + payConfig.setKeyPath(FileUtils.createTempFile(Base64.decode(config.getKeyContent())).getPath()); + } + if (StrUtil.isNotEmpty(config.getPrivateKeyContent())) { + payConfig.setPrivateKeyPath(FileUtils.createTempFile(config.getPrivateKeyContent()).getPath()); + } + if (StrUtil.isNotEmpty(config.getPrivateCertContent())) { + payConfig.setPrivateCertPath(FileUtils.createTempFile(config.getPrivateCertContent()).getPath()); + } +// payConfig.setCertSerialNo(); + + // 创建 client 客户端 + client = new WxPayServiceImpl(); + client.setConfig(payConfig); + } + + // ============ 支付相关 ========== + + @Override + protected PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws Exception { + try { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doUnifiedOrderV2(reqDTO); + case WxPayClientConfig.API_VERSION_V3: + return doUnifiedOrderV3(reqDTO); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayOrderRespDTO.closedOf(errorCode, errorMessage, + reqDTO.getOutTradeNo(), e.getXmlString()); + } + } + + /** + * 【V2】调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 各支付渠道的返回结果 + */ + protected abstract PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) + throws Exception; + + /** + * 【V3】调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 各支付渠道的返回结果 + */ + protected abstract PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) + throws WxPayException; + + /** + * 【V2】创建微信下单请求 + * + * @param reqDTO 下信息 + * @return 下单请求 + */ + protected WxPayUnifiedOrderRequest buildPayUnifiedOrderRequestV2(PayOrderUnifiedReqDTO reqDTO) { + return WxPayUnifiedOrderRequest.newBuilder() + .outTradeNo(reqDTO.getOutTradeNo()) + .body(reqDTO.getSubject()) + .detail(reqDTO.getBody()) + .totalFee(reqDTO.getPrice()) // 单位分 + .timeExpire(formatDateV2(reqDTO.getExpireTime())) + .spbillCreateIp(reqDTO.getUserIp()) + .notifyUrl(reqDTO.getNotifyUrl()) + .build(); + } + + /** + * 【V3】创建微信下单请求 + * + * @param reqDTO 下信息 + * @return 下单请求 + */ + protected WxPayUnifiedOrderV3Request buildPayUnifiedOrderRequestV3(PayOrderUnifiedReqDTO reqDTO) { + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request(); + request.setOutTradeNo(reqDTO.getOutTradeNo()); + request.setDescription(reqDTO.getSubject()); + request.setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(reqDTO.getPrice())); // 单位分 + request.setTimeExpire(formatDateV3(reqDTO.getExpireTime())); + request.setSceneInfo(new WxPayUnifiedOrderV3Request.SceneInfo().setPayerClientIp(reqDTO.getUserIp())); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + return request; + } + + @Override + public PayOrderRespDTO doParseOrderNotify(Map params, String body) throws WxPayException { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doParseOrderNotifyV2(body); + case WxPayClientConfig.API_VERSION_V3: + return doParseOrderNotifyV3(body); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } + + private PayOrderRespDTO doParseOrderNotifyV2(String body) throws WxPayException { + // 1. 解析回调 + WxPayOrderNotifyResult response = client.parseOrderNotifyResult(body); + // 2. 构建结果 + // V2 微信支付的回调,只有 SUCCESS 支付成功、CLOSED 支付失败两种情况,无需像支付宝一样解析的比较复杂 + Integer status = Objects.equals(response.getResultCode(), "SUCCESS") ? + PayOrderStatusRespEnum.SUCCESS.getStatus() : PayOrderStatusRespEnum.CLOSED.getStatus(); + return PayOrderRespDTO.of(status, response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + response.getOutTradeNo(), body); + } + + private PayOrderRespDTO doParseOrderNotifyV3(String body) throws WxPayException { + // 1. 解析回调 + WxPayNotifyV3Result response = client.parseOrderNotifyV3Result(body, null); + WxPayNotifyV3Result.DecryptNotifyResult result = response.getResult(); + // 2. 构建结果 + Integer status = parseStatus(result.getTradeState()); + String openid = result.getPayer() != null ? result.getPayer().getOpenid() : null; + return PayOrderRespDTO.of(status, result.getTransactionId(), openid, parseDateV3(result.getSuccessTime()), + result.getOutTradeNo(), body); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) throws Throwable { + try { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doGetOrderV2(outTradeNo); + case WxPayClientConfig.API_VERSION_V3: + return doGetOrderV3(outTradeNo); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + if (ObjectUtils.equalsAny(e.getErrCode(), "ORDERNOTEXIST", "ORDER_NOT_EXIST")) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayOrderRespDTO.closedOf(errorCode, errorMessage, + outTradeNo, e.getXmlString()); + } + throw e; + } + } + + private PayOrderRespDTO doGetOrderV2(String outTradeNo) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayOrderQueryRequest request = WxPayOrderQueryRequest.newBuilder() + .outTradeNo(outTradeNo).build(); + // 执行请求 + WxPayOrderQueryResult response = client.queryOrder(request); + + // 转换结果 + Integer status = parseStatus(response.getTradeState()); + return PayOrderRespDTO.of(status, response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + outTradeNo, response); + } + + private PayOrderRespDTO doGetOrderV3(String outTradeNo) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayOrderQueryV3Request request = new WxPayOrderQueryV3Request() + .setOutTradeNo(outTradeNo); + // 执行请求 + WxPayOrderQueryV3Result response = client.queryOrderV3(request); + + // 转换结果 + Integer status = parseStatus(response.getTradeState()); + String openid = response.getPayer() != null ? response.getPayer().getOpenid() : null; + return PayOrderRespDTO.of(status, response.getTransactionId(), openid, parseDateV3(response.getSuccessTime()), + outTradeNo, response); + } + + private static Integer parseStatus(String tradeState) { + switch (tradeState) { + case "NOTPAY": + case "USERPAYING": // 支付中,等待用户输入密码(条码支付独有) + return PayOrderStatusRespEnum.WAITING.getStatus(); + case "SUCCESS": + return PayOrderStatusRespEnum.SUCCESS.getStatus(); + case "REFUND": + return PayOrderStatusRespEnum.REFUND.getStatus(); + case "CLOSED": + case "REVOKED": // 已撤销(刷卡支付独有) + case "PAYERROR": // 支付失败(其它原因,如银行返回失败) + return PayOrderStatusRespEnum.CLOSED.getStatus(); + default: + throw new IllegalArgumentException(StrUtil.format("未知的支付状态({})", tradeState)); + } + } + + // ============ 退款相关 ========== + + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + try { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doUnifiedRefundV2(reqDTO); + case WxPayClientConfig.API_VERSION_V3: + return doUnifiedRefundV3(reqDTO); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayRefundRespDTO.failureOf(errorCode, errorMessage, + reqDTO.getOutTradeNo(), e.getXmlString()); + } + } + + private PayRefundRespDTO doUnifiedRefundV2(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundRequest request = new WxPayRefundRequest() + .setOutTradeNo(reqDTO.getOutTradeNo()) + .setOutRefundNo(reqDTO.getOutRefundNo()) + .setRefundFee(reqDTO.getRefundPrice()) + .setRefundDesc(reqDTO.getReason()) + .setTotalFee(reqDTO.getPayPrice()) + .setNotifyUrl(reqDTO.getNotifyUrl()); + // 2.1 执行请求 + WxPayRefundResult response = client.refundV2(request); + // 2.2 创建返回结果 + if (Objects.equals("SUCCESS", response.getResultCode())) { // V2 情况下,不直接返回退款成功,而是等待异步通知 + return PayRefundRespDTO.waitingOf(response.getRefundId(), + reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(reqDTO.getOutRefundNo(), response); + } + + private PayRefundRespDTO doUnifiedRefundV3(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo(reqDTO.getOutTradeNo()) + .setOutRefundNo(reqDTO.getOutRefundNo()) + .setAmount(new WxPayRefundV3Request.Amount().setRefund(reqDTO.getRefundPrice()) + .setTotal(reqDTO.getPayPrice()).setCurrency("CNY")) + .setReason(reqDTO.getReason()) + .setNotifyUrl(reqDTO.getNotifyUrl()); + // 2.1 执行请求 + WxPayRefundV3Result response = client.refundV3(request); + // 2.2 创建返回结果 + if (Objects.equals("SUCCESS", response.getStatus())) { + return PayRefundRespDTO.successOf(response.getRefundId(), parseDateV3(response.getSuccessTime()), + reqDTO.getOutRefundNo(), response); + } + if (Objects.equals("PROCESSING", response.getStatus())) { + return PayRefundRespDTO.waitingOf(response.getRefundId(), + reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(reqDTO.getOutRefundNo(), response); + } + + @Override + public PayRefundRespDTO doParseRefundNotify(Map params, String body) throws WxPayException { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doParseRefundNotifyV2(body); + case WxPayClientConfig.API_VERSION_V3: + return parseRefundNotifyV3(body); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } + + private PayRefundRespDTO doParseRefundNotifyV2(String body) throws WxPayException { + // 1. 解析回调 + WxPayRefundNotifyResult response = client.parseRefundNotifyResult(body); + WxPayRefundNotifyResult.ReqInfo result = response.getReqInfo(); + // 2. 构建结果 + if (Objects.equals("SUCCESS", result.getRefundStatus())) { + return PayRefundRespDTO.successOf(result.getRefundId(), parseDateV2B(result.getSuccessTime()), + result.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(result.getOutRefundNo(), response); + } + + private PayRefundRespDTO parseRefundNotifyV3(String body) throws WxPayException { + // 1. 解析回调 + WxPayRefundNotifyV3Result response = client.parseRefundNotifyV3Result(body, null); + WxPayRefundNotifyV3Result.DecryptNotifyResult result = response.getResult(); + // 2. 构建结果 + if (Objects.equals("SUCCESS", result.getRefundStatus())) { + return PayRefundRespDTO.successOf(result.getRefundId(), parseDateV3(result.getSuccessTime()), + result.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(result.getOutRefundNo(), response); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) throws WxPayException { + try { + switch (config.getApiVersion()) { + case API_VERSION_V2: + return doGetRefundV2(outTradeNo, outRefundNo); + case WxPayClientConfig.API_VERSION_V3: + return doGetRefundV3(outTradeNo, outRefundNo); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + if (ObjectUtils.equalsAny(e.getErrCode(), "REFUNDNOTEXIST", "RESOURCE_NOT_EXISTS")) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayRefundRespDTO.failureOf(errorCode, errorMessage, + outRefundNo, e.getXmlString()); + } + throw e; + } + } + + private PayRefundRespDTO doGetRefundV2(String outTradeNo, String outRefundNo) throws WxPayException { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundQueryRequest request = WxPayRefundQueryRequest.newBuilder() + .outTradeNo(outTradeNo) + .outRefundNo(outRefundNo) + .build(); + // 2.1 执行请求 + WxPayRefundQueryResult response = client.refundQuery(request); + // 2.2 创建返回结果 + if (!Objects.equals("SUCCESS", response.getResultCode())) { + return PayRefundRespDTO.waitingOf(null, + outRefundNo, response); + } + WxPayRefundQueryResult.RefundRecord refund = CollUtil.findOne(response.getRefundRecords(), + record -> record.getOutRefundNo().equals(outRefundNo)); + if (refund == null) { + return PayRefundRespDTO.failureOf(outRefundNo, response); + } + switch (refund.getRefundStatus()) { + case "SUCCESS": + return PayRefundRespDTO.successOf(refund.getRefundId(), parseDateV2B(refund.getRefundSuccessTime()), + outRefundNo, response); + case "PROCESSING": + return PayRefundRespDTO.waitingOf(refund.getRefundId(), + outRefundNo, response); + case "CHANGE": // 退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,资金回流到商户的现金帐号,需要商户人工干预,通过线下或者财付通转账的方式进行退款 + case "FAIL": + return PayRefundRespDTO.failureOf(outRefundNo, response); + default: + throw new IllegalArgumentException(String.format("未知的退款状态(%s)", refund.getRefundStatus())); + } + } + + private PayRefundRespDTO doGetRefundV3(String outTradeNo, String outRefundNo) throws WxPayException { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundQueryV3Request request = new WxPayRefundQueryV3Request(); + request.setOutRefundNo(outRefundNo); + // 2.1 执行请求 + WxPayRefundQueryV3Result response = client.refundQueryV3(request); + // 2.2 创建返回结果 + switch (response.getStatus()) { + case "SUCCESS": + return PayRefundRespDTO.successOf(response.getRefundId(), parseDateV3(response.getSuccessTime()), + outRefundNo, response); + case "PROCESSING": + return PayRefundRespDTO.waitingOf(response.getRefundId(), + outRefundNo, response); + case "ABNORMAL": // 退款异常 + case "CLOSED": + return PayRefundRespDTO.failureOf(outRefundNo, response); + default: + throw new IllegalArgumentException(String.format("未知的退款状态(%s)", response.getStatus())); + } + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + throw new UnsupportedOperationException("待实现"); + } + + @Override + protected PayTransferRespDTO doGetTransfer(String outTradeNo, PayTransferTypeEnum type) { + throw new UnsupportedOperationException("待实现"); + } + + // ========== 各种工具方法 ========== + + static String formatDateV2(LocalDateTime time) { + return TemporalAccessorUtil.format(time.atZone(ZoneId.systemDefault()), PURE_DATETIME_PATTERN); + } + + static LocalDateTime parseDateV2(String time) { + return LocalDateTimeUtil.parse(time, PURE_DATETIME_PATTERN); + } + + static LocalDateTime parseDateV2B(String time) { + return LocalDateTimeUtil.parse(time, NORM_DATETIME_PATTERN); + } + + static String formatDateV3(LocalDateTime time) { + return TemporalAccessorUtil.format(time.atZone(ZoneId.systemDefault()), UTC_WITH_XXX_OFFSET_PATTERN); + } + + static LocalDateTime parseDateV3(String time) { + return LocalDateTimeUtil.parse(time, UTC_WITH_XXX_OFFSET_PATTERN); + } + + static String getErrorCode(WxPayException e) { + if (StrUtil.isNotEmpty(e.getErrCode())) { + return e.getErrCode(); + } + if (StrUtil.isNotEmpty(e.getCustomErrorMsg())) { + return "CUSTOM_ERROR"; + } + return e.getReturnCode(); + } + + static String getErrorMessage(WxPayException e) { + if (StrUtil.isNotEmpty(e.getErrCode())) { + return e.getErrCodeDes(); + } + if (StrUtil.isNotEmpty(e.getCustomErrorMsg())) { + return e.getCustomErrorMsg(); + } + return e.getReturnMsg(); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxAppPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxAppPayClient.java new file mode 100644 index 0000000000..396694a75b --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxAppPayClient.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayMpOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付【App 支付】的 PayClient 实现类 + * + * 文档:App 支付 + * + * // TODO 芋艿:未详细测试,因为手头没 App + * + * @author 芋道源码 + */ +@Slf4j +public class WxAppPayClient extends AbstractWxPayClient { + + public WxAppPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_APP.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.APP); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO); + // 执行请求 + WxPayMpOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderV3Request 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO); + // 执行请求 + WxPayUnifiedOrderV3Result.AppResult response = client.createOrderV3(TradeTypeEnum.APP, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClient.java new file mode 100644 index 0000000000..d01b504050 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClient.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.request.WxPayMicropayRequest; +import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付【付款码支付】的 PayClient 实现类 + * + * 文档:付款码支付 + * + * @author 芋道源码 + */ +@Slf4j +public class WxBarPayClient extends AbstractWxPayClient { + + /** + * 微信付款码的过期时间 + */ + private static final Duration AUTH_CODE_EXPIRE = Duration.ofMinutes(3); + + public WxBarPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_BAR.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.MICROPAY); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 由于付款码需要不断轮询,所以需要在较短的时间完成支付 + LocalDateTime expireTime = LocalDateTimeUtils.addTime(AUTH_CODE_EXPIRE); + if (expireTime.isAfter(reqDTO.getExpireTime())) { + expireTime = reqDTO.getExpireTime(); + } + // 构建 WxPayMicropayRequest 对象 + WxPayMicropayRequest request = WxPayMicropayRequest.newBuilder() + .outTradeNo(reqDTO.getOutTradeNo()) + .body(reqDTO.getSubject()) + .detail(reqDTO.getBody()) + .totalFee(reqDTO.getPrice()) // 单位分 + .timeExpire(formatDateV2(expireTime)) + .spbillCreateIp(reqDTO.getUserIp()) + .authCode(getAuthCode(reqDTO)) + .build(); + // 执行请求,重试直到失败(过期),或者成功 + WxPayException lastWxPayException = null; + for (int i = 1; i < Byte.MAX_VALUE; i++) { + try { + WxPayMicropayResult response = client.micropay(request); + // 支付成功,例如说:1)用户输入了密码;2)用户免密支付 + return PayOrderRespDTO.successOf(response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + response.getOutTradeNo(), response) + .setDisplayMode(PayOrderDisplayModeEnum.BAR_CODE.getMode()); + } catch (WxPayException ex) { + lastWxPayException = ex; + // 如果不满足这 3 种任一的,则直接抛出 WxPayException 异常,不仅需处理 + // 1. SYSTEMERROR:接口返回错误:请立即调用被扫订单结果查询API,查询当前订单状态,并根据订单的状态决定下一步的操作。 + // 2. USERPAYING:用户支付中,需要输入密码:等待 5 秒,然后调用被扫订单结果查询 API,查询当前订单的不同状态,决定下一步的操作。 + // 3. BANKERROR:银行系统异常:请立即调用被扫订单结果查询 API,查询当前订单的不同状态,决定下一步的操作。 + if (!StrUtil.equalsAny(ex.getErrCode(), "SYSTEMERROR", "USERPAYING", "BANKERROR")) { + throw ex; + } + // 等待 5 秒,继续下一轮重新发起支付 + log.info("[doUnifiedOrderV2][发起微信 Bar 支付第({})失败,等待下一轮重试,请求({}),响应({})]", i, + toJsonString(request), ex.getMessage()); + ThreadUtil.sleep(5, TimeUnit.SECONDS); + } + } + throw lastWxPayException; + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + return doUnifiedOrderV2(reqDTO); + } + + // ========== 各种工具方法 ========== + + static String getAuthCode(PayOrderUnifiedReqDTO reqDTO) { + String authCode = MapUtil.getStr(reqDTO.getChannelExtras(), "authCode"); + if (StrUtil.isEmpty(authCode)) { + throw invalidParamException("支付请求的 authCode 不能为空!"); + } + return authCode; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxLitePayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxLitePayClient.java new file mode 100644 index 0000000000..9929955ae2 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxLitePayClient.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import lombok.extern.slf4j.Slf4j; + +/** + * 微信支付【小程序】的 PayClient 实现类 + * + * 由于公众号和小程序的微信支付逻辑一致,所以直接进行继承 + * + * 文档:JSAPI 下单 + * + * @author zwy + */ +@Slf4j +public class WxLitePayClient extends WxPubPayClient { + + public WxLitePayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_LITE.getCode(), config); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClient.java new file mode 100644 index 0000000000..5a073501d4 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClient.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayNativeOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +/** + * 微信支付【Native 二维码】的 PayClient 实现类 + * + * 文档:Native 下单 + * + * @author zwy + */ +@Slf4j +public class WxNativePayClient extends AbstractWxPayClient { + + public WxNativePayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_NATIVE.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.NATIVE); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO); + // 执行请求 + WxPayNativeOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.QR_CODE.getMode(), response.getCodeUrl(), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderV3Request 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO); + // 执行请求 + String response = client.createOrderV3(TradeTypeEnum.NATIVE, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.QR_CODE.getMode(), response, + reqDTO.getOutTradeNo(), response); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPayClientConfig.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPayClientConfig.java new file mode 100644 index 0000000000..77027ae3aa --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPayClientConfig.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.io.IoUtil; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; +import javax.validation.constraints.NotBlank; +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * 微信支付的 PayClientConfig 实现类 + * 属性主要来自 {@link com.github.binarywang.wxpay.config.WxPayConfig} 的必要属性 + * + * @author 芋道源码 + */ +@Data +public class WxPayClientConfig implements PayClientConfig { + + /** + * API 版本 - V2 + * + * V2 协议说明 + */ + public static final String API_VERSION_V2 = "v2"; + /** + * API 版本 - V3 + * + * V3 协议说明 + */ + public static final String API_VERSION_V3 = "v3"; + + /** + * 公众号或者小程序的 appid + * + * 只有公众号或小程序需要该字段 + */ + @NotBlank(message = "APPID 不能为空", groups = {V2.class, V3.class}) + private String appId; + /** + * 商户号 + */ + @NotBlank(message = "商户号不能为空", groups = {V2.class, V3.class}) + private String mchId; + /** + * API 版本 + */ + @NotBlank(message = "API 版本不能为空", groups = {V2.class, V3.class}) + private String apiVersion; + + // ========== V2 版本的参数 ========== + + /** + * 商户密钥 + */ + @NotBlank(message = "商户密钥不能为空", groups = V2.class) + private String mchKey; + /** + * apiclient_cert.p12 证书文件的对应字符串【base64 格式】 + * + * 为什么采用 base64 格式?因为 p12 读取后是二进制,需要转换成 base64 格式才好传输和存储 + */ + @NotBlank(message = "apiclient_cert.p12 不能为空", groups = V2.class) + private String keyContent; + + // ========== V3 版本的参数 ========== + /** + * apiclient_key.pem 证书文件的对应字符串 + */ + @NotBlank(message = "apiclient_key 不能为空", groups = V3.class) + private String privateKeyContent; + /** + * apiclient_cert.pem 证书文件的对应的字符串 + */ + @NotBlank(message = "apiclient_cert 不能为空", groups = V3.class) + private String privateCertContent; + /** + * apiV3 密钥值 + */ + @NotBlank(message = "apiV3 密钥值不能为空", groups = V3.class) + private String apiV3Key; + + /** + * 分组校验 v2版本 + */ + public interface V2 { + } + + /** + * 分组校验 v3版本 + */ + public interface V3 { + } + + @Override + public void validate(Validator validator) { + ValidationUtils.validate(validator, this, + API_VERSION_V2.equals(this.getApiVersion()) ? V2.class : V3.class); + } + + public static void main(String[] args) throws FileNotFoundException { + String path = "/Users/yunai/Downloads/wx_pay/apiclient_cert.p12"; + /// String path = "/Users/yunai/Downloads/wx_pay/apiclient_key.pem"; + /// String path = "/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"; + System.out.println(IoUtil.readUtf8(new FileInputStream(path))); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPubPayClient.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPubPayClient.java new file mode 100644 index 0000000000..390c513630 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxPubPayClient.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayMpOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付(公众号)的 PayClient 实现类 + * + * 文档:JSAPI 下单 + * + * @author 芋道源码 + */ +@Slf4j +public class WxPubPayClient extends AbstractWxPayClient { + + public WxPubPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_PUB.getCode(), config); + } + + protected WxPubPayClient(Long channelId, String channelCode, WxPayClientConfig config) { + super(channelId, channelCode, config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.JSAPI); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO) + .setOpenid(getOpenid(reqDTO)); + // 执行请求 + WxPayMpOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO) + .setPayer(new WxPayUnifiedOrderV3Request.Payer().setOpenid(getOpenid(reqDTO))); + // 执行请求 + WxPayUnifiedOrderV3Result.JsapiResult response = client.createOrderV3(TradeTypeEnum.JSAPI, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + // ========== 各种工具方法 ========== + + static String getOpenid(PayOrderUnifiedReqDTO reqDTO) { + String openid = MapUtil.getStr(reqDTO.getChannelExtras(), "openid"); + if (StrUtil.isEmpty(openid)) { + throw invalidParamException("支付请求的 openid 不能为空!"); + } + return openid; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/channel/PayChannelEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/channel/PayChannelEnum.java new file mode 100644 index 0000000000..83d2d13add --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/channel/PayChannelEnum.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.framework.pay.core.enums.channel; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.pay.core.client.impl.NonePayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.impl.weixin.WxPayClientConfig; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付渠道的编码的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PayChannelEnum { + + WX_PUB("wx_pub", "微信 JSAPI 支付", WxPayClientConfig.class), // 公众号网页 + WX_LITE("wx_lite", "微信小程序支付", WxPayClientConfig.class), + WX_APP("wx_app", "微信 App 支付", WxPayClientConfig.class), + WX_NATIVE("wx_native", "微信 Native 支付", WxPayClientConfig.class), + WX_BAR("wx_bar", "微信付款码支付", WxPayClientConfig.class), + + ALIPAY_PC("alipay_pc", "支付宝 PC 网站支付", AlipayPayClientConfig.class), + ALIPAY_WAP("alipay_wap", "支付宝 Wap 网站支付", AlipayPayClientConfig.class), + ALIPAY_APP("alipay_app", "支付宝App 支付", AlipayPayClientConfig.class), + ALIPAY_QR("alipay_qr", "支付宝扫码支付", AlipayPayClientConfig.class), + ALIPAY_BAR("alipay_bar", "支付宝条码支付", AlipayPayClientConfig.class), + MOCK("mock", "模拟支付", NonePayClientConfig.class), + + WALLET("wallet", "钱包支付", NonePayClientConfig.class); + + /** + * 编码 + * + * 参考 支付渠道属性值 + */ + private final String code; + /** + * 名字 + */ + private final String name; + + /** + * 配置类 + */ + private final Class configClass; + + /** + * 微信支付 + */ + public static final String WECHAT = "WECHAT"; + + /** + * 支付宝支付 + */ + public static final String ALIPAY = "ALIPAY"; + + public static PayChannelEnum getByCode(String code) { + return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values()); + } + + public static boolean isAlipay(String channelCode) { + return channelCode != null && channelCode.startsWith("alipay"); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java new file mode 100644 index 0000000000..129c406029 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.framework.pay.core.enums.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付 UI 展示模式 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PayOrderDisplayModeEnum { + + URL("url"), // Redirect 跳转链接的方式 + IFRAME("iframe"), // IFrame 内嵌链接的方式【目前暂时用不到】 + FORM("form"), // HTML 表单提交 + QR_CODE("qr_code"), // 二维码的文字内容 + QR_CODE_URL("qr_code_url"), // 二维码的图片链接 + BAR_CODE("bar_code"), // 条形码 + APP("app"), // 应用:Android、iOS、微信小程序、微信公众号等,需要做自定义处理的 + ; + + /** + * 展示模式 + */ + private final String mode; + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderStatusRespEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderStatusRespEnum.java new file mode 100644 index 0000000000..eac381c472 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/order/PayOrderStatusRespEnum.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.framework.pay.core.enums.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的支付状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PayOrderStatusRespEnum { + + WAITING(0, "未支付"), + SUCCESS(10, "支付成功"), + REFUND(20, "已退款"), + CLOSED(30, "支付关闭"), + ; + + private final Integer status; + private final String name; + + /** + * 判断是否支付成功 + * + * @param status 状态 + * @return 是否支付成功 + */ + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + /** + * 判断是否已退款 + * + * @param status 状态 + * @return 是否支付成功 + */ + public static boolean isRefund(Integer status) { + return Objects.equals(status, REFUND.getStatus()); + } + + /** + * 判断是否支付关闭 + * + * @param status 状态 + * @return 是否支付关闭 + */ + public static boolean isClosed(Integer status) { + return Objects.equals(status, CLOSED.getStatus()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java new file mode 100644 index 0000000000..8ad61a6cff --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.framework.pay.core.enums.refund; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的退款状态枚举 + * + * @author jason + */ +@Getter +@AllArgsConstructor +public enum PayRefundStatusRespEnum { + + WAITING(0, "等待退款"), + SUCCESS(10, "退款成功"), + FAILURE(20, "退款失败"); + + private final Integer status; + private final String name; + + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + public static boolean isFailure(Integer status) { + return Objects.equals(status, FAILURE.getStatus()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java new file mode 100644 index 0000000000..35ea344da2 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.framework.pay.core.enums.transfer; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的转账状态枚举 + * + * @author jason + */ +@Getter +@AllArgsConstructor +public enum PayTransferStatusRespEnum { + + WAITING(0, "等待转账"), + + /** + * TODO 转账到银行卡. 会有T+0 T+1 到账的请情况。 还未实现 + * TODO @jason:可以看看其它开源项目,针对这个场景,处理策略是怎么样的?例如说,每天主动轮询?这个状态的单子? + */ + IN_PROGRESS(10, "转账进行中"), + + SUCCESS(20, "转账成功"), + /** + * 转账关闭 (失败,或者其它情况) + */ + CLOSED(30, "转账关闭"); + + private final Integer status; + private final String name; + + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + public static boolean isClosed(Integer status) { + return Objects.equals(status, CLOSED.getStatus()); + } + + public static boolean isInProgress(Integer status) { + return Objects.equals(status, IN_PROGRESS.getStatus()); + } +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferTypeEnum.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferTypeEnum.java new file mode 100644 index 0000000000..a7580f0132 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/transfer/PayTransferTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.framework.pay.core.enums.transfer; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 转账类型枚举 + * + * @author jason + */ +@AllArgsConstructor +@Getter +public enum PayTransferTypeEnum implements IntArrayValuable { + + ALIPAY_BALANCE(1, "支付宝余额"), + WX_BALANCE(2, "微信余额"), + BANK_CARD(3, "银行卡"), + WALLET_BALANCE(4, "钱包余额"); + + public interface WxPay { + } + + public interface Alipay { + } + + private final Integer type; + private final String name; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(PayTransferTypeEnum::getType).toArray(); + + @Override + public int[] array() { + return ARRAYS; + } + + public static PayTransferTypeEnum typeOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..f2a8bf1463 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.iocoder.yudao.framework.pay.config.YudaoPayAutoConfiguration \ No newline at end of file diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java new file mode 100644 index 0000000000..9842560636 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.RandomUtil; +import cn.iocoder.yudao.framework.pay.core.client.PayClient; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayQrPayClient; +import cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayWapPayClient; +import cn.iocoder.yudao.framework.pay.core.client.impl.weixin.WxPayClientConfig; +import cn.iocoder.yudao.framework.pay.core.client.impl.weixin.WxPubPayClient; +import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * {@link PayClientFactoryImpl} 的集成测试 + * + * @author 芋道源码 + */ +@Disabled +public class PayClientFactoryImplIntegrationTest { + + private static final String SERVER_URL_SANDBOX = "https://openapi.alipaydev.com/gateway.do"; + + private final PayClientFactoryImpl payClientFactory = new PayClientFactoryImpl(); + + /** + * {@link WxPubPayClient} 的 V2 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V2() { + // 创建配置 + WxPayClientConfig config = new WxPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WxPayClientConfig.API_VERSION_V2); + config.setMchKey("0alL64UDQdlCwiKZ73ib7ypaIjMns06p"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(result); + } + + /** + * {@link WxPubPayClient} 的 V3 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V3() throws FileNotFoundException { + // 创建配置 + WxPayClientConfig config = new WxPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WxPayClientConfig.API_VERSION_V3); + config.setPrivateKeyContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_key.pem"))); + config.setPrivateCertContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"))); + config.setApiV3Key("joerVi8y5DJ3o4ttA0o1uH47Xz1u2Ase"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(result); + } + + /** + * {@link AlipayQrPayClient} + */ + @Test + @SuppressWarnings("unchecked") + public void testCreatePayClient_ALIPAY_QR() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_QR.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + reqDTO.setNotifyUrl("http://yunai.natapp1.cc/admin-api/pay/notify/callback/18"); // TODO @tina: 这里改成你的 natapp 回调地址 +// CommonResult result = (CommonResult) client.unifiedOrder(reqDTO); +// System.out.println(JsonUtils.toJsonString(result)); +// System.out.println(result.getData().getQrCode()); + } + + /** + * {@link AlipayWapPayClient} + */ + @Test + public void testCreatePayClient_ALIPAY_WAP() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_WAP.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(JsonUtils.toJsonString(result)); + } + + private static PayOrderUnifiedReqDTO buildPayOrderUnifiedReqDTO() { + PayOrderUnifiedReqDTO reqDTO = new PayOrderUnifiedReqDTO(); + reqDTO.setPrice(123); + reqDTO.setSubject("IPhone 13"); + reqDTO.setBody("biubiubiu"); + reqDTO.setOutTradeNo(String.valueOf(System.currentTimeMillis())); + reqDTO.setUserIp("127.0.0.1"); + reqDTO.setNotifyUrl("http://127.0.0.1:8080"); + return reqDTO; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java new file mode 100644 index 0000000000..2d220079eb --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java @@ -0,0 +1,221 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.RandomUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.exception.PayException; +import cn.iocoder.yudao.framework.pay.core.enums.refund.PayRefundStatusRespEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import com.alipay.api.AlipayApiException; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.DefaultSigner; +import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.response.AlipayTradeRefundResponse; +import lombok.Setter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; + +import javax.validation.ConstraintViolationException; +import java.util.Date; + +import static cn.iocoder.yudao.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_PUBLIC_KEY; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * 支付宝 Client 的测试基类 + * + * @author jason + */ +public abstract class AbstractAlipayClientTest extends BaseMockitoUnitTest { + + protected AlipayPayClientConfig config = randomPojo(AlipayPayClientConfig.class, o -> { + o.setServerUrl(randomURL()); + o.setPrivateKey(randomString()); + o.setMode(MODE_PUBLIC_KEY); + o.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + o.setAppCertContent(""); + o.setAlipayPublicCertContent(""); + o.setRootCertContent(""); + }); + + @Mock + protected DefaultAlipayClient defaultAlipayClient; + + @Setter + private AbstractAlipayPayClient client; + + /** + * 子类需要实现该方法. 设置 client 的具体实现 + */ + @BeforeEach + public abstract void setUp(); + + @Test + @DisplayName("支付宝 Client 初始化") + public void testDoInit() { + // 调用 + client.doInit(); + // 断言 + DefaultAlipayClient realClient = client.getClient(); + assertNotSame(defaultAlipayClient, realClient); + assertInstanceOf(DefaultSigner.class, realClient.getSigner()); + assertEquals(config.getPrivateKey(), ((DefaultSigner) realClient.getSigner()).getPrivateKey()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:成功") + public void testUnifiedRefund_success() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + Date refundTime = randomDate(); + String outRefundNo = randomString(); + String outTradeNo = randomString(); + Integer refundAmount = randomInteger(); + AlipayTradeRefundResponse response = randomPojo(AlipayTradeRefundResponse.class, o -> { + o.setSubCode(""); + o.setGmtRefundPay(refundTime); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeRefundModel.class, request.getBizModel()); + AlipayTradeRefundModel bizModel = (AlipayTradeRefundModel) request.getBizModel(); + assertEquals(outRefundNo, bizModel.getOutRequestNo()); + assertEquals(outTradeNo, bizModel.getOutTradeNo()); + assertEquals(String.valueOf(refundAmount / 100.0), bizModel.getRefundAmount()); + return true; + }))).thenReturn(response); + // 准备请求参数 + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutRefundNo(outRefundNo); + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + o.setRefundPrice(refundAmount); + }); + + // 调用 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO); + // 断言 + assertEquals(PayRefundStatusRespEnum.SUCCESS.getStatus(), resp.getStatus()); + assertEquals(outRefundNo, resp.getOutRefundNo()); + assertNull(resp.getChannelRefundNo()); + assertEquals(LocalDateTimeUtil.of(refundTime), resp.getSuccessTime()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:渠道返回失败") + public void test_unified_refund_channel_failed() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradeRefundResponse response = randomPojo(AlipayTradeRefundResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeRefundModel.class, request.getBizModel()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outRefundNo = randomString(); + String outTradeNo = randomString(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutRefundNo(outRefundNo); + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + }); + + // 调用 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO); + // 断言 + assertEquals(PayRefundStatusRespEnum.FAILURE.getStatus(), resp.getStatus()); + assertEquals(outRefundNo, resp.getOutRefundNo()); + assertNull(resp.getChannelRefundNo()); + assertNull(resp.getSuccessTime()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:参数校验不通过") + public void testUnifiedRefund_paramInvalidate() { + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutTradeNo(""); + o.setNotifyUrl(notifyUrl); + }); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一退款:抛出业务异常") + public void testUnifiedRefund_throwServiceException() throws AlipayApiException { + // mock 方法 + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenThrow(ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR)); + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> o.setNotifyUrl(notifyUrl)); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一退款:抛出系统异常") + public void testUnifiedRefund_throwPayException() throws AlipayApiException { + // mock 方法 + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenThrow(new RuntimeException("系统异常")); + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> o.setNotifyUrl(notifyUrl)); + + // 调用,并断言 + assertThrows(PayException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一下单:参数校验不通过") + public void testUnifiedOrder_paramInvalidate() { + // 准备请求参数 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + PayOrderUnifiedReqDTO reqDTO = randomPojo(PayOrderUnifiedReqDTO.class, o -> { + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + }); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> client.unifiedOrder(reqDTO)); + } + + protected PayOrderUnifiedReqDTO buildOrderUnifiedReqDTO(String notifyUrl, String outTradeNo, Integer price) { + return randomPojo(PayOrderUnifiedReqDTO.class, o -> { + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + o.setPrice(price); + o.setSubject(RandomUtil.randomString(32)); + o.setBody(RandomUtil.randomString(32)); + }); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java new file mode 100644 index 0000000000..47f10081ca --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java @@ -0,0 +1,170 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePayModel; +import com.alipay.api.request.AlipayTradePayRequest; +import com.alipay.api.response.AlipayTradePayResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayBarPayClient} 单元测试 + * + * @author jason + */ +public class AlipayBarPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayBarPayClient client = new AlipayBarPayClient(randomLongId(), config); + + @Override + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝条码支付:非免密码支付下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + String authCode = randomString(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradePayModel.class, request.getBizModel()); + assertEquals(notifyUrl, request.getNotifyUrl()); + AlipayTradePayModel model = (AlipayTradePayModel) request.getBizModel(); + assertEquals(outTradeNo, model.getOutTradeNo()); + assertEquals(String.valueOf(price / 100.0), model.getTotalAmount()); + assertEquals(authCode, model.getAuthCode()); + return true; + }))).thenReturn(response); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 调用方法 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.BAR_CODE.getMode(), resp.getDisplayMode()); + assertEquals("", resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝条码支付:免密码支付下单成功") + public void testUnifiedOrder_code10000Success() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String channelNo = randomString(); + String channelUserId = randomString(); + Date payTime = randomDate(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> { + o.setSubCode(""); + o.setCode("10000"); + o.setOutTradeNo(outTradeNo); + o.setTradeNo(channelNo); + o.setBuyerUserId(channelUserId); + o.setGmtPayment(payTime); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenReturn(response); + // 准备请求参数 + String authCode = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 下单请求 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(PayOrderStatusRespEnum.SUCCESS.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertEquals(channelNo, resp.getChannelOrderNo()); + assertEquals(channelUserId, resp.getChannelUserId()); + assertEquals(LocalDateTimeUtil.of(payTime), resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.BAR_CODE.getMode(), resp.getDisplayMode()); + assertEquals("", resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝条码支付:没有传条码") + public void testUnifiedOrder_emptyAuthCode() { + // 准备参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), randomString(), randomInteger()); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedOrder(reqDTO)); + } + + @Test + @DisplayName("支付宝条码支付:渠道返回失败") + public void test_unified_order_channel_failed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenReturn(response); + // 准备请求参数 + String authCode = randomString(); + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 调用方法 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java new file mode 100644 index 0000000000..d78caf28c5 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.request.AlipayTradePagePayRequest; +import com.alipay.api.response.AlipayTradePagePayResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayPcPayClient} 单元测试 + * + * @author jason + */ +public class AlipayPcPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayPcPayClient client = new AlipayPcPayClient(randomLongId(), config); + + @Override + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝 PC 网站支付:URL Display Mode 下单成功") + public void testUnifiedOrder_urlSuccess() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + Integer price = randomInteger(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + reqDTO.setDisplayMode(null); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.URL.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 PC 网站支付:Form Display Mode 下单成功") + public void testUnifiedOrder_formSuccess() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.POST.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + Integer price = randomInteger(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + reqDTO.setDisplayMode(PayOrderDisplayModeEnum.FORM.getMode()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.FORM.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 PC 网站支付:渠道返回失败") + public void testUnifiedOrder_channelFailed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + reqDTO.setDisplayMode(PayOrderDisplayModeEnum.URL.getMode()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java new file mode 100644 index 0000000000..c7e1eb33f7 --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java @@ -0,0 +1,147 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.client.exception.PayException; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayQrPayClient} 单元测试 + * + * @author jason + */ +public class AlipayQrPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayQrPayClient client = new AlipayQrPayClient(randomLongId(), config); + + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝扫描支付:下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String qrCode = randomString(); + Integer price = randomInteger(); + AlipayTradePrecreateResponse response = randomPojo(AlipayTradePrecreateResponse.class, o -> { + o.setQrCode(qrCode); + o.setSubCode(""); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.QR_CODE.getMode(), resp.getDisplayMode()); + assertEquals(response.getQrCode(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝扫描支付:渠道返回失败") + public void testUnifiedOrder_channelFailed() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String subCode = randomString(); + String subMsg = randomString(); + Integer price = randomInteger(); + AlipayTradePrecreateResponse response = randomPojo(AlipayTradePrecreateResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + // mock + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝扫描支付, 抛出系统异常") + public void testUnifiedOrder_throwPayException() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenThrow(new RuntimeException("系统异常")); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo,price); + + // 调用,并断言 + assertThrows(PayException.class, () -> client.unifiedOrder(reqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一下单:抛出业务异常") + public void testUnifiedOrder_throwServiceException() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenThrow(ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR)); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedOrder(reqDTO)); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java new file mode 100644 index 0000000000..af0c7170bf --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeWapPayModel; +import com.alipay.api.request.AlipayTradeWapPayRequest; +import com.alipay.api.response.AlipayTradeWapPayResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static cn.iocoder.yudao.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayWapPayClient} 单元测试 + * + * @author jason + */ +public class AlipayWapPayClientTest extends AbstractAlipayClientTest { + + /** + * 支付宝 H5 支付 Client + */ + @InjectMocks + private AlipayWapPayClient client = new AlipayWapPayClient(randomLongId(), config); + + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝 H5 支付:下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String h5Body = randomString(); + Integer price = randomInteger(); + AlipayTradeWapPayResponse response = randomPojo(AlipayTradeWapPayResponse.class, o -> { + o.setSubCode(""); + o.setBody(h5Body); + }); + String notifyUrl = randomURL(); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeWapPayModel.class, request.getBizModel()); + AlipayTradeWapPayModel bizModel = (AlipayTradeWapPayModel) request.getBizModel(); + assertEquals(String.valueOf(price / 100.0), bizModel.getTotalAmount()); + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }), eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.URL.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 H5 支付:渠道返回失败") + public void test_unified_order_channel_failed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradeWapPayResponse response = randomPojo(AlipayTradeWapPayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java new file mode 100644 index 0000000000..9af11ac3fc --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyResult; +import com.github.binarywang.wxpay.bean.request.WxPayMicropayRequest; +import com.github.binarywang.wxpay.bean.request.WxPayRefundRequest; +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult; +import com.github.binarywang.wxpay.bean.result.WxPayRefundResult; +import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static cn.iocoder.yudao.framework.pay.core.client.impl.weixin.AbstractWxPayClient.formatDateV2; + +/** + * {@link WxBarPayClient} 的集成测试,用于快速调试微信条码支付 + * + * @author 芋道源码 + */ +@Disabled +public class WxBarPayClientIntegrationTest { + + @Test + public void testPayV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起支付 + WxPayMicropayRequest request = WxPayMicropayRequest.newBuilder() + .outTradeNo(String.valueOf(System.currentTimeMillis())) + .body("测试支付-body") + .detail("测试支付-detail") + .totalFee(1) // 单位分 + .timeExpire(formatDateV2(LocalDateTimeUtils.addTime(Duration.ofMinutes(2)))) + .spbillCreateIp("127.0.0.1") + .authCode("134298744426278497") + .build(); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayMicropayResult response = client.micropay(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testParseRefundNotifyV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行解析 + String xml = "SUCCESS"; + WxPayRefundNotifyResult response = client.parseRefundNotifyResult(xml); + System.out.println(response.getReqInfo()); + } + + @Test + public void testRefundV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundRequest request = new WxPayRefundRequest() + .setOutTradeNo("1689545667276") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setRefundFee(1) + .setRefundDesc("就是想退了") + .setTotalFee(1); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundResult response = client.refund(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testRefundV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo("1689506325635") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setAmount(new WxPayRefundV3Request.Amount().setTotal(1).setRefund(1).setCurrency("CNY")) + .setReason("就是想退了"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundV3Result response = client.refundV3(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + private WxPayConfig buildWxPayConfigV2() { + WxPayConfig config = new WxPayConfig(); + config.setAppId("wx62056c0d5e8db250"); + config.setMchId("1545083881"); + config.setMchKey("dS1ngeN63JLr3NRbvPH9AJy3MyUxZdim"); +// config.setSignType(WxPayConstants.SignType.MD5); + config.setKeyPath("/Users/yunai/Downloads/wx_pay/apiclient_cert.p12"); + return config; + } + +} diff --git a/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java new file mode 100644 index 0000000000..5e73601c2b --- /dev/null +++ b/yudao-module-pay/yudao-spring-boot-starter-biz-pay/src/test/java/cn/iocoder/yudao/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static cn.iocoder.yudao.framework.pay.core.client.impl.weixin.AbstractWxPayClient.formatDateV3; + +/** + * {@link WxNativePayClient} 的集成测试,用于快速调试微信扫码支付 + * + * @author 芋道源码 + */ +@Disabled +public class WxNativePayClientIntegrationTest { + + @Test + public void testPayV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV3(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起支付 + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request() + .setOutTradeNo(String.valueOf(System.currentTimeMillis())) + .setDescription("测试支付-body") + .setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(1)) // 单位分 + .setTimeExpire(formatDateV3(LocalDateTimeUtils.addTime(Duration.ofMinutes(2)))) + .setSceneInfo(new WxPayUnifiedOrderV3Request.SceneInfo().setPayerClientIp("127.0.0.1")) + .setNotifyUrl("http://127.0.0.1:48080"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + String response = client.createOrderV3(TradeTypeEnum.NATIVE, request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testRefundV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV3(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo("1689545729695") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setAmount(new WxPayRefundV3Request.Amount().setTotal(1).setRefund(1).setCurrency("CNY")) + .setReason("就是想退了"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundV3Result response = client.refundV3(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + private WxPayConfig buildWxPayConfigV3() { + WxPayConfig config = new WxPayConfig(); + config.setAppId("wx62056c0d5e8db250"); + config.setMchId("1545083881"); + config.setApiV3Key("459arNsYHl1mgkiO6H9ZH5KkhFXSxaA4"); +// config.setCertSerialNo(serialNo); + config.setPrivateCertPath("/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"); + config.setPrivateKeyPath("/Users/yunai/Downloads/wx_pay/apiclient_key.pem"); + return config; + } + +} diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApi.java index 107184564f..5c47f4de3c 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApi.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApi.java @@ -3,6 +3,9 @@ package cn.iocoder.yudao.module.system.api.dict; import cn.iocoder.yudao.module.system.api.dict.dto.DictDataRespDTO; import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; /** * 字典数据 API 接口 @@ -39,4 +42,23 @@ public interface DictDataApi { */ DictDataRespDTO parseDictData(String type, String label); + /** + * 获得指定字典类型的字典数据列表 + * + * @param dictType 字典类型 + * @return 字典数据列表 + */ + List getDictDataList(String dictType); + + /** + * 获得字典数据标签列表 + * + * @param dictType 字典类型 + * @return 字典数据标签列表 + */ + default List getDictDataLabelList(String dictType) { + List list = getDictDataList(dictType); + return convertList(list, DictDataRespDTO::getLabel); + } + } diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java index cea3c3663b..507fb4b3e7 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java @@ -26,10 +26,10 @@ public interface AdminUserApi { /** * 通过用户 ID 查询用户下属 * - * @param userId 用户编号 + * @param id 用户编号 * @return 用户下属用户列表 */ - List getUserListBySubordinate(Long userId); + List getUserListBySubordinate(Long id); /** * 通过用户 ID 查询用户们 diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/permission/RoleCodeEnum.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/permission/RoleCodeEnum.java index b13b3b71a0..cdf9a001d8 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/permission/RoleCodeEnum.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/permission/RoleCodeEnum.java @@ -13,6 +13,7 @@ public enum RoleCodeEnum { SUPER_ADMIN("super_admin", "超级管理员"), TENANT_ADMIN("tenant_admin", "租户管理员"), + CRM_ADMIN("crm_admin", "CRM 管理员"); // CRM 系统专用 ; /** diff --git a/yudao-module-system/yudao-module-system-biz/pom.xml b/yudao-module-system/yudao-module-system-biz/pom.xml index 49ddf4e04d..1810124fe1 100644 --- a/yudao-module-system/yudao-module-system-biz/pom.xml +++ b/yudao-module-system/yudao-module-system-biz/pom.xml @@ -34,10 +34,6 @@ cn.iocoder.boot yudao-spring-boot-starter-biz-operatelog - - cn.iocoder.boot - yudao-spring-boot-starter-biz-dict - cn.iocoder.boot yudao-spring-boot-starter-biz-data-permission @@ -91,11 +87,6 @@ yudao-spring-boot-starter-excel - - cn.iocoder.boot - yudao-spring-boot-starter-captcha - - org.springframework.boot spring-boot-starter-mail @@ -128,6 +119,11 @@ com.tencentcloudapi tencentcloud-sdk-java-sms + + + com.xingyuv + spring-boot-starter-captcha-plus + diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApiImpl.java index 3d045cd40f..aecaf80b25 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApiImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/dict/DictDataApiImpl.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Collection; +import java.util.List; /** * 字典数据 API 实现类 @@ -37,4 +38,10 @@ public class DictDataApiImpl implements DictDataApi { return BeanUtils.toBean(dictData, DictDataRespDTO.class); } + @Override + public List getDictDataList(String dictType) { + List list = dictDataService.getDictDataListByDictType(dictType); + return BeanUtils.toBean(list, DictDataRespDTO.class); + } + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java index e9edcc9a0b..373e088e40 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java @@ -38,9 +38,9 @@ public class AdminUserApiImpl implements AdminUserApi { } @Override - public List getUserListBySubordinate(Long userId) { + public List getUserListBySubordinate(Long id) { // 1.1 获取用户负责的部门 - AdminUserDO user = userService.getUser(userId); + AdminUserDO user = userService.getUser(id); if (user == null) { return Collections.emptyList(); } @@ -49,7 +49,7 @@ public class AdminUserApiImpl implements AdminUserApi { if (dept == null) { return Collections.emptyList(); } - if (ObjUtil.notEqual(dept.getLeaderUserId(), userId)) { // 校验为负责人 + if (ObjUtil.notEqual(dept.getLeaderUserId(), id)) { // 校验为负责人 return Collections.emptyList(); } deptIds.add(dept.getId()); @@ -61,7 +61,7 @@ public class AdminUserApiImpl implements AdminUserApi { // 2. 获取部门对应的用户信息 List users = userService.getUserListByDeptIds(deptIds); - users.removeIf(item -> ObjUtil.equal(item.getId(), userId)); // 排除自己 + users.removeIf(item -> ObjUtil.equal(item.getId(), id)); // 排除自己 return BeanUtils.toBean(users, AdminUserRespDTO.class); } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/MenuController.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/MenuController.java index 46a14254cc..1a0dca3313 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/MenuController.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/MenuController.java @@ -50,7 +50,7 @@ public class MenuController { @DeleteMapping("/delete") @Operation(summary = "删除菜单") - @Parameter(name = "id", description = "角色编号", required= true, example = "1024") + @Parameter(name = "id", description = "菜单编号", required= true, example = "1024") @PreAuthorize("@ss.hasPermission('system:menu:delete')") public CommonResult deleteMenu(@RequestParam("id") Long id) { menuService.deleteMenu(id); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/vo/role/RoleRespVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/vo/role/RoleRespVO.java index 2e15ee4f7d..1316a92f02 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/vo/role/RoleRespVO.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/permission/vo/role/RoleRespVO.java @@ -25,6 +25,7 @@ public class RoleRespVO { @ExcelProperty("角色名称") private String name; + @Schema(description = "角色标志", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @NotBlank(message = "角色标志不能为空") @ExcelProperty("角色标志") private String code; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java index cda6dab4ba..8b9e7681f5 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java @@ -36,7 +36,7 @@ public interface RedisKeyConstants { /** * 拥有指定菜单的角色编号的缓存 *

- * KEY 格式:user_role_ids:{menuId} + * KEY 格式:menu_role_ids:{menuId} * VALUE 数据类型:String 角色编号集合 */ String MENU_ROLE_ID_LIST = "menu_role_ids"; @@ -52,7 +52,7 @@ public interface RedisKeyConstants { /** * OAuth2 客户端的缓存 *

- * KEY 格式:user:{id} + * KEY 格式:oauth_client:{id} * VALUE 数据类型:String 客户端信息 */ String OAUTH_CLIENT = "oauth_client"; @@ -78,7 +78,7 @@ public interface RedisKeyConstants { /** * 邮件账号的缓存 *

- * KEY 格式:sms_template:{id} + * KEY 格式:mail_account:{id} * VALUE 数据格式:String 账号信息 */ String MAIL_ACCOUNT = "mail_account"; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/config/YudaoCaptchaConfiguration.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/config/YudaoCaptchaConfiguration.java new file mode 100644 index 0000000000..23c8d78be2 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/config/YudaoCaptchaConfiguration.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.system.framework.captcha.config; + +import cn.iocoder.yudao.module.system.framework.captcha.core.RedisCaptchaServiceImpl; +import com.xingyuv.captcha.properties.AjCaptchaProperties; +import com.xingyuv.captcha.service.CaptchaCacheService; +import com.xingyuv.captcha.service.impl.CaptchaServiceFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * 验证码的配置类 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class YudaoCaptchaConfiguration { + + @Bean + public CaptchaCacheService captchaCacheService(AjCaptchaProperties config, + StringRedisTemplate stringRedisTemplate) { + CaptchaCacheService captchaCacheService = CaptchaServiceFactory.getCache(config.getCacheType().name()); + if (captchaCacheService instanceof RedisCaptchaServiceImpl) { + ((RedisCaptchaServiceImpl) captchaCacheService).setStringRedisTemplate(stringRedisTemplate); + } + return captchaCacheService; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/service/RedisCaptchaServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/RedisCaptchaServiceImpl.java similarity index 71% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/service/RedisCaptchaServiceImpl.java rename to yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/RedisCaptchaServiceImpl.java index 95c9ec4afd..d69b88ccfe 100644 --- a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/java/cn/iocoder/yudao/framework/captcha/core/service/RedisCaptchaServiceImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/RedisCaptchaServiceImpl.java @@ -1,11 +1,9 @@ -package cn.iocoder.yudao.framework.captcha.core.service; +package cn.iocoder.yudao.module.system.framework.captcha.core; import com.xingyuv.captcha.service.CaptchaCacheService; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; +import lombok.Setter; import org.springframework.data.redis.core.StringRedisTemplate; -import javax.annotation.Resource; import java.util.concurrent.TimeUnit; /** @@ -13,11 +11,9 @@ import java.util.concurrent.TimeUnit; * * @author 星语 */ -@NoArgsConstructor // 保证 aj-captcha 的 SPI 创建 -@AllArgsConstructor +@Setter public class RedisCaptchaServiceImpl implements CaptchaCacheService { - @Resource // 保证 aj-captcha 的 SPI 创建时的注入 private StringRedisTemplate stringRedisTemplate; @Override @@ -25,10 +21,6 @@ public class RedisCaptchaServiceImpl implements CaptchaCacheService { return "redis"; } - public void setStringRedisTemplate(StringRedisTemplate stringRedisTemplate) { - this.stringRedisTemplate = stringRedisTemplate; - } - @Override public void set(String key, String value, long expiresInSeconds) { stringRedisTemplate.opsForValue().set(key, value, expiresInSeconds, TimeUnit.SECONDS); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/package-info.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/package-info.java new file mode 100644 index 0000000000..a94be215e1 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/package-info.java @@ -0,0 +1,8 @@ +/** + * 验证码拓展 + * + * 基于 aj-captcha 实现滑块验证码,文档:https://ajcaptcha.beliefteam.cn/captcha-doc/ + * + * @author 星语 + */ +package cn.iocoder.yudao.module.system.framework.captcha; \ No newline at end of file diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/BooleanParseFunction.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/BooleanParseFunction.java index 7d3a0072bf..2be7ee00fb 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/BooleanParseFunction.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/BooleanParseFunction.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.module.system.framework.operatelog.core; import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.framework.dict.core.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; import cn.iocoder.yudao.module.infra.enums.DictTypeConstants; import com.mzt.logapi.service.IParseFunction; import lombok.extern.slf4j.Slf4j; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/SexParseFunction.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/SexParseFunction.java index dcb89d2483..1bd4a80772 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/SexParseFunction.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/operatelog/core/SexParseFunction.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.module.system.framework.operatelog.core; import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.framework.dict.core.util.DictFrameworkUtils; +import cn.iocoder.yudao.framework.dict.util.DictFrameworkUtils; import cn.iocoder.yudao.module.system.enums.DictTypeConstants; import com.mzt.logapi.service.IParseFunction; import lombok.extern.slf4j.Slf4j; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataService.java index a752476da9..fc2498f1b8 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataService.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataService.java @@ -99,4 +99,12 @@ public interface DictDataService { */ DictDataDO parseDictData(String dictType, String label); + /** + * 获得指定数据类型的字典数据列表 + * + * @param dictType 字典类型 + * @return 字典数据列表 + */ + List getDictDataListByDictType(String dictType); + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataServiceImpl.java index b9b87f0c49..e9f215de86 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataServiceImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/dict/DictDataServiceImpl.java @@ -169,4 +169,11 @@ public class DictDataServiceImpl implements DictDataService { return dictDataMapper.selectByDictTypeAndLabel(dictType, label); } + @Override + public List getDictDataListByDictType(String dictType) { + List list = dictDataMapper.selectList(DictDataDO::getDictType, dictType); + list.sort(Comparator.comparing(DictDataDO::getSort)); + return list; + } + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService b/yudao-module-system/yudao-module-system-biz/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService new file mode 100644 index 0000000000..946ee59649 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService @@ -0,0 +1 @@ +cn.iocoder.yudao.module.system.framework.captcha.core.RedisCaptchaServiceImpl diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg1.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg1.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg1.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg1.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg2.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg2.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg2.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg2.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg3.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg3.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg3.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg3.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg4.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg4.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg4.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg4.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg5.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg5.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg5.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg5.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg6.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg6.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg6.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg6.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg7.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg7.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg7.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg7.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg8.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg8.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg8.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg8.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg9.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg9.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg9.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/original/bg9.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/1.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/1.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/1.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/1.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/10.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/10.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/10.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/10.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/11.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/11.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/11.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/11.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/12.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/12.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/12.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/12.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/13.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/13.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/13.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/13.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/14.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/14.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/14.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/14.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/15.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/15.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/15.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/15.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/16.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/16.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/16.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/16.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/17.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/17.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/17.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/17.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/18.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/18.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/18.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/18.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/19.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/19.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/19.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/19.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/8.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/8.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/8.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/8.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/9.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/9.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/9.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/11/9.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/2.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/2.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/2.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/2.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/3.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/3.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/3.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/3.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/4.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/4.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/4.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/jigsaw/slidingBlock/4.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg1.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg1.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg1.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg1.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg10.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg10.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg10.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg10.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg2.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg2.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg2.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg2.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg3.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg3.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg3.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg3.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg4.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg4.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg4.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg4.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg5.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg5.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg5.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg5.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg6.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg6.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg6.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg6.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg7.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg7.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg7.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg7.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg8.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg8.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg8.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg8.png diff --git a/yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg9.png b/yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg9.png similarity index 100% rename from yudao-framework/yudao-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg9.png rename to yudao-module-system/yudao-module-system-biz/src/main/resources/images/pic-click/bg9.png diff --git a/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java b/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java new file mode 100644 index 0000000000..9ade60aa09 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java @@ -0,0 +1,372 @@ +package cn.iocoder.yudao.module.system.service.auth; + +import cn.hutool.core.util.ReflectUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; +import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*; +import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum; +import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum; +import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum; +import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum; +import cn.iocoder.yudao.module.system.service.logger.LoginLogService; +import cn.iocoder.yudao.module.system.service.member.MemberService; +import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService; +import cn.iocoder.yudao.module.system.service.social.SocialUserService; +import cn.iocoder.yudao.module.system.service.user.AdminUserService; +import com.xingyuv.captcha.model.common.ResponseModel; +import com.xingyuv.captcha.service.CaptchaService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import javax.annotation.Resource; +import javax.validation.ConstraintViolationException; +import javax.validation.Validation; +import javax.validation.Validator; + +import static cn.hutool.core.util.RandomUtil.randomEle; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@Import(AdminAuthServiceImpl.class) +public class AdminAuthServiceImplTest extends BaseDbUnitTest { + + @Resource + private AdminAuthServiceImpl authService; + + @MockBean + private AdminUserService userService; + @MockBean + private CaptchaService captchaService; + @MockBean + private LoginLogService loginLogService; + @MockBean + private SocialUserService socialUserService; + @MockBean + private SmsCodeApi smsCodeApi; + @MockBean + private OAuth2TokenService oauth2TokenService; + @MockBean + private MemberService memberService; + @MockBean + private Validator validator; + + @BeforeEach + public void setUp() { + ReflectUtil.setFieldValue(authService, "captchaEnable", true); + // 注入一个 Validator 对象 + ReflectUtil.setFieldValue(authService, "validator", + Validation.buildDefaultValidatorFactory().getValidator()); + } + + @Test + public void testAuthenticate_success() { + // 准备参数 + String username = randomString(); + String password = randomString(); + // mock user 数据 + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setUsername(username) + .setPassword(password).setStatus(CommonStatusEnum.ENABLE.getStatus())); + when(userService.getUserByUsername(eq(username))).thenReturn(user); + // mock password 匹配 + when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true); + + // 调用 + AdminUserDO loginUser = authService.authenticate(username, password); + // 校验 + assertPojoEquals(user, loginUser); + } + + @Test + public void testAuthenticate_userNotFound() { + // 准备参数 + String username = randomString(); + String password = randomString(); + + // 调用, 并断言异常 + assertServiceException(() -> authService.authenticate(username, password), + AUTH_LOGIN_BAD_CREDENTIALS); + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType()) + && o.getResult().equals(LoginResultEnum.BAD_CREDENTIALS.getResult()) + && o.getUserId() == null) + ); + } + + @Test + public void testAuthenticate_badCredentials() { + // 准备参数 + String username = randomString(); + String password = randomString(); + // mock user 数据 + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setUsername(username) + .setPassword(password).setStatus(CommonStatusEnum.ENABLE.getStatus())); + when(userService.getUserByUsername(eq(username))).thenReturn(user); + + // 调用, 并断言异常 + assertServiceException(() -> authService.authenticate(username, password), + AUTH_LOGIN_BAD_CREDENTIALS); + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType()) + && o.getResult().equals(LoginResultEnum.BAD_CREDENTIALS.getResult()) + && o.getUserId().equals(user.getId())) + ); + } + + @Test + public void testAuthenticate_userDisabled() { + // 准备参数 + String username = randomString(); + String password = randomString(); + // mock user 数据 + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setUsername(username) + .setPassword(password).setStatus(CommonStatusEnum.DISABLE.getStatus())); + when(userService.getUserByUsername(eq(username))).thenReturn(user); + // mock password 匹配 + when(userService.isPasswordMatch(eq(password), eq(user.getPassword()))).thenReturn(true); + + // 调用, 并断言异常 + assertServiceException(() -> authService.authenticate(username, password), + AUTH_LOGIN_USER_DISABLED); + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType()) + && o.getResult().equals(LoginResultEnum.USER_DISABLED.getResult()) + && o.getUserId().equals(user.getId())) + ); + } + + @Test + public void testLogin_success() { + // 准备参数 + AuthLoginReqVO reqVO = randomPojo(AuthLoginReqVO.class, o -> + o.setUsername("test_username").setPassword("test_password") + .setSocialType(randomEle(SocialTypeEnum.values()).getType())); + + // mock 验证码正确 + ReflectUtil.setFieldValue(authService, "captchaEnable", false); + // mock user 数据 + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setId(1L).setUsername("test_username") + .setPassword("test_password").setStatus(CommonStatusEnum.ENABLE.getStatus())); + when(userService.getUserByUsername(eq("test_username"))).thenReturn(user); + // mock password 匹配 + when(userService.isPasswordMatch(eq("test_password"), eq(user.getPassword()))).thenReturn(true); + // mock 缓存登录用户到 Redis + OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull())) + .thenReturn(accessTokenDO); + + // 调用,并校验 + AuthLoginRespVO loginRespVO = authService.login(reqVO); + assertPojoEquals(accessTokenDO, loginRespVO); + // 校验调用参数 + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType()) + && o.getResult().equals(LoginResultEnum.SUCCESS.getResult()) + && o.getUserId().equals(user.getId())) + ); + verify(socialUserService).bindSocialUser(eq(new SocialUserBindReqDTO( + user.getId(), UserTypeEnum.ADMIN.getValue(), + reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()))); + } + + @Test + public void testSendSmsCode() { + // 准备参数 + String mobile = randomString(); + Integer scene = randomEle(SmsSceneEnum.values()).getScene(); + AuthSmsSendReqVO reqVO = new AuthSmsSendReqVO(mobile, scene); + // mock 方法(用户信息) + AdminUserDO user = randomPojo(AdminUserDO.class); + when(userService.getUserByMobile(eq(mobile))).thenReturn(user); + + // 调用 + authService.sendSmsCode(reqVO); + // 断言 + verify(smsCodeApi).sendSmsCode(argThat(sendReqDTO -> { + assertEquals(mobile, sendReqDTO.getMobile()); + assertEquals(scene, sendReqDTO.getScene()); + return true; + })); + } + + @Test + public void testSmsLogin_success() { + // 准备参数 + String mobile = randomString(); + String code = randomString(); + AuthSmsLoginReqVO reqVO = new AuthSmsLoginReqVO(mobile, code); + // mock 方法(用户信息) + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setId(1L)); + when(userService.getUserByMobile(eq(mobile))).thenReturn(user); + // mock 缓存登录用户到 Redis + OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull())) + .thenReturn(accessTokenDO); + + // 调用,并断言 + AuthLoginRespVO loginRespVO = authService.smsLogin(reqVO); + assertPojoEquals(accessTokenDO, loginRespVO); + // 断言调用 + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_MOBILE.getType()) + && o.getResult().equals(LoginResultEnum.SUCCESS.getResult()) + && o.getUserId().equals(user.getId())) + ); + } + + @Test + public void testSocialLogin_success() { + // 准备参数 + AuthSocialLoginReqVO reqVO = randomPojo(AuthSocialLoginReqVO.class); + // mock 方法(绑定的用户编号) + Long userId = 1L; + when(socialUserService.getSocialUserByCode(eq(UserTypeEnum.ADMIN.getValue()), eq(reqVO.getType()), + eq(reqVO.getCode()), eq(reqVO.getState()))).thenReturn(new SocialUserRespDTO(randomString(), randomString(), randomString(), userId)); + // mock(用户) + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setId(userId)); + when(userService.getUser(eq(userId))).thenReturn(user); + // mock 缓存登录用户到 Redis + OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + when(oauth2TokenService.createAccessToken(eq(1L), eq(UserTypeEnum.ADMIN.getValue()), eq("default"), isNull())) + .thenReturn(accessTokenDO); + + // 调用,并断言 + AuthLoginRespVO loginRespVO = authService.socialLogin(reqVO); + assertPojoEquals(accessTokenDO, loginRespVO); + // 断言调用 + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_SOCIAL.getType()) + && o.getResult().equals(LoginResultEnum.SUCCESS.getResult()) + && o.getUserId().equals(user.getId())) + ); + } + + @Test + public void testValidateCaptcha_successWithEnable() { + // 准备参数 + AuthLoginReqVO reqVO = randomPojo(AuthLoginReqVO.class); + + // mock 验证码打开 + ReflectUtil.setFieldValue(authService, "captchaEnable", true); + // mock 验证通过 + when(captchaService.verification(argThat(captchaVO -> { + assertEquals(reqVO.getCaptchaVerification(), captchaVO.getCaptchaVerification()); + return true; + }))).thenReturn(ResponseModel.success()); + + // 调用,无需断言 + authService.validateCaptcha(reqVO); + } + + @Test + public void testValidateCaptcha_successWithDisable() { + // 准备参数 + AuthLoginReqVO reqVO = randomPojo(AuthLoginReqVO.class); + + // mock 验证码关闭 + ReflectUtil.setFieldValue(authService, "captchaEnable", false); + + // 调用,无需断言 + authService.validateCaptcha(reqVO); + } + + @Test + public void testValidateCaptcha_constraintViolationException() { + // 准备参数 + AuthLoginReqVO reqVO = randomPojo(AuthLoginReqVO.class).setCaptchaVerification(null); + + // mock 验证码打开 + ReflectUtil.setFieldValue(authService, "captchaEnable", true); + + // 调用,并断言异常 + assertThrows(ConstraintViolationException.class, () -> authService.validateCaptcha(reqVO), + "验证码不能为空"); + } + + + @Test + public void testCaptcha_fail() { + // 准备参数 + AuthLoginReqVO reqVO = randomPojo(AuthLoginReqVO.class); + + // mock 验证码打开 + ReflectUtil.setFieldValue(authService, "captchaEnable", true); + // mock 验证通过 + when(captchaService.verification(argThat(captchaVO -> { + assertEquals(reqVO.getCaptchaVerification(), captchaVO.getCaptchaVerification()); + return true; + }))).thenReturn(ResponseModel.errorMsg("就是不对")); + + // 调用, 并断言异常 + assertServiceException(() -> authService.validateCaptcha(reqVO), AUTH_LOGIN_CAPTCHA_CODE_ERROR, "就是不对"); + // 校验调用参数 + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGIN_USERNAME.getType()) + && o.getResult().equals(LoginResultEnum.CAPTCHA_CODE_ERROR.getResult())) + ); + } + + @Test + public void testRefreshToken() { + // 准备参数 + String refreshToken = randomString(); + // mock 方法 + OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class); + when(oauth2TokenService.refreshAccessToken(eq(refreshToken), eq("default"))) + .thenReturn(accessTokenDO); + + // 调用 + AuthLoginRespVO loginRespVO = authService.refreshToken(refreshToken); + // 断言 + assertPojoEquals(accessTokenDO, loginRespVO); + } + + @Test + public void testLogout_success() { + // 准备参数 + String token = randomString(); + // mock + OAuth2AccessTokenDO accessTokenDO = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + when(oauth2TokenService.removeAccessToken(eq(token))).thenReturn(accessTokenDO); + + // 调用 + authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType()); + // 校验调用参数 + verify(loginLogService).createLoginLog( + argThat(o -> o.getLogType().equals(LoginLogTypeEnum.LOGOUT_SELF.getType()) + && o.getResult().equals(LoginResultEnum.SUCCESS.getResult())) + ); + // 调用,并校验 + + } + + @Test + public void testLogout_fail() { + // 准备参数 + String token = randomString(); + + // 调用 + authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType()); + // 校验调用参数 + verify(loginLogService, never()).createLoginLog(any()); + } + +} diff --git a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java index 7aba89997e..a9e690853a 100644 --- a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java +++ b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java @@ -41,6 +41,12 @@ public class DefaultController { "[ERP 模块 yudao-module-erp - 已禁用][参考 https://doc.iocoder.cn/erp/build/ 开启]"); } + @RequestMapping("/admin-api/crm/**") + public CommonResult crm404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[CRM 模块 yudao-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]"); + } + @RequestMapping(value = {"/admin-api/report/**"}) public CommonResult report404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(),