diff --git a/excel-handle/sql/customer_statistics_v2_init.sql b/excel-handle/sql/customer_statistics_v2_init.sql new file mode 100644 index 0000000..e51f44f --- /dev/null +++ b/excel-handle/sql/customer_statistics_v2_init.sql @@ -0,0 +1,191 @@ +-- ========================================== +-- 流量看板V2 数据表和菜单配置 +-- ========================================== + +-- ---------------------------- +-- 1. 数据表创建 +-- ---------------------------- + +-- 客户统计数据表V2(支持标签级成本,行列转换存储) +DROP TABLE IF EXISTS `customer_statistics_data_v2`; + +CREATE TABLE `customer_statistics_data_v2` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `corp_id` VARCHAR(100) NOT NULL COMMENT '企业ID', + `cur_date` DATE NOT NULL COMMENT '统计日期', + + -- 维度信息 + `group_name` VARCHAR(50) NOT NULL COMMENT '组名(N组、O组等)', + `tag_name` VARCHAR(100) DEFAULT NULL COMMENT '标签名(NULL表示组级汇总)', + `tag_group_id` VARCHAR(100) DEFAULT NULL COMMENT '标签组ID(关联wecom_tag_group)', + `tag_id` VARCHAR(100) DEFAULT NULL COMMENT '标签ID(关联wecom_tag)', + + -- 层级关系 + `data_level` TINYINT DEFAULT 1 COMMENT '数据级别:1-组级汇总,2-标签级明细', + `parent_id` BIGINT(20) DEFAULT NULL COMMENT '父记录ID(标签级数据对应组级记录的ID)', + + -- 成本数据(支持标签级成本) + `total_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '总成本(手工录入)', + `single_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '单条成本(计算得出)', + `order_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '成单成本(计算得出)', + `cost_input_type` VARCHAR(20) DEFAULT NULL COMMENT '成本录入类型:total-总成本,single-单条成本', + + -- 数量指标 + `order_count` INT DEFAULT 0 COMMENT '成单数', + `customer_count` INT DEFAULT 0 COMMENT '进粉数', + `timely_order_count` INT DEFAULT 0 COMMENT '及时单数', + `non_timely_order_count` INT DEFAULT 0 COMMENT '非及时单数', + + -- 比率指标 + `conversion_rate` VARCHAR(10) DEFAULT '0%' COMMENT '转化率', + `timely_rate` VARCHAR(10) DEFAULT '0%' COMMENT '及时单占比', + `non_timely_rate` VARCHAR(10) DEFAULT '0%' COMMENT '非及时单占比', + + -- 客户属性指标 + `customer_attr_count` INT DEFAULT 0 COMMENT '客户属性数量', + `parent_count` INT DEFAULT 0 COMMENT '家长数量', + `student_count` INT DEFAULT 0 COMMENT '学生数量', + `teacher_count` INT DEFAULT 0 COMMENT '老师数量', + `unknown_attr_count` INT DEFAULT 0 COMMENT '未知属性数量', + `parent_rate` VARCHAR(10) DEFAULT '0%' COMMENT '家长占比', + `student_rate` VARCHAR(10) DEFAULT '0%' COMMENT '学生占比', + `teacher_rate` VARCHAR(10) DEFAULT '0%' COMMENT '老师占比', + `unknown_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未知占比', + + -- 出单率指标 + `parent_order_count` INT DEFAULT 0 COMMENT '家长出单数量', + `student_order_count` INT DEFAULT 0 COMMENT '学生出单数量', + `teacher_order_count` INT DEFAULT 0 COMMENT '老师出单数量', + `unknown_order_count` INT DEFAULT 0 COMMENT '未知出单数量', + `parent_daily_count` INT DEFAULT 0 COMMENT '家长当日数量', + `student_daily_count` INT DEFAULT 0 COMMENT '学生当日数量', + `teacher_daily_count` INT DEFAULT 0 COMMENT '老师当日数量', + `unknown_daily_count` INT DEFAULT 0 COMMENT '未知当日数量', + `parent_daily_order_count` INT DEFAULT 0 COMMENT '家长当日出单数量', + `student_daily_order_count` INT DEFAULT 0 COMMENT '学生当日出单数量', + `teacher_daily_order_count` INT DEFAULT 0 COMMENT '老师当日出单数量', + `unknown_daily_order_count` INT DEFAULT 0 COMMENT '未知当日出单数量', + `parent_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '家长出单率', + `student_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '学生出单率', + `teacher_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '老师出单率', + `unknown_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未知出单率', + + -- 意向度指标 + `intention_count` INT DEFAULT 0 COMMENT '意向度数量', + `active_quote_count` INT DEFAULT 0 COMMENT '主动报价数量', + `passive_quote_count` INT DEFAULT 0 COMMENT '被动报价数量', + `no_quote_count` INT DEFAULT 0 COMMENT '未开口报价数量', + `deleted_quote_count` INT DEFAULT 0 COMMENT '已删除报价数量', + `active_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '主动报价占比', + `passive_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '被动报价占比', + `no_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未开口报价占比', + `deleted_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '已删除报价占比', + + -- 年级指标 + `grade_count` INT DEFAULT 0 COMMENT '年级数量', + `primary_count` INT DEFAULT 0 COMMENT '小学数量', + `middle_count` INT DEFAULT 0 COMMENT '初中数量', + `high_count` INT DEFAULT 0 COMMENT '高中数量', + `primary_rate` VARCHAR(10) DEFAULT '0%' COMMENT '小学占比', + `middle_rate` VARCHAR(10) DEFAULT '0%' COMMENT '初中占比', + `high_rate` VARCHAR(10) DEFAULT '0%' COMMENT '高中占比', + + `sort_no` INT DEFAULT 0 COMMENT '排序号', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_corp_date_group_tag` (`corp_id`, `cur_date`, `group_name`, `tag_name`), + INDEX `idx_corp_date` (`corp_id`, `cur_date`), + INDEX `idx_group_name` (`group_name`), + INDEX `idx_data_level` (`data_level`), + INDEX `idx_parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户统计数据表V2(支持标签级成本,行列转换)'; + +-- 成本录入记录表(用于追溯) +DROP TABLE IF EXISTS `cost_input_record_v2`; + +CREATE TABLE `cost_input_record_v2` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT, + `corp_id` VARCHAR(100) NOT NULL COMMENT '企业ID', + `cur_date` DATE NOT NULL COMMENT '统计日期', + `group_name` VARCHAR(50) NOT NULL COMMENT '组名', + `tag_name` VARCHAR(100) DEFAULT NULL COMMENT '标签名(NULL表示组级)', + `cost_type` VARCHAR(20) NOT NULL COMMENT 'total-总成本,single-单条成本', + `input_value` DECIMAL(12,2) NOT NULL COMMENT '录入值', + `actual_total_cost` DECIMAL(12,2) NOT NULL COMMENT '实际总成本', + `input_by` VARCHAR(50) DEFAULT NULL COMMENT '录入人', + `input_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + INDEX `idx_corp_date_group` (`corp_id`, `cur_date`, `group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成本录入记录表V2'; + + +-- ---------------------------- +-- 2. 菜单配置 +-- 父菜单ID: 2000 (企业微信统计) +-- 新菜单ID从 2100 开始 +-- ---------------------------- + +-- 流量看板V2 菜单(主菜单) +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2100, '流量看板V2', 2000, 0, 'customerStatisticsV2', 'wecom/customerStatisticsV2/index', NULL, 'CustomerStatisticsV2', 1, 0, 'C', '0', '0', 'wecom:customerStatisticsV2:list', 'chart', 'admin', NOW(), '', NULL, '流量看板V2菜单(支持标签级成本)'); + +-- 流量看板V2 - 查询按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2101, '流量看板V2查询', 2100, 1, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:query', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 新增按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2102, '流量看板V2新增', 2100, 2, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:add', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 修改按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2103, '流量看板V2修改', 2100, 3, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:edit', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 删除按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2104, '流量看板V2删除', 2100, 4, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:remove', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 导出按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2105, '流量看板V2导出', 2100, 5, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:export', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 成本录入按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2106, '流量看板V2成本录入', 2100, 6, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:cost', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 重新计算按钮 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2107, '流量看板V2重新计算', 2100, 7, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:recalculate', '#', 'admin', NOW(), '', NULL, ''); + +-- 流量看板V2 - 树状数据查询 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `route_name`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +VALUES (2108, '流量看板V2树状查询', 2100, 8, '#', '', NULL, '', 1, 0, 'F', '0', '0', 'wecom:customerStatisticsV2:tree', '#', 'admin', NOW(), '', NULL, ''); + + +-- ---------------------------- +-- 3. 菜单说明 +-- ---------------------------- +-- 菜单类型说明: +-- M: 目录 +-- C: 菜单 +-- F: 按钮 +-- +-- 菜单层级结构: +-- 2000 企业微信统计 (目录) +-- ├── 2001 客户列表数据 +-- ├── 2002 客户联系统计 +-- ├── 2003 流量看板数据 (原V1版本) +-- ├── 2004 销售看板数据 +-- ├── 2100 流量看板V2 (新增,支持标签级成本) +-- │ ├── 2101 查询 +-- │ ├── 2102 新增 +-- │ ├── 2103 修改 +-- │ ├── 2104 删除 +-- │ ├── 2105 导出 +-- │ ├── 2106 成本录入 +-- │ ├── 2107 重新计算 +-- │ └── 2108 树状查询 +-- └── 3000 企业信息 diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/CustomerStatisticsDataV2.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/CustomerStatisticsDataV2.java new file mode 100644 index 0000000..2f656ea --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/CustomerStatisticsDataV2.java @@ -0,0 +1,266 @@ +package com.ruoyi.excel.wecom.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 客户统计数据V2(支持标签级成本,行列转换存储) + * 与V1的区别: + * 1. V1:行是指标,列是组 + * 2. V2:行是组/标签,列是指标 + */ +@Data +@TableName("customer_statistics_data_v2") +public class CustomerStatisticsDataV2 implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String corpId; + + private Date curDate; + + /** + * 组名(N组、O组等) + */ + private String groupName; + + /** + * 标签名(NULL表示组级汇总) + */ + private String tagName; + + /** + * 标签组ID(关联wecom_tag_group) + */ + private String tagGroupId; + + /** + * 标签ID(关联wecom_tag) + */ + private String tagId; + + /** + * 数据级别:1-组级汇总,2-标签级明细 + */ + private Integer dataLevel; + + /** + * 父记录ID(标签级数据对应组级记录的ID) + */ + private Long parentId; + + // ==================== 成本数据 ==================== + /** + * 总成本(手工录入) + */ + private BigDecimal totalCost; + + /** + * 单条成本(计算得出) + */ + private BigDecimal singleCost; + + /** + * 成单成本(计算得出) + */ + private BigDecimal orderCost; + + /** + * 成本录入类型:total-总成本,single-单条成本 + */ + private String costInputType; + + // ==================== 数量指标 ==================== + /** + * 成单数 + */ + private Integer orderCount; + + /** + * 进粉数 + */ + private Integer customerCount; + + /** + * 及时单数 + */ + private Integer timelyOrderCount; + + /** + * 非及时单数 + */ + private Integer nonTimelyOrderCount; + + // ==================== 比率指标 ==================== + /** + * 转化率 + */ + private String conversionRate; + + /** + * 及时单占比 + */ + private String timelyRate; + + /** + * 非及时单占比 + */ + private String nonTimelyRate; + + // ==================== 客户属性指标 ==================== + /** + * 客户属性数量 + */ + private Integer customerAttrCount; + + /** + * 家长数量 + */ + private Integer parentCount; + + /** + * 学生数量 + */ + private Integer studentCount; + + /** + * 老师数量 + */ + private Integer teacherCount; + + /** + * 未知属性数量 + */ + private Integer unknownAttrCount; + + /** + * 家长占比 + */ + private String parentRate; + + /** + * 学生占比 + */ + private String studentRate; + + /** + * 老师占比 + */ + private String teacherRate; + + /** + * 未知占比 + */ + private String unknownRate; + + // ==================== 出单率指标 ==================== + private Integer parentOrderCount; + private Integer studentOrderCount; + private Integer teacherOrderCount; + private Integer unknownOrderCount; + + private Integer parentDailyCount; + private Integer studentDailyCount; + private Integer teacherDailyCount; + private Integer unknownDailyCount; + + private Integer parentDailyOrderCount; + private Integer studentDailyOrderCount; + private Integer teacherDailyOrderCount; + private Integer unknownDailyOrderCount; + + private String parentOrderRate; + private String studentOrderRate; + private String teacherOrderRate; + private String unknownOrderRate; + + // ==================== 意向度指标 ==================== + private Integer intentionCount; + private Integer activeQuoteCount; + private Integer passiveQuoteCount; + private Integer noQuoteCount; + private Integer deletedQuoteCount; + + private String activeQuoteRate; + private String passiveQuoteRate; + private String noQuoteRate; + private String deletedQuoteRate; + + // ==================== 年级指标 ==================== + private Integer gradeCount; + private Integer primaryCount; + private Integer middleCount; + private Integer highCount; + + private String primaryRate; + private String middleRate; + private String highRate; + + // ==================== 其他 ==================== + private Integer sortNo; + + private Date createTime; + + private Date updateTime; + + // ==================== 非持久化字段 ==================== + /** + * 年份周数显示(如:2026年第10周) + */ + @TableField(exist = false) + private String yearWeek; + + /** + * 年月显示(如:2026年03月) + */ + @TableField(exist = false) + private String yearMonth; + + /** + * 日期范围显示(如:2026-03-02 至 2026-03-08) + */ + @TableField(exist = false) + private String dateRange; + + /** + * 子标签列表(用于树状展示) + */ + @TableField(exist = false) + private List children; + + /** + * 是否为叶子节点(标签级) + */ + @TableField(exist = false) + private Boolean leaf; + + /** + * 节点显示名称(组名或标签名) + */ + public String getDisplayName() { + if (tagName != null && !tagName.isEmpty()) { + return tagName; + } + return groupName; + } + + /** + * 获取完整路径(用于树状展示) + */ + public String getFullPath() { + if (tagName != null && !tagName.isEmpty()) { + return groupName + "/" + tagName; + } + return groupName; + } +} diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/dto/TagTreeDTO.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/dto/TagTreeDTO.java new file mode 100644 index 0000000..71589ac --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/domain/dto/TagTreeDTO.java @@ -0,0 +1,34 @@ +package com.ruoyi.excel.wecom.domain.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 标签树DTO + * 用于返回组-标签的树状结构 + */ +@Data +public class TagTreeDTO { + + /** 节点ID */ + private String id; + + /** 节点名称 */ + private String label; + + /** 节点类型:group-组,tag-标签 */ + private String type; + + /** 组名 */ + private String groupName; + + /** 标签名 */ + private String tagName; + + /** 子节点 */ + private List children; + + /** 数量统计 */ + private Integer count; +} diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllData.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllData.java index a3778cc..b2e7390 100644 --- a/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllData.java +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllData.java @@ -48,6 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger; @Autowired private CorpInfoMapper corpInfoMapper; + private List finishFlag = Arrays.asList("已成交及时单9元+", "已成交非及时单9元+"); + private List timelyFinishFlag = Arrays.asList("已成交及时单9元+"); + private List noTimelyfinishFlag = Arrays.asList("已成交非及时单9元+"); /** * 线程池配置 - 用于并行处理客户数据 * 设置为4个线程,适应4核8G服务器环境 @@ -773,15 +776,15 @@ import java.util.concurrent.atomic.AtomicInteger; if (matchesQValue(data, date) && isTimelyOrder(data)) { stats.setTeacherDailyOrderCount(stats.getTeacherDailyOrderCount() + 1); } - } - } else { - stats.setUnknownAttrCount(stats.getUnknownAttrCount() + 1); - stats.setUnkownOrderCount(stats.getUnkownOrderCount() + 1); + } else { + stats.setUnknownAttrCount(stats.getUnknownAttrCount() + 1); + stats.setUnkownOrderCount(stats.getUnkownOrderCount() + 1); - // 新增:N组未知出单率统计(当日) - stats.setUnkownDailyCount(stats.getUnkownDailyCount() + 1); - if (matchesQValue(data, date) && isTimelyOrder(data)) { - stats.setUnkownDailyOrderCount(stats.getUnkownDailyOrderCount() + 1); + // 新增:N组未知出单率统计(当日) + stats.setUnkownDailyCount(stats.getUnkownDailyCount() + 1); + if (matchesQValue(data, date) && isTimelyOrder(data)) { + stats.setUnkownDailyOrderCount(stats.getUnkownDailyOrderCount() + 1); + } } } @@ -864,25 +867,29 @@ import java.util.concurrent.atomic.AtomicInteger; // 1. 成单数 //成单数 需要从历史的所有数据中获取 成交日期 = date的数据 - Long finishCount = customerExportDataMapper.selectByFinishDate(corpId,curDate,GROUP_ATTR_MAP.get(groupName)); + Long finishCount = customerExportDataMapper.selectByFinishDate(corpId,curDate,GROUP_ATTR_MAP.get(groupName),finishFlag); setIndicatorValue(corpId,indicatorMap,curDate, "成单数(当日)", groupName, String.valueOf(finishCount),(10*sortNo++)); // 2. 进粉数 setIndicatorValue(corpId,indicatorMap,curDate, "进粉数(当日)", groupName, String.valueOf(stats.getCustomerCount()),(10*sortNo++)); - // 3. 转化率 - String conversionRate = calculateRate(stats.getOrderCount(), stats.getCustomerCount()); + // 3. 转化率 = 成单数 / 进粉数(使用finishCount而不是stats.getOrderCount()) + String conversionRate = calculateRate(finishCount.intValue(), stats.getCustomerCount()); setIndicatorValue(corpId,indicatorMap,curDate, "转化率(当日)", groupName, conversionRate,(10*sortNo++)); - // 4. 及时单占比 - String timelyRate = calculateRate(stats.getTimelyOrderCount(), stats.getCustomerCount()); + // 4. 及时单占比 = 及时单数 / 成单数(当日) + // 及时单数量需要从历史数据中获取(根据成交日期和订单状态) + Long timelyCount = customerExportDataMapper.selectTimelyOrderCount(corpId, curDate, GROUP_ATTR_MAP.get(groupName),timelyFinishFlag); + String timelyRate = calculateRate(timelyCount.intValue(), finishCount.intValue()); setIndicatorValue(corpId,indicatorMap,curDate, "及时单占比(当日)", groupName, timelyRate,(10*sortNo++)); - setIndicatorValue(corpId,indicatorMap,curDate, "及时单数量(当日)", groupName, String.valueOf(stats.getTimelyOrderCount()),(10*sortNo++),true); + setIndicatorValue(corpId,indicatorMap,curDate, "及时单数量(当日)", groupName, String.valueOf(timelyCount),(10*sortNo++),true); - // 5. 非及时单占比 - String nonTimelyRate = calculateRate(stats.getNonTimelyOrderCount(), stats.getCustomerCount()); + // 5. 非及时单占比 = 非及时单数 / 成单数(当日) + // 非及时单数量需要从历史数据中获取(根据成交日期和订单状态) + Long nonTimelyCount = customerExportDataMapper.selectNonTimelyOrderCount(corpId, curDate, GROUP_ATTR_MAP.get(groupName),noTimelyfinishFlag); + String nonTimelyRate = calculateRate(nonTimelyCount.intValue(), finishCount.intValue()); setIndicatorValue(corpId,indicatorMap,curDate, "非及时单占比(当日)", groupName, nonTimelyRate,(10*sortNo++)); - setIndicatorValue(corpId,indicatorMap,curDate, "非及时单数量(当日)", groupName, String.valueOf(stats.getNonTimelyOrderCount()),(10*sortNo++),true); + setIndicatorValue(corpId,indicatorMap,curDate, "非及时单数量(当日)", groupName, String.valueOf(nonTimelyCount),(10*sortNo++),true); // 6. 客户属性数量 setIndicatorValue(corpId,indicatorMap,curDate, "客户属性数量(当日)", groupName, String.valueOf(stats.getTotalCustomerAttr()),(10*sortNo++)); diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllDataV2.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllDataV2.java new file mode 100644 index 0000000..71d3b32 --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/helper/HandleAllDataV2.java @@ -0,0 +1,816 @@ +package com.ruoyi.excel.wecom.helper; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.excel.wecom.domain.CorpInfo; +import com.ruoyi.excel.wecom.domain.CustomerExportData; +import com.ruoyi.excel.wecom.domain.CustomerStatisticsDataV2; +import com.ruoyi.excel.wecom.mapper.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 流量看板数据处理V2 + * 支持标签级成本,行列转换存储 + * 与V1的区别: + * 1. V1:行是指标,列是组 + * 2. V2:行是组/标签,列是指标 + */ +@Slf4j +@Component +public class HandleAllDataV2 { + + @Autowired + private CustomerExportDataMapper customerExportDataMapper; + + @Autowired + private CustomerStatisticsDataV2Mapper dataV2Mapper; + + @Autowired + private WecomTagGroupMapper wecomTagGroupMapper; + + @Autowired + private WecomTagMapper wecomTagMapper; + + @Autowired + private CorpInfoMapper corpInfoMapper; + private List finishFlag = Arrays.asList("已成交及时单9元+", "已成交非及时单9元+"); + private List timelyFinishFlag = Arrays.asList("已成交及时单9元+"); + private List noTimelyfinishFlag = Arrays.asList("已成交非及时单9元+"); + /** + * 组名到字段名的映射(使用括号内的名称) + */ + private static final Map GROUP_FIELD_MAP = new LinkedHashMap<>(); + static { + GROUP_FIELD_MAP.put("投放", "tagGroup1"); + GROUP_FIELD_MAP.put("公司孵化", "tagGroup2"); + GROUP_FIELD_MAP.put("商务", "tagGroup3"); + GROUP_FIELD_MAP.put("A1组", "tagGroup10"); + GROUP_FIELD_MAP.put("B1组", "tagGroup11"); + GROUP_FIELD_MAP.put("C1组", "tagGroup12"); + GROUP_FIELD_MAP.put("D1组", "tagGroup13"); + GROUP_FIELD_MAP.put("E1组", "tagGroup14"); + GROUP_FIELD_MAP.put("自然流", "tagGroup16"); + GROUP_FIELD_MAP.put("F1组", "tagGroup17"); + GROUP_FIELD_MAP.put("G1组", "tagGroup18"); + } + + /** + * 组名到数据库字段名的映射(用于SQL查询) + */ + private static final Map GROUP_ATTR_MAP = new LinkedHashMap<>(); + static { + GROUP_ATTR_MAP.put("投放", "tag_group1"); + GROUP_ATTR_MAP.put("公司孵化", "tag_group2"); + GROUP_ATTR_MAP.put("商务", "tag_group3"); + GROUP_ATTR_MAP.put("A1组", "tag_group10"); + GROUP_ATTR_MAP.put("B1组", "tag_group11"); + GROUP_ATTR_MAP.put("C1组", "tag_group12"); + GROUP_ATTR_MAP.put("D1组", "tag_group13"); + GROUP_ATTR_MAP.put("E1组", "tag_group14"); + GROUP_ATTR_MAP.put("自然流", "tag_group16"); + GROUP_ATTR_MAP.put("F1组", "tag_group17"); + GROUP_ATTR_MAP.put("G1组", "tag_group18"); + } + + /** + * 线程池配置 + */ + private final ExecutorService executorService = Executors.newFixedThreadPool( + 4, + new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "customer-data-v2-handler-" + threadNumber.getAndIncrement()); + thread.setDaemon(false); + return thread; + } + } + ); + + /** + * 创建所有日期的流量看板数据V2 + */ + public void createAllReportDataV2() { + List corpInfos = corpInfoMapper.selectCorpInfoList(new CorpInfo()); + int batchSize = 10; + + for (CorpInfo item : corpInfos) { + try { + String corpId = item.getCorpId(); + List allDate = getAllDate(corpId); + + for (int i = 0; i < allDate.size(); i += batchSize) { + int end = Math.min(i + batchSize, allDate.size()); + List batchDates = allDate.subList(i, end); + + List> futures = new ArrayList<>(); + for (Date date : batchDates) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + createReportDataV2(corpId, date); + }, executorService); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + } catch (Exception e) { + throw new RuntimeException("多线程处理流量看板V2数据时发生错误: " + e.getMessage(), e); + } + } + } + + /** + * 创建指定日期的流量看板数据V2 + * @param corpId 企业ID + * @param date 统计日期 + */ + @Transactional + public void createReportDataV2(String corpId, Date date) { + log.info("开始创建V2流量看板数据:corpId={}, date={}", corpId, date); + + // 1. 先删除当天已存在的数据 + LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); + deleteWrapper.eq(CustomerStatisticsDataV2::getCorpId, corpId) + .eq(CustomerStatisticsDataV2::getCurDate, date); + dataV2Mapper.delete(deleteWrapper); + + // 2. 重新计算并插入当天数据 + List dataList = calculateStatisticsV2(corpId, date); + + // 3. 批量插入 + if (!dataList.isEmpty()) { + // 分批插入,每批500条 + int batchSize = 500; + for (int i = 0; i < dataList.size(); i += batchSize) { + int end = Math.min(i + batchSize, dataList.size()); + List batch = dataList.subList(i, end); + dataV2Mapper.batchInsert(batch); + } + } + + log.info("V2流量看板数据创建完成:corpId={}, date={}, 共{}条记录", + corpId, date, dataList.size()); + } + + /** + * 计算统计数据V2 + * @param corpId 企业ID + * @param date 目标日期 + * @return 统计结果列表 + */ + private List calculateStatisticsV2(String corpId, Date date) { + // 1. 初始化累加器(按组和标签) + GroupTagAccumulator accumulator = new GroupTagAccumulator(); + + // 2. 分页查询并累加统计 + int pageSize = 1000; + int pageNum = 1; + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomerExportData::getCorpId, corpId) + .eq(CustomerExportData::getAddDate, date); + + while (true) { + Page page = new Page<>(pageNum, pageSize); + Page pageData = customerExportDataMapper.selectPage(page, wrapper); + + for (CustomerExportData data : pageData.getRecords()) { + processDataRecordV2(data, date, accumulator); + } + + if (!pageData.hasNext()) { + break; + } + pageNum++; + } + + // 3. 从累加器生成最终结果 + return generateStatisticsResultsV2(corpId, date, accumulator); + } + + /** + * 处理单条数据记录,累加到组级和标签级统计 + */ + private void processDataRecordV2(CustomerExportData data, Date date, GroupTagAccumulator accumulator) { + // 遍历所有组 + for (Map.Entry entry : GROUP_FIELD_MAP.entrySet()) { + String groupName = entry.getKey(); + String fieldName = entry.getValue(); + + // 获取该组的标签值 + String tagValue = getFieldValue(data, fieldName); + + // 如果该组标签为空,跳过 + if (tagValue == null || tagValue.trim().isEmpty()) { + continue; + } + + // 获取该组的统计器 + GroupStatistics groupStats = accumulator.getGroupStats(groupName); + + // 累加组级统计 + accumulateGroupStatistics(data, date, groupStats); + + // 解析标签值(可能是逗号分隔的多个标签) + String[] tags = tagValue.split(","); + for (String tag : tags) { + tag = tag.trim(); + if (tag.isEmpty()) continue; + + // 累加到标签级统计 + TagStatistics tagStats = accumulator.getTagStats(groupName, tag); + accumulateGroupStatistics(data, date, tagStats); + } + } + } + + /** + * 使用反射获取字段值(带缓存) + */ + private String getFieldValue(CustomerExportData data, String fieldName) { + try { + java.lang.reflect.Field field = CustomerExportData.class.getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(data); + return value == null ? "" : value.toString(); + } catch (Exception e) { + return ""; + } + } + + /** + * 累加单条数据的统计指标 + */ + private void accumulateGroupStatistics(CustomerExportData data, Date date, BaseStatistics stats) { + // 1. 成单数统计 + if (matchesQValue(data, date)) { + stats.setOrderCount(stats.getOrderCount() + 1); + String orderStatus = data.getTagGroup7(); + if (orderStatus != null) { + String[] split = orderStatus.split(","); + String statusInfo = split[split.length - 1]; + if (statusInfo.contains("已成交及时单9元+")) { + stats.setTimelyOrderCount(stats.getTimelyOrderCount() + 1); + } else if (statusInfo.contains("已成交非及时单9元+")) { + stats.setNonTimelyOrderCount(stats.getNonTimelyOrderCount() + 1); + } + } + } + + // 来源筛选 + if (!matchesSource(data) || !matchesDate(data, date)) { + return; + } + + // 2. 进粉数 + stats.setCustomerCount(stats.getCustomerCount() + 1); + + // 3. 客户属性统计 + String customerAttr = data.getTagGroup6(); + if (customerAttr != null && !customerAttr.trim().isEmpty()) { + stats.setCustomerAttrCount(stats.getCustomerAttrCount() + 1); + if (customerAttr.contains("家长")) { + stats.setParentCount(stats.getParentCount() + 1); + stats.setParentOrderCount(stats.getParentOrderCount() + 1); + stats.setParentDailyCount(stats.getParentDailyCount() + 1); + if (matchesQValue(data, date) && isTimelyOrder(data)) { + stats.setParentDailyOrderCount(stats.getParentDailyOrderCount() + 1); + } + } else if (customerAttr.contains("学生")) { + stats.setStudentCount(stats.getStudentCount() + 1); + stats.setStudentOrderCount(stats.getStudentOrderCount() + 1); + stats.setStudentDailyCount(stats.getStudentDailyCount() + 1); + if (matchesQValue(data, date) && isTimelyOrder(data)) { + stats.setStudentDailyOrderCount(stats.getStudentDailyOrderCount() + 1); + } + } else if (customerAttr.contains("老师")) { + stats.setTeacherCount(stats.getTeacherCount() + 1); + stats.setTeacherOrderCount(stats.getTeacherOrderCount() + 1); + stats.setTeacherDailyCount(stats.getTeacherDailyCount() + 1); + if (matchesQValue(data, date) && isTimelyOrder(data)) { + stats.setTeacherDailyOrderCount(stats.getTeacherDailyOrderCount() + 1); + } + } else { + // 属性不为空但无法识别(未知) + stats.setUnknownAttrCount(stats.getUnknownAttrCount() + 1); + stats.setUnknownOrderCount(stats.getUnknownOrderCount() + 1); + stats.setUnknownDailyCount(stats.getUnknownDailyCount() + 1); + if (matchesQValue(data, date) && isTimelyOrder(data)) { + stats.setUnknownDailyOrderCount(stats.getUnknownDailyOrderCount() + 1); + } + } + } + // 注意:属性为空的客户不统计在 customerAttrCount 和 unknownAttrCount 中 + + // 4. 意向度统计 + String intention = data.getTagGroup15(); + if (intention != null && !intention.trim().isEmpty() && !"空白".equals(intention)) { + stats.setIntentionCount(stats.getIntentionCount() + 1); + if (intention.contains("主动报价")) { + stats.setActiveQuoteCount(stats.getActiveQuoteCount() + 1); + } else if (intention.contains("被动报价")) { + stats.setPassiveQuoteCount(stats.getPassiveQuoteCount() + 1); + } else if (intention.contains("未开口")) { + stats.setNoQuoteCount(stats.getNoQuoteCount() + 1); + } else if (intention.contains("已删除")) { + stats.setDeletedQuoteCount(stats.getDeletedQuoteCount() + 1); + } + } + + // 5. 年级统计 + String grade = data.getTagGroup5(); + if (grade != null && !grade.trim().isEmpty() && !"空白".equals(grade)) { + stats.setGradeCount(stats.getGradeCount() + 1); + if (isPrimarySchool(grade)) { + stats.setPrimaryCount(stats.getPrimaryCount() + 1); + } else if (isMiddleSchool(grade)) { + stats.setMiddleCount(stats.getMiddleCount() + 1); + } else if (isHighSchool(grade)) { + stats.setHighCount(stats.getHighCount() + 1); + } + } + } + + /** + * 从累加器生成最终统计结果V2 + */ + private List generateStatisticsResultsV2( + String corpId, Date date, GroupTagAccumulator accumulator) { + List result = new ArrayList<>(); + int sortNo = 0; + long tempId = 1; // 临时ID生成器 + + // 1. 生成组级数据 + Map groupIdMap = new HashMap<>(); + for (Map.Entry entry : accumulator.getGroupStatsMap().entrySet()) { + String groupName = entry.getKey(); + GroupStatistics stats = entry.getValue(); + + // 使用SQL查询获取成单数(与V1保持一致) + Long finishCount = customerExportDataMapper.selectByFinishDate( + corpId, date, GROUP_ATTR_MAP.get(groupName),finishFlag); + stats.setOrderCount(finishCount.intValue()); + + // 使用SQL查询获取及时单数量(根据成交日期和订单状态) + Long timelyCount = customerExportDataMapper.selectTimelyOrderCount( + corpId, date, GROUP_ATTR_MAP.get(groupName),timelyFinishFlag); + stats.setTimelyOrderCount(timelyCount.intValue()); + + // 使用SQL查询获取非及时单数量(根据成交日期和订单状态) + Long nonTimelyCount = customerExportDataMapper.selectNonTimelyOrderCount( + corpId, date, GROUP_ATTR_MAP.get(groupName),noTimelyfinishFlag); + stats.setNonTimelyOrderCount(nonTimelyCount.intValue()); + + CustomerStatisticsDataV2 groupData = createStatisticsDataV2( + corpId, date, groupName, null, stats, sortNo++); + groupData.setDataLevel(1); + groupData.setId(tempId); // 设置临时ID + result.add(groupData); + + // 临时存储组ID(用于标签级数据的parentId) + groupIdMap.put(groupName, tempId); + tempId++; + } + + // 2. 生成标签级数据 + for (Map.Entry> groupEntry : + accumulator.getTagStatsMap().entrySet()) { + String groupName = groupEntry.getKey(); + Long parentId = groupIdMap.get(groupName); + + for (Map.Entry tagEntry : groupEntry.getValue().entrySet()) { + String tagName = tagEntry.getKey(); + TagStatistics stats = tagEntry.getValue(); + + // 使用SQL查询获取该标签的成单数(根据标签值筛选) + Long finishCount = customerExportDataMapper.selectByFinishDateAndTag( + corpId, date, GROUP_ATTR_MAP.get(groupName), tagName,finishFlag); + stats.setOrderCount(finishCount.intValue()); + + // 使用SQL查询获取该标签的及时单数量 + Long timelyCount = customerExportDataMapper.selectTimelyOrderCountByTag( + corpId, date, GROUP_ATTR_MAP.get(groupName), tagName,timelyFinishFlag); + stats.setTimelyOrderCount(timelyCount.intValue()); + + // 使用SQL查询获取该标签的非及时单数量 + Long nonTimelyCount = customerExportDataMapper.selectNonTimelyOrderCountByTag( + corpId, date, GROUP_ATTR_MAP.get(groupName), tagName,noTimelyfinishFlag); + stats.setNonTimelyOrderCount(nonTimelyCount.intValue()); + + CustomerStatisticsDataV2 tagData = createStatisticsDataV2( + corpId, date, groupName, tagName, stats, sortNo++); + tagData.setDataLevel(2); + tagData.setId(tempId); // 设置临时ID + tagData.setParentId(parentId); + result.add(tagData); + tempId++; + } + } + + return result; + } + + /** + * 创建统计数据V2对象 + */ + private CustomerStatisticsDataV2 createStatisticsDataV2( + String corpId, Date date, String groupName, String tagName, + BaseStatistics stats, int sortNo) { + CustomerStatisticsDataV2 data = new CustomerStatisticsDataV2(); + data.setCorpId(corpId); + data.setCurDate(date); + data.setGroupName(groupName); + data.setTagName(tagName); + data.setSortNo(sortNo); + + // 数量指标 + data.setOrderCount(stats.getOrderCount()); + data.setCustomerCount(stats.getCustomerCount()); + data.setTimelyOrderCount(stats.getTimelyOrderCount()); + data.setNonTimelyOrderCount(stats.getNonTimelyOrderCount()); + + // 比率指标 + // 转化率 = 成单数 / 进粉数 + data.setConversionRate(calculateRate(stats.getOrderCount(), stats.getCustomerCount())); + // 及时单占比 = 及时单数 / 成单数(当日) + data.setTimelyRate(calculateRate(stats.getTimelyOrderCount(), stats.getOrderCount())); + // 非及时单占比 = 非及时单数 / 成单数(当日) + data.setNonTimelyRate(calculateRate(stats.getNonTimelyOrderCount(), stats.getOrderCount())); + + // 客户属性指标 + data.setCustomerAttrCount(stats.getCustomerAttrCount()); + data.setParentCount(stats.getParentCount()); + data.setStudentCount(stats.getStudentCount()); + data.setTeacherCount(stats.getTeacherCount()); + data.setUnknownAttrCount(stats.getUnknownAttrCount()); + data.setParentRate(calculateRate(stats.getParentCount(), stats.getCustomerAttrCount())); + data.setStudentRate(calculateRate(stats.getStudentCount(), stats.getCustomerAttrCount())); + data.setTeacherRate(calculateRate(stats.getTeacherCount(), stats.getCustomerAttrCount())); + data.setUnknownRate(calculateRate(stats.getUnknownAttrCount(), stats.getCustomerAttrCount())); + + // 出单率指标 + data.setParentOrderCount(stats.getParentOrderCount()); + data.setStudentOrderCount(stats.getStudentOrderCount()); + data.setTeacherOrderCount(stats.getTeacherOrderCount()); + data.setUnknownOrderCount(stats.getUnknownOrderCount()); + data.setParentDailyCount(stats.getParentDailyCount()); + data.setStudentDailyCount(stats.getStudentDailyCount()); + data.setTeacherDailyCount(stats.getTeacherDailyCount()); + data.setUnknownDailyCount(stats.getUnknownDailyCount()); + data.setParentDailyOrderCount(stats.getParentDailyOrderCount()); + data.setStudentDailyOrderCount(stats.getStudentDailyOrderCount()); + data.setTeacherDailyOrderCount(stats.getTeacherDailyOrderCount()); + data.setUnknownDailyOrderCount(stats.getUnknownDailyOrderCount()); + data.setParentOrderRate(calculateRate(stats.getParentDailyOrderCount(), stats.getParentDailyCount())); + data.setStudentOrderRate(calculateRate(stats.getStudentDailyOrderCount(), stats.getStudentDailyCount())); + data.setTeacherOrderRate(calculateRate(stats.getTeacherDailyOrderCount(), stats.getTeacherDailyCount())); + data.setUnknownOrderRate(calculateRate(stats.getUnknownDailyOrderCount(), stats.getUnknownDailyCount())); + + // 意向度指标 + data.setIntentionCount(stats.getIntentionCount()); + data.setActiveQuoteCount(stats.getActiveQuoteCount()); + data.setPassiveQuoteCount(stats.getPassiveQuoteCount()); + data.setNoQuoteCount(stats.getNoQuoteCount()); + data.setDeletedQuoteCount(stats.getDeletedQuoteCount()); + data.setActiveQuoteRate(calculateRate(stats.getActiveQuoteCount(), stats.getIntentionCount())); + data.setPassiveQuoteRate(calculateRate(stats.getPassiveQuoteCount(), stats.getIntentionCount())); + data.setNoQuoteRate(calculateRate(stats.getNoQuoteCount(), stats.getIntentionCount())); + data.setDeletedQuoteRate(calculateRate(stats.getDeletedQuoteCount(), stats.getIntentionCount())); + + // 年级指标 + data.setGradeCount(stats.getGradeCount()); + data.setPrimaryCount(stats.getPrimaryCount()); + data.setMiddleCount(stats.getMiddleCount()); + data.setHighCount(stats.getHighCount()); + data.setPrimaryRate(calculateRate(stats.getPrimaryCount(), stats.getGradeCount())); + data.setMiddleRate(calculateRate(stats.getMiddleCount(), stats.getGradeCount())); + data.setHighRate(calculateRate(stats.getHighCount(), stats.getGradeCount())); + + return data; + } + + /** + * 计算百分比 + */ + private String calculateRate(int count, int total) { + if (total == 0) { + return "0%"; + } + BigDecimal rate = new BigDecimal(count) + .multiply(new BigDecimal(100)) + .divide(new BigDecimal(total), 2, RoundingMode.HALF_UP); + return rate.toString() + "%"; + } + + /** + * 检查数据是否匹配日期 + */ + private boolean matchesDate(CustomerExportData data, Date date) { + if (data.getAddDate() != null && date != null) { + return date.compareTo(data.getAddDate()) == 0; + } + return false; + } + + /** + * 检查数据来源是否匹配 + */ + private boolean matchesSource(CustomerExportData data) { + if (data.getSource() != null && + data.getSource().contains("管理员") && + data.getSource().contains("分配")) { + return false; + } + return true; + } + + /** + * 检查成交日期是否匹配 + */ + private boolean matchesQValue(CustomerExportData data, Date curDate) { + String orderDate = data.getTagGroup4(); + if (orderDate == null || orderDate.trim().isEmpty()) { + return false; + } + + try { + String[] dates = orderDate.trim().split(","); + String lastDateStr = dates[dates.length - 1].trim(); + + Calendar orderCal = Calendar.getInstance(); + orderCal.setTime(curDate); + orderCal.set(Calendar.HOUR_OF_DAY, 0); + orderCal.set(Calendar.MINUTE, 0); + orderCal.set(Calendar.SECOND, 0); + orderCal.set(Calendar.MILLISECOND, 0); + + boolean parsed = false; + + // 格式1: 2026/2/24周二 + java.util.regex.Pattern fullDatePattern = java.util.regex.Pattern.compile("(\\d{4})[/\\-](\\d{1,2})[/\\-](\\d{1,2})"); + java.util.regex.Matcher fullDateMatcher = fullDatePattern.matcher(lastDateStr); + if (fullDateMatcher.find()) { + int year = Integer.parseInt(fullDateMatcher.group(1)); + int month = Integer.parseInt(fullDateMatcher.group(2)); + int day = Integer.parseInt(fullDateMatcher.group(3)); + orderCal.set(Calendar.YEAR, year); + orderCal.set(Calendar.MONTH, month - 1); + orderCal.set(Calendar.DAY_OF_MONTH, day); + parsed = true; + } + + // 格式2: 1.7-小雅初中公众号K 或 2.3 + if (!parsed) { + java.util.regex.Pattern monthDayPattern = java.util.regex.Pattern.compile("^(\\d{1,2})[.\\-/](\\d{1,2})"); + java.util.regex.Matcher monthDayMatcher = monthDayPattern.matcher(lastDateStr); + if (monthDayMatcher.find()) { + int month = Integer.parseInt(monthDayMatcher.group(1)); + int day = Integer.parseInt(monthDayMatcher.group(2)); + orderCal.set(Calendar.MONTH, month - 1); + orderCal.set(Calendar.DAY_OF_MONTH, day); + Calendar tempCal = Calendar.getInstance(); + tempCal.setTime(curDate); + tempCal.set(Calendar.MONTH, month - 1); + tempCal.set(Calendar.DAY_OF_MONTH, day); + tempCal.set(Calendar.HOUR_OF_DAY, 0); + tempCal.set(Calendar.MINUTE, 0); + tempCal.set(Calendar.SECOND, 0); + tempCal.set(Calendar.MILLISECOND, 0); + if (tempCal.getTime().before(curDate)) { + orderCal.add(Calendar.YEAR, 1); + } + parsed = true; + } + } + + if (!parsed) { + return false; + } + + Calendar targetCal = Calendar.getInstance(); + targetCal.setTime(curDate); + targetCal.set(Calendar.HOUR_OF_DAY, 0); + targetCal.set(Calendar.MINUTE, 0); + targetCal.set(Calendar.SECOND, 0); + targetCal.set(Calendar.MILLISECOND, 0); + + return orderCal.getTimeInMillis() == targetCal.getTimeInMillis(); + + } catch (Exception e) { + return false; + } + } + + /** + * 判断是否为及时单 + */ + private boolean isTimelyOrder(CustomerExportData data) { + String orderStatus = data.getTagGroup7(); + if (orderStatus == null || orderStatus.trim().isEmpty()) { + return false; + } + String[] split = orderStatus.split(","); + String statusInfo = split[split.length - 1]; + return statusInfo.contains("已成交及时单9元+"); + } + + /** + * 判断是否为小学 + */ + private boolean isPrimarySchool(String grade) { + return grade.contains("小学") || grade.contains("一年级") || grade.contains("二年级") || + grade.contains("三年级") || grade.contains("四年级") || grade.contains("五年级") || + grade.contains("六年级"); + } + + /** + * 判断是否为初中 + */ + private boolean isMiddleSchool(String grade) { + return grade.contains("初中") || grade.contains("初一") || + grade.contains("初二") || grade.contains("初三"); + } + + /** + * 判断是否为高中 + */ + private boolean isHighSchool(String grade) { + return grade.contains("高中") || grade.contains("高一") || + grade.contains("高二") || grade.contains("高三"); + } + + /** + * 获取所有日期 + */ + private List getAllDate(String corpId) { + return customerExportDataMapper.getDistinctDate(corpId); + } + + /** + * 关闭线程池 + */ + public void shutdown() { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + // ==================== 内部类定义 ==================== + + /** + * 基础统计类 + */ + public static class BaseStatistics { + // 数量指标 + private int orderCount; + private int customerCount; + private int timelyOrderCount; + private int nonTimelyOrderCount; + + // 客户属性指标 + private int customerAttrCount; + private int parentCount; + private int studentCount; + private int teacherCount; + private int unknownAttrCount; + + // 出单率指标 + private int parentOrderCount; + private int studentOrderCount; + private int teacherOrderCount; + private int unknownOrderCount; + private int parentDailyCount; + private int studentDailyCount; + private int teacherDailyCount; + private int unknownDailyCount; + private int parentDailyOrderCount; + private int studentDailyOrderCount; + private int teacherDailyOrderCount; + private int unknownDailyOrderCount; + + // 意向度指标 + private int intentionCount; + private int activeQuoteCount; + private int passiveQuoteCount; + private int noQuoteCount; + private int deletedQuoteCount; + + // 年级指标 + private int gradeCount; + private int primaryCount; + private int middleCount; + private int highCount; + + // Getters and Setters + public int getOrderCount() { return orderCount; } + public void setOrderCount(int orderCount) { this.orderCount = orderCount; } + public int getCustomerCount() { return customerCount; } + public void setCustomerCount(int customerCount) { this.customerCount = customerCount; } + public int getTimelyOrderCount() { return timelyOrderCount; } + public void setTimelyOrderCount(int timelyOrderCount) { this.timelyOrderCount = timelyOrderCount; } + public int getNonTimelyOrderCount() { return nonTimelyOrderCount; } + public void setNonTimelyOrderCount(int nonTimelyOrderCount) { this.nonTimelyOrderCount = nonTimelyOrderCount; } + public int getCustomerAttrCount() { return customerAttrCount; } + public void setCustomerAttrCount(int customerAttrCount) { this.customerAttrCount = customerAttrCount; } + public int getParentCount() { return parentCount; } + public void setParentCount(int parentCount) { this.parentCount = parentCount; } + public int getStudentCount() { return studentCount; } + public void setStudentCount(int studentCount) { this.studentCount = studentCount; } + public int getTeacherCount() { return teacherCount; } + public void setTeacherCount(int teacherCount) { this.teacherCount = teacherCount; } + public int getUnknownAttrCount() { return unknownAttrCount; } + public void setUnknownAttrCount(int unknownAttrCount) { this.unknownAttrCount = unknownAttrCount; } + public int getParentOrderCount() { return parentOrderCount; } + public void setParentOrderCount(int parentOrderCount) { this.parentOrderCount = parentOrderCount; } + public int getStudentOrderCount() { return studentOrderCount; } + public void setStudentOrderCount(int studentOrderCount) { this.studentOrderCount = studentOrderCount; } + public int getTeacherOrderCount() { return teacherOrderCount; } + public void setTeacherOrderCount(int teacherOrderCount) { this.teacherOrderCount = teacherOrderCount; } + public int getUnknownOrderCount() { return unknownOrderCount; } + public void setUnknownOrderCount(int unknownOrderCount) { this.unknownOrderCount = unknownOrderCount; } + public int getParentDailyCount() { return parentDailyCount; } + public void setParentDailyCount(int parentDailyCount) { this.parentDailyCount = parentDailyCount; } + public int getStudentDailyCount() { return studentDailyCount; } + public void setStudentDailyCount(int studentDailyCount) { this.studentDailyCount = studentDailyCount; } + public int getTeacherDailyCount() { return teacherDailyCount; } + public void setTeacherDailyCount(int teacherDailyCount) { this.teacherDailyCount = teacherDailyCount; } + public int getUnknownDailyCount() { return unknownDailyCount; } + public void setUnknownDailyCount(int unknownDailyCount) { this.unknownDailyCount = unknownDailyCount; } + public int getParentDailyOrderCount() { return parentDailyOrderCount; } + public void setParentDailyOrderCount(int parentDailyOrderCount) { this.parentDailyOrderCount = parentDailyOrderCount; } + public int getStudentDailyOrderCount() { return studentDailyOrderCount; } + public void setStudentDailyOrderCount(int studentDailyOrderCount) { this.studentDailyOrderCount = studentDailyOrderCount; } + public int getTeacherDailyOrderCount() { return teacherDailyOrderCount; } + public void setTeacherDailyOrderCount(int teacherDailyOrderCount) { this.teacherDailyOrderCount = teacherDailyOrderCount; } + public int getUnknownDailyOrderCount() { return unknownDailyOrderCount; } + public void setUnknownDailyOrderCount(int unknownDailyOrderCount) { this.unknownDailyOrderCount = unknownDailyOrderCount; } + public int getIntentionCount() { return intentionCount; } + public void setIntentionCount(int intentionCount) { this.intentionCount = intentionCount; } + public int getActiveQuoteCount() { return activeQuoteCount; } + public void setActiveQuoteCount(int activeQuoteCount) { this.activeQuoteCount = activeQuoteCount; } + public int getPassiveQuoteCount() { return passiveQuoteCount; } + public void setPassiveQuoteCount(int passiveQuoteCount) { this.passiveQuoteCount = passiveQuoteCount; } + public int getNoQuoteCount() { return noQuoteCount; } + public void setNoQuoteCount(int noQuoteCount) { this.noQuoteCount = noQuoteCount; } + public int getDeletedQuoteCount() { return deletedQuoteCount; } + public void setDeletedQuoteCount(int deletedQuoteCount) { this.deletedQuoteCount = deletedQuoteCount; } + public int getGradeCount() { return gradeCount; } + public void setGradeCount(int gradeCount) { this.gradeCount = gradeCount; } + public int getPrimaryCount() { return primaryCount; } + public void setPrimaryCount(int primaryCount) { this.primaryCount = primaryCount; } + public int getMiddleCount() { return middleCount; } + public void setMiddleCount(int middleCount) { this.middleCount = middleCount; } + public int getHighCount() { return highCount; } + public void setHighCount(int highCount) { this.highCount = highCount; } + } + + /** + * 组级统计 + */ + public static class GroupStatistics extends BaseStatistics {} + + /** + * 标签级统计 + */ + public static class TagStatistics extends BaseStatistics {} + + /** + * 组和标签累加器 + */ + public static class GroupTagAccumulator { + private final Map groupStatsMap = new LinkedHashMap<>(); + private final Map> tagStatsMap = new LinkedHashMap<>(); + + public GroupStatistics getGroupStats(String groupName) { + return groupStatsMap.computeIfAbsent(groupName, k -> new GroupStatistics()); + } + + public TagStatistics getTagStats(String groupName, String tagName) { + Map tagMap = tagStatsMap.computeIfAbsent(groupName, k -> new LinkedHashMap<>()); + return tagMap.computeIfAbsent(tagName, k -> new TagStatistics()); + } + + public Map getGroupStatsMap() { + return groupStatsMap; + } + + public Map> getTagStatsMap() { + return tagStatsMap; + } + } +} diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerExportDataMapper.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerExportDataMapper.java index f33cb29..9023258 100644 --- a/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerExportDataMapper.java +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerExportDataMapper.java @@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Param; import java.util.Date; import java.util.List; +import java.util.Map; /** * 客户导出数据Mapper @@ -59,7 +60,69 @@ public interface CustomerExportDataMapper extends BaseMapper ); Long selectByFinishDate( - @Param("corpId") String corpId,@Param("date") Date date,@Param("attr") String attr); + @Param("corpId") String corpId,@Param("date") Date date, + @Param("attr") String attr,@Param("successFlags") List successFlags); + + /** + * 查询及时单数量(根据成交日期和订单状态) + * @param corpId 企业ID + * @param date 成交日期 + * @param attr 组字段名 + * @return 及时单数量 + */ + Long selectTimelyOrderCount( + @Param("corpId") String corpId, @Param("date") Date date, + @Param("attr") String attr,@Param("successFlags") List successFlags); + + /** + * 查询非及时单数量(根据成交日期和订单状态) + * @param corpId 企业ID + * @param date 成交日期 + * @param attr 组字段名 + * @return 非及时单数量 + */ + Long selectNonTimelyOrderCount( + @Param("corpId") String corpId, @Param("date") Date date, + @Param("attr") String attr,@Param("successFlags") List successFlags); + + /** + * 查询成单数(根据成交日期、组字段和标签值) + * @param corpId 企业ID + * @param date 成交日期 + * @param attr 组字段名 + * @param tagValue 标签值 + * @return 成单数 + */ + Long selectByFinishDateAndTag( + @Param("corpId") String corpId, @Param("date") Date date, + @Param("attr") String attr, @Param("tagValue") String tagValue, + @Param("successFlags") List successFlags); + + /** + * 查询及时单数量(根据成交日期、订单状态和标签值) + * @param corpId 企业ID + * @param date 成交日期 + * @param attr 组字段名 + * @param tagValue 标签值 + * @return 及时单数量 + */ + Long selectTimelyOrderCountByTag( + @Param("corpId") String corpId, @Param("date") Date date, + @Param("attr") String attr, @Param("tagValue") String tagValue, + @Param("successFlags") List successFlags); + + /** + * 查询非及时单数量(根据成交日期、订单状态和标签值) + * @param corpId 企业ID + * @param date 成交日期 + * @param attr 组字段名 + * @param tagValue 标签值 + * @return 非及时单数量 + */ + Long selectNonTimelyOrderCountByTag( + @Param("corpId") String corpId, @Param("date") Date date, + @Param("attr") String attr, @Param("tagValue") String tagValue, + @Param("successFlags") List successFlags); /** * 统计客户导出数据VO数量(用于异步导出) @@ -94,5 +157,88 @@ public interface CustomerExportDataMapper extends BaseMapper @Param("offset") int offset, @Param("limit") int limit ); + + /** + * 按日期范围查询成单数(根据finish_date) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param attr 组字段名(如tag_group11) + * @return 成单数 + */ + Long selectOrderCountByFinishDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("attr") String attr, + @Param("successFlags") List successFlags); + + /** + * 按日期范围查询及时单数量(根据finish_date) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param attr 组字段名 + * @return 及时单数量 + */ + Long selectTimelyOrderCountByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("attr") String attr, + @Param("successFlags") List successFlags); + + /** + * 按日期范围查询非及时单数量(根据finish_date) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param attr 组字段名 + * @return 非及时单数量 + */ + Long selectNonTimelyOrderCountByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("attr") String attr, + @Param("successFlags") List successFlags); + + /** + * 批量查询所有组的成单数(根据finish_date) + * 一次查询返回所有组的成单数,避免N+1查询问题 + * @param corpId 企业ID + * @param startDate 开始日期(可为null,表示不限制) + * @param endDate 结束日期(可为null,表示不限制) + * @return Map格式:key=组字段名,value=成单数 + */ + Map selectOrderCountBatchByFinishDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("successFlags") List successFlags); + + /** + * 批量查询所有组的及时单数量(根据finish_date) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return Map格式:key=组字段名,value=及时单数量 + */ + Map selectTimelyOrderCountBatchByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate,@Param("successFlags") List successFlags); + + /** + * 批量查询所有组的非及时单数量(根据finish_date) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return Map格式:key=组字段名,value=非及时单数量 + */ + Map selectNonTimelyOrderCountBatchByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate,@Param("successFlags") List successFlags); } diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataMapper.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataMapper.java index a1b58b6..5cd619e 100644 --- a/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataMapper.java +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataMapper.java @@ -53,6 +53,21 @@ public interface CustomerStatisticsDataMapper extends BaseMapper selectDailyDataByWeekRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("indicatorName") String indicatorName + ); + List selectDailyDataByMonth( @Param("corpId") String corpId, @Param("yearMonth") String yearMonth, diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataV2Mapper.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataV2Mapper.java new file mode 100644 index 0000000..b1fa0e9 --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/mapper/CustomerStatisticsDataV2Mapper.java @@ -0,0 +1,134 @@ +package com.ruoyi.excel.wecom.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.excel.wecom.domain.CustomerStatisticsDataV2; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; +import java.util.List; + +/** + * 客户统计数据V2 Mapper接口 + * 支持标签级成本,行列转换存储 + */ +@Mapper +public interface CustomerStatisticsDataV2Mapper extends BaseMapper { + + /** + * 根据企业ID、日期、组名、标签名查询数据 + */ + CustomerStatisticsDataV2 selectByCorpDateGroupTag( + @Param("corpId") String corpId, + @Param("curDate") Date curDate, + @Param("groupName") String groupName, + @Param("tagName") String tagName + ); + + /** + * 查询组级数据列表 + */ + List selectGroupLevelList( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate + ); + + /** + * 查询标签级数据列表 + */ + List selectTagLevelList( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("groupName") String groupName + ); + + /** + * 根据企业ID、日期、组名查询组级数据 + */ + CustomerStatisticsDataV2 selectGroupLevelByCorpDateGroup( + @Param("corpId") String corpId, + @Param("curDate") Date curDate, + @Param("groupName") String groupName + ); + + /** + * 根据企业ID、日期、组名查询标签级数据列表 + */ + List selectTagLevelByCorpDateGroup( + @Param("corpId") String corpId, + @Param("curDate") Date curDate, + @Param("groupName") String groupName + ); + + /** + * 批量插入数据 + */ + int batchInsert(@Param("list") List list); + + /** + * 删除指定日期范围的数据 + */ + int deleteByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate + ); + + /** + * 查询树状结构数据(组+标签) + */ + List selectTreeData( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate + ); + + /** + * 根据筛选条件查询数据列表(支持按组、标签筛选) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 数据列表 + */ + List selectListByFilter( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("groupName") String groupName, + @Param("tagName") String tagName + ); + + /** + * 按日期范围聚合查询(支持按组、标签筛选) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 聚合后的数据列表 + */ + List selectAggregatedByDateRange( + @Param("corpId") String corpId, + @Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("groupName") String groupName, + @Param("tagName") String tagName + ); + + /** + * 查询所有数据并聚合(支持按组、标签筛选) + * @param corpId 企业ID + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 聚合后的数据列表 + */ + List selectAllAggregated( + @Param("corpId") String corpId, + @Param("groupName") String groupName, + @Param("tagName") String tagName + ); +} diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/ICustomerStatisticsDataV2Service.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/ICustomerStatisticsDataV2Service.java new file mode 100644 index 0000000..aa45f3a --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/ICustomerStatisticsDataV2Service.java @@ -0,0 +1,147 @@ +package com.ruoyi.excel.wecom.service; + +import com.ruoyi.excel.wecom.domain.CustomerStatisticsDataV2; +import com.ruoyi.excel.wecom.domain.dto.TagTreeDTO; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 客户统计数据V2 Service接口 + * 支持标签级成本,行列转换存储 + */ +public interface ICustomerStatisticsDataV2Service { + + /** + * 查询客户统计数据V2列表 + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 客户统计数据V2列表 + */ + List selectCustomerStatisticsDataV2List( + String corpId, Date startDate, Date endDate); + + /** + * 查询树状结构数据(组+标签) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 树状结构数据列表 + */ + List selectTreeData( + String corpId, Date startDate, Date endDate); + + /** + * 查询标签树(只返回组-标签结构,不返回统计数据) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 标签树列表 + */ + List selectTagTree(String corpId, Date startDate, Date endDate); + + /** + * 查询客户统计数据V2列表(支持按组、标签筛选) + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 客户统计数据V2列表 + */ + List selectCustomerStatisticsDataV2List( + String corpId, Date startDate, Date endDate, String groupName, String tagName); + + /** + * 根据ID查询客户统计数据V2 + * @param id 主键ID + * @return 客户统计数据V2 + */ + CustomerStatisticsDataV2 selectCustomerStatisticsDataV2ById(Long id); + + /** + * 新增客户统计数据V2 + * @param data 客户统计数据V2 + * @return 结果 + */ + int insertCustomerStatisticsDataV2(CustomerStatisticsDataV2 data); + + /** + * 修改客户统计数据V2 + * @param data 客户统计数据V2 + * @return 结果 + */ + int updateCustomerStatisticsDataV2(CustomerStatisticsDataV2 data); + + /** + * 批量删除客户统计数据V2 + * @param ids 需要删除的数据ID + * @return 结果 + */ + int deleteCustomerStatisticsDataV2ByIds(Long[] ids); + + /** + * 录入成本(支持组级和标签级) + * @param corpId 企业ID + * @param date 日期 + * @param groupName 组名 + * @param tagName 标签名(null表示组级) + * @param costValue 成本值 + * @param inputType 录入类型:total-总成本,single-单条成本 + * @return 结果 + */ + int inputCost(String corpId, Date date, String groupName, String tagName, + BigDecimal costValue, String inputType); + + /** + * 重新计算指定日期的统计数据 + * @param corpId 企业ID + * @param date 日期 + * @return 结果 + */ + int recalculateStatistics(String corpId, Date date); + + /** + * 重新计算指定日期范围的统计数据 + * @param corpId 企业ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 结果 + */ + int recalculateStatisticsRange(String corpId, Date startDate, Date endDate); + + /** + * 按周聚合查询 + * @param corpId 企业ID + * @param year 年份 + * @param week 周数 + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 聚合后的数据列表 + */ + List selectByWeekAggregation( + String corpId, Integer year, Integer week, String groupName, String tagName); + + /** + * 按月聚合查询 + * @param corpId 企业ID + * @param yearMonth 年月(格式:yyyy-MM) + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 聚合后的数据列表 + */ + List selectByMonthAggregation( + String corpId, String yearMonth, String groupName, String tagName); + + /** + * 查询所有数据的聚合 + * @param corpId 企业ID + * @param groupName 组名(可选) + * @param tagName 标签名(可选) + * @return 聚合后的数据列表 + */ + List selectAllAggregation( + String corpId, String groupName, String tagName); +} diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataServiceImpl.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataServiceImpl.java index 6b10b36..1ac5894 100644 --- a/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataServiceImpl.java +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataServiceImpl.java @@ -19,6 +19,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.temporal.WeekFields; import java.util.ArrayList; +import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.Map; @@ -245,8 +246,131 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat @Override public List selectByWeekAggregation(String corpId, Integer year, Integer week, String indicatorName) { - List dailyDataList = customerStatisticsDataMapper.selectDailyDataByWeek(corpId, year, week, indicatorName); - return aggregateDataList(dailyDataList, year, week, null); + log.info("========== V1周聚合查询开始 =========="); + log.info("参数: corpId={}, year={}, week={}, indicatorName={}", corpId, year, week, indicatorName); + + // 计算周范围(修复跨年问题) + Date[] weekRange = calculateWeekRangeFixed(year, week); + if (weekRange == null) { + // 如果周范围无效(跨年),返回空列表 + log.info("周范围无效(跨年),返回空列表"); + return new ArrayList<>(); + } + Date startDate = weekRange[0]; + Date endDate = weekRange[1]; + log.info("周日期范围: {} 至 {}", startDate, endDate); + + // 使用日期范围查询(修复跨年周问题) + List dailyDataList = customerStatisticsDataMapper.selectDailyDataByWeekRange(corpId, startDate, endDate, indicatorName); + log.info("查询到原始数据: {}条", dailyDataList != null ? dailyDataList.size() : 0); + + if (dailyDataList != null && !dailyDataList.isEmpty()) { + // 按指标分组统计 + Map indicatorCount = dailyDataList.stream() + .collect(Collectors.groupingBy(CustomerStatisticsData::getIndicatorName, Collectors.counting())); + log.info("指标分布: {}", indicatorCount); + + // 查看第一条数据的详细信息 + CustomerStatisticsData first = dailyDataList.get(0); + log.info("第一条数据: date={}, indicator={}, ntfGroup={}, ofhGroup={}, wa1Group={}, xb1Group={}", + first.getCurDate(), first.getIndicatorName(), + first.getNtfGroup(), first.getOfhGroup(), first.getWa1Group(), first.getXb1Group()); + } + + List result = aggregateDataList(dailyDataList, year, week, null); + log.info("聚合后结果: {}条", result != null ? result.size() : 0); + log.info("========== V1周聚合查询结束 =========="); + return result; + } + + /** + * 计算周范围(修复跨年问题) + * 规则: + * 1. 周的第一天是周一(不是周日) + * 2. 第一周:从1月1日开始,到1月1日所在周的周日结束 + * - 如果1月1日是周日,第一周只有1天(1月1日当天) + * 3. 最后一周:从最后一周的周一(12月31日往前推)开始,到12月31日结束 + * - 如果12月31日是周一,最后一周只有1天(12月31日当天) + * 4. 其他周:按正常的周一到周日计算 + */ + private Date[] calculateWeekRangeFixed(int year, int week) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, Calendar.JANUARY); + calendar.set(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + // 获取1月1日是星期几(1=周日,2=周一,...,7=周六) + int jan1DayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); + // 转换为:0=周日,1=周一,...,6=周六 + int jan1Weekday = (jan1DayOfWeek == Calendar.SUNDAY) ? 0 : (jan1DayOfWeek - 1); + + // 计算12月31日 + Calendar dec31Cal = Calendar.getInstance(); + dec31Cal.set(Calendar.YEAR, year); + dec31Cal.set(Calendar.MONTH, Calendar.DECEMBER); + dec31Cal.set(Calendar.DAY_OF_MONTH, 31); + dec31Cal.set(Calendar.HOUR_OF_DAY, 0); + dec31Cal.set(Calendar.MINUTE, 0); + dec31Cal.set(Calendar.SECOND, 0); + dec31Cal.set(Calendar.MILLISECOND, 0); + + // 第一周:从1月1日开始 + if (week == 1) { + Date startDate = calendar.getTime(); + // 计算第一周的结束日期(周日) + // 如果1月1日是周日(0),则第一周只有1天(当天) + // 否则,计算到本周日 + int daysToSunday; + if (jan1Weekday == 0) { + daysToSunday = 0; // 1月1日是周日,第一周只有1天 + } else { + daysToSunday = 7 - jan1Weekday; // 到本周日 + } + calendar.add(Calendar.DAY_OF_MONTH, daysToSunday); + Date endDate = calendar.getTime(); + return new Date[]{startDate, endDate}; + } + + // 计算第一周结束日期(1月1日所在周的周日) + Calendar firstWeekEndCal = (Calendar) calendar.clone(); + int firstWeekDays; + if (jan1Weekday == 0) { + firstWeekDays = 0; // 1月1日是周日,第一周只有1天 + } else { + firstWeekDays = 7 - jan1Weekday; // 到本周日 + } + firstWeekEndCal.add(Calendar.DAY_OF_MONTH, firstWeekDays); + + // 计算第二周开始日期(第一周结束后的周一) + Calendar secondWeekStartCal = (Calendar) firstWeekEndCal.clone(); + secondWeekStartCal.add(Calendar.DAY_OF_MONTH, 1); + + // 计算目标周的开始和结束 + // 从第二周开始,每周都是周一到周日 + Calendar targetWeekStartCal = (Calendar) secondWeekStartCal.clone(); + targetWeekStartCal.add(Calendar.WEEK_OF_YEAR, week - 2); + + // 如果开始日期已经跨年了(大于12月31日),则返回null + if (targetWeekStartCal.after(dec31Cal)) { + return null; + } + + Date startDate = targetWeekStartCal.getTime(); + + Calendar targetWeekEndCal = (Calendar) targetWeekStartCal.clone(); + targetWeekEndCal.add(Calendar.DAY_OF_MONTH, 6); + Date endDate = targetWeekEndCal.getTime(); + + // 如果目标周的结束超过了12月31日,则调整到12月31日 + if (targetWeekEndCal.after(dec31Cal)) { + endDate = dec31Cal.getTime(); + } + + return new Date[]{startDate, endDate}; } @Override @@ -422,11 +546,19 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat return result; } + // 针对成单数和进粉数添加详细日志 + boolean isTargetIndicator = indicatorName.equals("成单数(当日)") || indicatorName.equals("进粉数(当日)"); + if (isTargetIndicator) { + log.info("========== 开始聚合指标: {} ==========", indicatorName); + } + for (String field : groupFields) { BigDecimal sum = BigDecimal.ZERO; boolean hasValue = false; + for (CustomerStatisticsData data : dailyList) { String value = getFieldValue(data, field); + if (value != null && !value.trim().isEmpty()) { String cleanValue = value.replace("%", "").trim(); if (indicatorName.equals("总成本(当日)") && !cleanValue.equals("需手工填写")) { @@ -438,20 +570,31 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } if (cleanValue.matches("-?\\d+(\\.\\d+)?")) { try { - sum = sum.add(new BigDecimal(cleanValue)); + BigDecimal decimalValue = new BigDecimal(cleanValue); + sum = sum.add(decimalValue); hasValue = true; + } catch (NumberFormatException e) { + if (isTargetIndicator) { + log.info(" 解析失败: {}", cleanValue); + } } } } } if (hasValue) { setFieldValue(result, field, sum.setScale(2, RoundingMode.HALF_UP).toString()); - log.info("指标 {} 字段 {} 最终聚合值: {}", indicatorName, field, sum.setScale(2, RoundingMode.HALF_UP)); + if (isTargetIndicator) { + log.info("指标 {} 字段 {} 最终聚合值: {}", indicatorName, field, sum.setScale(2, RoundingMode.HALF_UP)); + } } else if (indicatorName.equals("总成本(当日)")) { setFieldValue(result, field, "0"); } } + + if (isTargetIndicator) { + log.info("========== 结束聚合指标: {} ==========", indicatorName); + } return result; } @@ -469,6 +612,8 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat CustomerStatisticsDataVO keHuShuXingData = indicatorMap.get("客户属性数量(当日)"); CustomerStatisticsDataVO xueShengZhanBiData = indicatorMap.get("学生占比(当日)"); CustomerStatisticsDataVO weiZhiZhanBiData = indicatorMap.get("未知占比(当日)"); + CustomerStatisticsDataVO laoshiZhanBiData = indicatorMap.get("老师占比(当日)"); + CustomerStatisticsDataVO zhuDongBaoJiaZhanBiData = indicatorMap.get("主动报价占比(当日)"); CustomerStatisticsDataVO beiDongBaoJiaZhanBiData = indicatorMap.get("被动报价占比(当日)"); CustomerStatisticsDataVO weiKaiKouBaoJiaZhanBiData = indicatorMap.get("未开口报价占比(当日)"); @@ -486,6 +631,7 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat CustomerStatisticsDataVO feiJiShiDanShuLiangData = indicatorMap.get("非及时单数量(当日)"); CustomerStatisticsDataVO jiaZhangShuLiangData = indicatorMap.get("家长数量(当日)"); CustomerStatisticsDataVO xueShengShuLiangData = indicatorMap.get("学生数量(当日)"); + CustomerStatisticsDataVO laoshiShuLiangData = indicatorMap.get("老师数量(当日)"); CustomerStatisticsDataVO weiZhiShuLiangData = indicatorMap.get("未知数量(当日)"); CustomerStatisticsDataVO zhuDongBaoJiaShuLiangData = indicatorMap.get("主动报价数量(当日)"); CustomerStatisticsDataVO beiDongBaoJiaShuLiangData = indicatorMap.get("被动报价数量(当日)"); @@ -497,6 +643,11 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat CustomerStatisticsDataVO jiaZhangChuDanShuLiangData = indicatorMap.get("家长出单数量(当日)"); CustomerStatisticsDataVO xueShengChuDanShuLiangData = indicatorMap.get("学生出单数量(当日)"); + CustomerStatisticsDataVO jiazhangChuDanLvData = indicatorMap.get("家长出单率(当日)"); + CustomerStatisticsDataVO jiazhangJishiDanShuLiangData = indicatorMap.get("家长即时单数量(当日)"); + CustomerStatisticsDataVO xueshengChuDanLvData = indicatorMap.get("学生出单率(当日)"); + CustomerStatisticsDataVO xueshengJishiDanShuLiangData = indicatorMap.get("学生即时单数量(当日)"); + String[] groupFields = {"ntfGroup", "ofhGroup", "pswGroup", "wa1Group", "xb1Group", "yc1Group", "zd1Group", "aaGroup", "acGroup", "adGroup", "aeGroup"}; @@ -583,16 +734,16 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } - if (jiShiDanZhanBiData != null && jiShiDanShuLiangData != null && jinFenShuData != null) { + if (jiShiDanZhanBiData != null && jiShiDanShuLiangData != null && chengDanShuData != null) { for (String field : groupFields) { String jiShiDanStr = getFieldValue(jiShiDanShuLiangData, field); - String jinFenShuStr = getFieldValue(jinFenShuData, field); - if (jiShiDanStr != null && jinFenShuStr != null) { + String chengDanShuStr = getFieldValue(chengDanShuData, field); + if (jiShiDanStr != null && chengDanShuStr != null) { try { BigDecimal jiShiDan = new BigDecimal(jiShiDanStr); - BigDecimal jinFenShu = new BigDecimal(jinFenShuStr); - if (jinFenShu.compareTo(BigDecimal.ZERO) > 0) { - BigDecimal zhanBi = jiShiDan.divide(jinFenShu, 4, RoundingMode.HALF_UP) + BigDecimal chengDanShu = new BigDecimal(chengDanShuStr); + if (chengDanShu.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal zhanBi = jiShiDan.divide(chengDanShu, 4, RoundingMode.HALF_UP) .multiply(new BigDecimal("100")) .setScale(2, RoundingMode.HALF_UP); setFieldValue(jiShiDanZhanBiData, field, zhanBi + "%"); @@ -605,16 +756,16 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } - if (feiJiShiDanZhanBiData != null && feiJiShiDanShuLiangData != null && jinFenShuData != null) { + if (feiJiShiDanZhanBiData != null && feiJiShiDanShuLiangData != null && chengDanShuData != null) { for (String field : groupFields) { String feiJiShiDanStr = getFieldValue(feiJiShiDanShuLiangData, field); - String jinFenShuStr = getFieldValue(jinFenShuData, field); - if (feiJiShiDanStr != null && jinFenShuStr != null) { + String chengDanShuStr = getFieldValue(chengDanShuData, field); + if (feiJiShiDanStr != null && chengDanShuStr != null) { try { BigDecimal feiJiShiDan = new BigDecimal(feiJiShiDanStr); - BigDecimal jinFenShu = new BigDecimal(jinFenShuStr); - if (jinFenShu.compareTo(BigDecimal.ZERO) > 0) { - BigDecimal zhanBi = feiJiShiDan.divide(jinFenShu, 4, RoundingMode.HALF_UP) + BigDecimal chengDanShu = new BigDecimal(chengDanShuStr); + if (chengDanShu.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal zhanBi = feiJiShiDan.divide(chengDanShu, 4, RoundingMode.HALF_UP) .multiply(new BigDecimal("100")) .setScale(2, RoundingMode.HALF_UP); setFieldValue(feiJiShiDanZhanBiData, field, zhanBi + "%"); @@ -627,23 +778,25 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } - if (keHuShuXingData != null && jiaZhangShuLiangData != null && xueShengShuLiangData != null && weiZhiShuLiangData != null) { + if (keHuShuXingData != null && jiaZhangShuLiangData != null && laoshiShuLiangData != null && xueShengShuLiangData != null && weiZhiShuLiangData != null) { for (String field : groupFields) { String jiaZhangStr = getFieldValue(jiaZhangShuLiangData, field); String xueShengStr = getFieldValue(xueShengShuLiangData, field); + String laoShiStr = getFieldValue(laoshiShuLiangData, field); String weiZhiStr = getFieldValue(weiZhiShuLiangData, field); try { BigDecimal jiaZhang = jiaZhangStr != null ? new BigDecimal(jiaZhangStr) : BigDecimal.ZERO; BigDecimal xueSheng = xueShengStr != null ? new BigDecimal(xueShengStr) : BigDecimal.ZERO; + BigDecimal laoshi = laoShiStr != null ? new BigDecimal(laoShiStr) : BigDecimal.ZERO; BigDecimal weiZhi = weiZhiStr != null ? new BigDecimal(weiZhiStr) : BigDecimal.ZERO; - BigDecimal total = jiaZhang.add(xueSheng).add(weiZhi); + BigDecimal total = jiaZhang.add(xueSheng).add(weiZhi).add(laoshi); setFieldValue(keHuShuXingData, field, total.setScale(0, RoundingMode.HALF_UP).toString()); } catch (NumberFormatException e) { } } } - if (jiaZhangZhanBiData != null && jiaZhangShuLiangData != null && keHuShuXingData != null) { + if (jiaZhangZhanBiData != null && jiaZhangShuLiangData != null && keHuShuXingData != null ) { for (String field : groupFields) { String jiaZhangStr = getFieldValue(jiaZhangShuLiangData, field); String keHuShuXingStr = getFieldValue(keHuShuXingData, field); @@ -709,6 +862,29 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } + + if (laoshiZhanBiData != null && laoshiShuLiangData != null && keHuShuXingData != null) { + for (String field : groupFields) { + String laoshiStr = getFieldValue(laoshiShuLiangData, field); + String keHuShuXingStr = getFieldValue(keHuShuXingData, field); + if (laoshiStr != null && keHuShuXingStr != null) { + try { + BigDecimal laoshi = new BigDecimal(laoshiStr); + BigDecimal keHuShuXing = new BigDecimal(keHuShuXingStr); + if (keHuShuXing.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal zhanBi = laoshi.divide(keHuShuXing, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(2, RoundingMode.HALF_UP); + setFieldValue(laoshiZhanBiData, field, zhanBi + "%"); + } else { + setFieldValue(laoshiZhanBiData, field, "0%"); + } + } catch (NumberFormatException e) { + } + } + } + } + if (zhuDongBaoJiaZhanBiData != null && zhuDongBaoJiaShuLiangData != null && yiXiangDuShuLiangData != null) { for (String field : groupFields) { String zhuDongStr = getFieldValue(zhuDongBaoJiaShuLiangData, field); @@ -863,7 +1039,52 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } - if (jiaZhangChuDanZhanBiData != null && jiaZhangChuDanShuLiangData != null && jiaZhangShuLiangData != null) { + if (jiazhangChuDanLvData != null && jiaZhangShuLiangData != null && jiazhangJishiDanShuLiangData != null) { + for (String field : groupFields) { + String jiazhangJishidanStr = getFieldValue(jiazhangJishiDanShuLiangData, field); + String jiazhangShuLiangStr = getFieldValue(jiaZhangShuLiangData, field); + if (jiazhangJishidanStr != null && jiazhangShuLiangStr != null) { + try { + BigDecimal jiasZhangJishidan = new BigDecimal(jiazhangJishidanStr); + BigDecimal jiazhangshuliang = new BigDecimal(jiazhangShuLiangStr); + if (jiazhangshuliang.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal zhanBi = jiasZhangJishidan.divide(jiazhangshuliang, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(2, RoundingMode.HALF_UP); + setFieldValue(jiazhangChuDanLvData, field, zhanBi + "%"); + } else { + setFieldValue(jiazhangChuDanLvData, field, "0%"); + } + } catch (NumberFormatException e) { + } + } + } + } + + + if (xueshengChuDanLvData != null && xueShengShuLiangData != null && xueshengJishiDanShuLiangData != null) { + for (String field : groupFields) { + String xueshengJishidanStr = getFieldValue(xueshengJishiDanShuLiangData, field); + String xueshengShuLiangStr = getFieldValue(xueShengShuLiangData, field); + if (xueshengJishidanStr != null && xueshengShuLiangStr != null) { + try { + BigDecimal xueshengJishidan = new BigDecimal(xueshengJishidanStr); + BigDecimal xueshengshuliang = new BigDecimal(xueshengShuLiangStr); + if (xueshengshuliang.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal zhanBi = xueshengJishidan.divide(xueshengshuliang, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(2, RoundingMode.HALF_UP); + setFieldValue(xueshengChuDanLvData, field, zhanBi + "%"); + } else { + setFieldValue(xueshengChuDanLvData, field, "0%"); + } + } catch (NumberFormatException e) { + } + } + } + } + + /* if (jiaZhangChuDanZhanBiData != null && jiaZhangChuDanShuLiangData != null && jiaZhangShuLiangData != null) { for (String field : groupFields) { String jiaZhangChuDanStr = getFieldValue(jiaZhangChuDanShuLiangData, field); String jiaZhangStr = getFieldValue(jiaZhangShuLiangData, field); @@ -905,7 +1126,7 @@ public class CustomerStatisticsDataServiceImpl implements ICustomerStatisticsDat } } } - } + }*/ } private BigDecimal parseCostValue(String value) { diff --git a/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataV2ServiceImpl.java b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataV2ServiceImpl.java new file mode 100644 index 0000000..9179def --- /dev/null +++ b/excel-handle/src/main/java/com/ruoyi/excel/wecom/service/impl/CustomerStatisticsDataV2ServiceImpl.java @@ -0,0 +1,718 @@ +package com.ruoyi.excel.wecom.service.impl; + +import com.ruoyi.excel.wecom.domain.CustomerStatisticsDataV2; +import com.ruoyi.excel.wecom.domain.dto.TagTreeDTO; +import com.ruoyi.excel.wecom.mapper.CustomerExportDataMapper; +import com.ruoyi.excel.wecom.mapper.CustomerStatisticsDataV2Mapper; +import com.ruoyi.excel.wecom.service.ICustomerStatisticsDataV2Service; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 客户统计数据V2 Service业务层处理 + * 支持标签级成本,行列转换存储 + */ +@Slf4j +@Service +public class CustomerStatisticsDataV2ServiceImpl implements ICustomerStatisticsDataV2Service { + + @Autowired + private CustomerStatisticsDataV2Mapper dataV2Mapper; + + @Autowired + private CustomerExportDataMapper exportDataMapper; + private List finishFlag = Arrays.asList("已成交及时单9元+", "已成交非及时单9元+"); + private List timelyFinishFlag = Arrays.asList("已成交及时单9元+"); + private List noTimelyfinishFlag = Arrays.asList("已成交非及时单9元+"); + /** + * 组名到数据库字段的映射 + */ + private static final Map GROUP_NAME_TO_ATTR_MAP = new LinkedHashMap<>(); + static { + GROUP_NAME_TO_ATTR_MAP.put("投放", "tag_group1"); + GROUP_NAME_TO_ATTR_MAP.put("公司孵化", "tag_group2"); + GROUP_NAME_TO_ATTR_MAP.put("商务", "tag_group3"); + GROUP_NAME_TO_ATTR_MAP.put("A1组", "tag_group10"); + GROUP_NAME_TO_ATTR_MAP.put("B1组", "tag_group11"); + GROUP_NAME_TO_ATTR_MAP.put("C1组", "tag_group12"); + GROUP_NAME_TO_ATTR_MAP.put("D1组", "tag_group13"); + GROUP_NAME_TO_ATTR_MAP.put("E1组", "tag_group14"); + GROUP_NAME_TO_ATTR_MAP.put("自然流", "tag_group16"); + GROUP_NAME_TO_ATTR_MAP.put("F1组", "tag_group17"); + GROUP_NAME_TO_ATTR_MAP.put("G1组", "tag_group18"); + } + + @Override + public List selectCustomerStatisticsDataV2List( + String corpId, Date startDate, Date endDate) { + return dataV2Mapper.selectGroupLevelList(corpId, startDate, endDate); + } + + @Override + public List selectCustomerStatisticsDataV2List( + String corpId, Date startDate, Date endDate, String groupName, String tagName) { + return dataV2Mapper.selectListByFilter(corpId, startDate, endDate, groupName, tagName); + } + + @Override + public List selectTagTree(String corpId, Date startDate, Date endDate) { + log.info("selectTagTree called with corpId={}, startDate={}, endDate={}", corpId, startDate, endDate); + + // 查询所有数据,提取组-标签结构 + List allData = dataV2Mapper.selectTreeData(corpId, startDate, endDate); + log.info("selectTreeData returned {} records", allData.size()); + + // 按组名分组 + Map> groupDataMap = allData.stream() + .filter(d -> d.getGroupName() != null) + .collect(Collectors.groupingBy(CustomerStatisticsDataV2::getGroupName)); + log.info("groupDataMap has {} groups", groupDataMap.size()); + + List result = new ArrayList<>(); + + for (Map.Entry> entry : groupDataMap.entrySet()) { + String groupName = entry.getKey(); + List groupDataList = entry.getValue(); + + // 创建组节点 + TagTreeDTO groupNode = new TagTreeDTO(); + groupNode.setId("group_" + groupName); + groupNode.setLabel(groupName); + groupNode.setType("group"); + groupNode.setGroupName(groupName); + groupNode.setChildren(new ArrayList<>()); + + // 提取该组下的所有标签 + Set tagSet = new LinkedHashSet<>(); + for (CustomerStatisticsDataV2 data : groupDataList) { + if (data.getTagName() != null && !data.getTagName().isEmpty()) { + tagSet.add(data.getTagName()); + } + } + + // 创建标签节点 + for (String tagName : tagSet) { + TagTreeDTO tagNode = new TagTreeDTO(); + tagNode.setId("tag_" + groupName + "_" + tagName); + tagNode.setLabel(tagName); + tagNode.setType("tag"); + tagNode.setGroupName(groupName); + tagNode.setTagName(tagName); + tagNode.setCount(1); + groupNode.getChildren().add(tagNode); + } + + groupNode.setCount(groupNode.getChildren().size()); + result.add(groupNode); + } + + // 按组名排序 + result.sort(Comparator.comparing(TagTreeDTO::getLabel)); + + return result; + } + + @Override + public List selectTreeData( + String corpId, Date startDate, Date endDate) { + // 1. 查询所有数据(组级+标签级) + List allData = dataV2Mapper.selectTreeData( + corpId, startDate, endDate); + + // 2. 构建树状结构 + Map groupMap = new LinkedHashMap<>(); + List result = new ArrayList<>(); + + // 先处理组级数据 + for (CustomerStatisticsDataV2 data : allData) { + if (data.getDataLevel() != null && data.getDataLevel() == 1) { + data.setChildren(new ArrayList<>()); + data.setLeaf(false); + groupMap.put(data.getId(), data); + result.add(data); + } + } + + // 再处理标签级数据,挂载到对应组下 + for (CustomerStatisticsDataV2 data : allData) { + if (data.getDataLevel() != null && data.getDataLevel() == 2) { + data.setLeaf(true); + CustomerStatisticsDataV2 parent = groupMap.get(data.getParentId()); + if (parent != null) { + parent.getChildren().add(data); + } + } + } + + return result; + } + + @Override + public CustomerStatisticsDataV2 selectCustomerStatisticsDataV2ById(Long id) { + return dataV2Mapper.selectById(id); + } + + @Override + public int insertCustomerStatisticsDataV2(CustomerStatisticsDataV2 data) { + return dataV2Mapper.insert(data); + } + + @Override + public int updateCustomerStatisticsDataV2(CustomerStatisticsDataV2 data) { + return dataV2Mapper.updateById(data); + } + + @Override + public int deleteCustomerStatisticsDataV2ByIds(Long[] ids) { + int count = 0; + for (Long id : ids) { + count += dataV2Mapper.deleteById(id); + } + return count; + } + + @Override + @Transactional + public int inputCost(String corpId, Date date, String groupName, String tagName, + BigDecimal costValue, String inputType) { + try { + // 1. 查询目标记录 + CustomerStatisticsDataV2 data = dataV2Mapper.selectByCorpDateGroupTag( + corpId, date, groupName, tagName); + + if (data == null) { + throw new RuntimeException("未找到对应的统计数据:" + groupName + + (tagName != null ? "/" + tagName : "")); + } + + // 2. 计算实际总成本 + BigDecimal actualTotalCost; + boolean isSingleInput = "single".equals(inputType); + if (isSingleInput) { + // 单条成本 × 进粉数 = 总成本 + actualTotalCost = costValue.multiply( + new BigDecimal(data.getCustomerCount())); + data.setSingleCost(costValue); + data.setTotalCost(actualTotalCost); // 更新总成本 + } else { + actualTotalCost = costValue; + data.setTotalCost(costValue); + } + data.setCostInputType(inputType); + + // 3. 计算派生成本指标(单条成本录入时保留用户输入的单条成本值) + calculateDerivedCosts(data, actualTotalCost, isSingleInput); + + // 4. 更新记录 + dataV2Mapper.updateById(data); + + // 5. 如果是标签级成本,需要重新计算组级汇总 + if (tagName != null && !tagName.isEmpty()) { + recalculateGroupCost(corpId, date, groupName); + } + + log.info("成本录入成功:corpId={}, date={}, group={}, tag={}, cost={}", + corpId, date, groupName, tagName, costValue); + + return 1; + } catch (Exception e) { + log.error("成本录入失败:" + e.getMessage(), e); + throw new RuntimeException("成本录入失败: " + e.getMessage(), e); + } + } + + /** + * 计算派生成本指标 + * @param data 数据对象 + * @param totalCost 总成本 + * @param preserveSingleCost 是否保留原有的单条成本值(单条成本录入模式时为true) + */ + private void calculateDerivedCosts(CustomerStatisticsDataV2 data, BigDecimal totalCost, boolean preserveSingleCost) { + // 初始化成本为0 + if (!preserveSingleCost) { + data.setSingleCost(BigDecimal.ZERO); + } + data.setOrderCost(BigDecimal.ZERO); + + // 单条成本 = 总成本 / 进粉数(仅在非单条成本录入模式下计算) + if (!preserveSingleCost && data.getCustomerCount() != null && data.getCustomerCount() > 0) { + BigDecimal singleCost = totalCost.divide( + new BigDecimal(data.getCustomerCount()), 2, RoundingMode.HALF_UP); + data.setSingleCost(singleCost); + } + + // 成单成本 = 总成本 / 成单数 + if (data.getOrderCount() != null && data.getOrderCount() > 0) { + BigDecimal orderCost = totalCost.divide( + new BigDecimal(data.getOrderCount()), 2, RoundingMode.HALF_UP); + data.setOrderCost(orderCost); + } + } + + /** + * 计算派生成本指标(默认不保留单条成本) + */ + private void calculateDerivedCosts(CustomerStatisticsDataV2 data, BigDecimal totalCost) { + calculateDerivedCosts(data, totalCost, false); + } + + /** + * 重新计算组级成本汇总 + */ + private void recalculateGroupCost(String corpId, Date date, String groupName) { + // 1. 查询该组下所有标签级数据 + List tagDataList = dataV2Mapper + .selectTagLevelByCorpDateGroup(corpId, date, groupName); + + // 2. 汇总标签成本 + BigDecimal totalCostSum = BigDecimal.ZERO; + int totalCustomerCount = 0; + + for (CustomerStatisticsDataV2 tagData : tagDataList) { + if (tagData.getTotalCost() != null) { + totalCostSum = totalCostSum.add(tagData.getTotalCost()); + } + if (tagData.getCustomerCount() != null) { + totalCustomerCount += tagData.getCustomerCount(); + } + } + + // 3. 更新组级记录 + CustomerStatisticsDataV2 groupData = dataV2Mapper + .selectGroupLevelByCorpDateGroup(corpId, date, groupName); + + if (groupData != null) { + groupData.setTotalCost(totalCostSum); + if (totalCustomerCount > 0) { + BigDecimal avgSingleCost = totalCostSum.divide( + new BigDecimal(totalCustomerCount), 2, RoundingMode.HALF_UP); + groupData.setSingleCost(avgSingleCost); + } + calculateDerivedCosts(groupData, totalCostSum); + dataV2Mapper.updateById(groupData); + + log.info("组级成本重新计算完成:group={}, totalCost={}", groupName, totalCostSum); + } + } + + @Override + public int recalculateStatistics(String corpId, Date date) { + // 删除旧数据 + dataV2Mapper.deleteByDateRange(corpId, date, date); + // 重新计算逻辑在 HandleAllDataV2 中实现 + log.info("统计数据已删除,等待重新计算:corpId={}, date={}", corpId, date); + return 1; + } + + @Override + public int recalculateStatisticsRange(String corpId, Date startDate, Date endDate) { + // 删除旧数据 + dataV2Mapper.deleteByDateRange(corpId, startDate, endDate); + // 重新计算逻辑在 HandleAllDataV2 中实现 + log.info("统计数据已删除,等待重新计算:corpId={}, startDate={}, endDate={}", + corpId, startDate, endDate); + return 1; + } + + @Override + public List selectByWeekAggregation( + String corpId, Integer year, Integer week, String groupName, String tagName) { + log.info("周聚合查询: corpId={}, year={}, week={}, groupName={}, tagName={}", corpId, year, week, groupName, tagName); + + // 计算周的开始和结束日期(使用与V1一致的MySQL WEEK函数逻辑) + Date[] weekRange = calculateWeekRangeByMySQL(year, week); + if (weekRange == null) { + // 如果周范围无效(跨年),返回空列表 + log.info("周范围无效(跨年),返回空列表"); + return new ArrayList<>(); + } + Date startDate = weekRange[0]; + Date endDate = weekRange[1]; + log.info("周日期范围: {} 至 {}", formatDate(startDate), formatDate(endDate)); + + // 查询该周的数据并聚合 + List result = dataV2Mapper.selectAggregatedByDateRange(corpId, startDate, endDate, groupName, tagName); + log.info("周聚合查询结果: {}条记录", result.size()); + + // 从原始数据表按finish_date重新查询成单数(解决跨日成交问题) + recalculateOrderCountFromOriginalData(corpId, startDate, endDate, result); + + // 重新计算比率指标(SQL只聚合数量,比率需要重新计算) + for (CustomerStatisticsDataV2 data : result) { + recalculateRates(data); + log.info("记录: groupName={}, tagName={}, dataLevel={}, customerCount={}, orderCount={}, conversionRate={}", + data.getGroupName(), data.getTagName(), data.getDataLevel(), data.getCustomerCount(), + data.getOrderCount(), data.getConversionRate()); + } + + // 设置显示字段 + String yearWeek = year + "年第" + week + "周"; + String dateRange = formatDate(startDate) + " 至 " + formatDate(endDate); + for (CustomerStatisticsDataV2 data : result) { + data.setYearWeek(yearWeek); + data.setDateRange(dateRange); + } + + return result; + } + + @Override + public List selectByMonthAggregation( + String corpId, String yearMonth, String groupName, String tagName) { + // 解析年月 + String[] parts = yearMonth.split("-"); + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); + + // 计算月的开始和结束日期 + Date[] monthRange = calculateMonthRange(year, month); + Date startDate = monthRange[0]; + Date endDate = monthRange[1]; + + // 查询该月的数据并聚合 + List result = dataV2Mapper.selectAggregatedByDateRange(corpId, startDate, endDate, groupName, tagName); + + // 从原始数据表按finish_date重新查询成单数(解决跨日成交问题) + recalculateOrderCountFromOriginalData(corpId, startDate, endDate, result); + + // 重新计算比率指标 + for (CustomerStatisticsDataV2 data : result) { + recalculateRates(data); + } + + // 设置显示字段 + String yearMonthDisplay = year + "年" + String.format("%02d", month) + "月"; + String dateRange = formatDate(startDate) + " 至 " + formatDate(endDate); + for (CustomerStatisticsDataV2 data : result) { + data.setYearMonth(yearMonthDisplay); + data.setDateRange(dateRange); + } + + return result; + } + + @Override + public List selectAllAggregation( + String corpId, String groupName, String tagName) { + // 查询所有数据并聚合 + List result = dataV2Mapper.selectAllAggregated(corpId, groupName, tagName); + + // 从原始数据表按finish_date重新查询成单数(解决跨日成交问题) + // 对于全部数据,不限制日期范围 + recalculateOrderCountFromOriginalData(corpId, null, null, result); + + // 重新计算比率指标 + for (CustomerStatisticsDataV2 data : result) { + recalculateRates(data); + } + + // 设置显示字段 + for (CustomerStatisticsDataV2 data : result) { + data.setYearWeek("全部数据"); + data.setYearMonth("全部数据"); + data.setDateRange("全部数据"); + } + + return result; + } + + /** + * 从原始数据表按finish_date重新查询成单数 + * 解决跨日成交问题:成单数应该按finish_date统计,而不是按add_date + * 优化:使用批量查询,避免N+1问题 + */ + private void recalculateOrderCountFromOriginalData(String corpId, Date startDate, Date endDate, + List dataList) { + if (dataList == null || dataList.isEmpty()) { + return; + } + + // 批量查询所有组的成单数(一次SQL查询) + Map orderCountMap = exportDataMapper.selectOrderCountBatchByFinishDateRange(corpId, startDate, endDate,finishFlag); + if (orderCountMap == null) { + orderCountMap = new HashMap<>(); + } + + // 批量查询所有组的及时单数量(一次SQL查询) + Map timelyCountMap = exportDataMapper.selectTimelyOrderCountBatchByDateRange(corpId, startDate, endDate,timelyFinishFlag); + if (timelyCountMap == null) { + timelyCountMap = new HashMap<>(); + } + + // 批量查询所有组的非及时单数量(一次SQL查询) + Map nonTimelyCountMap = exportDataMapper.selectNonTimelyOrderCountBatchByDateRange(corpId, startDate, endDate,noTimelyfinishFlag); + if (nonTimelyCountMap == null) { + nonTimelyCountMap = new HashMap<>(); + } + + // 遍历数据,从批量查询结果中获取对应的值 + for (CustomerStatisticsDataV2 data : dataList) { + String groupName = data.getGroupName(); + if (groupName == null || groupName.isEmpty()) { + continue; + } + + String attr = GROUP_NAME_TO_ATTR_MAP.get(groupName); + if (attr == null) { + continue; + } + + // 从批量查询结果中获取成单数 + Object orderCountObj = orderCountMap.get(attr); + int orderCount = 0; + if (orderCountObj != null) { + if (orderCountObj instanceof Number) { + orderCount = ((Number) orderCountObj).intValue(); + } else { + try { + orderCount = Integer.parseInt(orderCountObj.toString()); + } catch (NumberFormatException e) { + log.warn("无法解析成单数: {}", orderCountObj); + } + } + } + data.setOrderCount(orderCount); + + // 从批量查询结果中获取及时单数量 + Object timelyCountObj = timelyCountMap.get(attr); + int timelyCount = 0; + if (timelyCountObj != null) { + if (timelyCountObj instanceof Number) { + timelyCount = ((Number) timelyCountObj).intValue(); + } else { + try { + timelyCount = Integer.parseInt(timelyCountObj.toString()); + } catch (NumberFormatException e) { + log.warn("无法解析及时单数量: {}", timelyCountObj); + } + } + } + data.setTimelyOrderCount(timelyCount); + + // 从批量查询结果中获取非及时单数量 + Object nonTimelyCountObj = nonTimelyCountMap.get(attr); + int nonTimelyCount = 0; + if (nonTimelyCountObj != null) { + if (nonTimelyCountObj instanceof Number) { + nonTimelyCount = ((Number) nonTimelyCountObj).intValue(); + } else { + try { + nonTimelyCount = Integer.parseInt(nonTimelyCountObj.toString()); + } catch (NumberFormatException e) { + log.warn("无法解析非及时单数量: {}", nonTimelyCountObj); + } + } + } + data.setNonTimelyOrderCount(nonTimelyCount); + + log.debug("重新计算成单数: groupName={}, orderCount={}, timelyCount={}, nonTimelyCount={}", + groupName, data.getOrderCount(), data.getTimelyOrderCount(), data.getNonTimelyOrderCount()); + } + } + + /** + * 重新计算比率指标 + * 在SQL聚合数量后,需要重新计算各种比率 + */ + private void recalculateRates(CustomerStatisticsDataV2 data) { + // 转化率 = 成单数 / 进粉数 + data.setConversionRate(calculateRate(data.getOrderCount(), data.getCustomerCount())); + + // 及时单占比 = 及时单数 / 成单数 + data.setTimelyRate(calculateRate(data.getTimelyOrderCount(), data.getOrderCount())); + + // 非及时单占比 = 非及时单数 / 成单数 + data.setNonTimelyRate(calculateRate(data.getNonTimelyOrderCount(), data.getOrderCount())); + + // 客户属性占比 + data.setParentRate(calculateRate(data.getParentCount(), data.getCustomerAttrCount())); + data.setStudentRate(calculateRate(data.getStudentCount(), data.getCustomerAttrCount())); + data.setTeacherRate(calculateRate(data.getTeacherCount(), data.getCustomerAttrCount())); + data.setUnknownRate(calculateRate(data.getUnknownAttrCount(), data.getCustomerAttrCount())); + + // 意向度占比 + data.setActiveQuoteRate(calculateRate(data.getActiveQuoteCount(), data.getIntentionCount())); + data.setPassiveQuoteRate(calculateRate(data.getPassiveQuoteCount(), data.getIntentionCount())); + data.setNoQuoteRate(calculateRate(data.getNoQuoteCount(), data.getIntentionCount())); + data.setDeletedQuoteRate(calculateRate(data.getDeletedQuoteCount(), data.getIntentionCount())); + + // 年级占比 + data.setPrimaryRate(calculateRate(data.getPrimaryCount(), data.getGradeCount())); + data.setMiddleRate(calculateRate(data.getMiddleCount(), data.getGradeCount())); + data.setHighRate(calculateRate(data.getHighCount(), data.getGradeCount())); + + // 出单率 + data.setParentOrderRate(calculateRate(data.getParentDailyOrderCount(), data.getParentDailyCount())); + data.setStudentOrderRate(calculateRate(data.getStudentDailyOrderCount(), data.getStudentDailyCount())); + data.setTeacherOrderRate(calculateRate(data.getTeacherDailyOrderCount(), data.getTeacherDailyCount())); + data.setUnknownOrderRate(calculateRate(data.getUnknownDailyOrderCount(), data.getUnknownDailyCount())); + + // 成本指标(基于聚合后的总成本重新计算) + // 初始化成本为0 + data.setSingleCost(BigDecimal.ZERO); + data.setOrderCost(BigDecimal.ZERO); + + // 确保totalCost不为null + if (data.getTotalCost() == null) { + data.setTotalCost(BigDecimal.ZERO); + } + + if (data.getTotalCost().compareTo(BigDecimal.ZERO) > 0) { + // 单条成本 = 总成本 / 进粉数 + if (data.getCustomerCount() != null && data.getCustomerCount() > 0) { + BigDecimal singleCost = data.getTotalCost().divide( + new BigDecimal(data.getCustomerCount()), 2, RoundingMode.HALF_UP); + data.setSingleCost(singleCost); + } + // 成单成本 = 总成本 / 成单数 + if (data.getOrderCount() != null && data.getOrderCount() > 0) { + BigDecimal orderCost = data.getTotalCost().divide( + new BigDecimal(data.getOrderCount()), 2, RoundingMode.HALF_UP); + data.setOrderCost(orderCost); + } + } + } + + /** + * 计算比率(返回百分比字符串) + */ + private String calculateRate(Integer count, Integer total) { + if (total == null || total == 0 || count == null) { + return "0%"; + } + BigDecimal rate = new BigDecimal(count) + .multiply(new BigDecimal(100)) + .divide(new BigDecimal(total), 2, RoundingMode.HALF_UP); + return rate.toString() + "%"; + } + + /** + * 计算周范围(修复跨年问题) + * 规则: + * 1. 周的第一天是周一(不是周日) + * 2. 第一周:从1月1日开始,到1月1日所在周的周日结束 + * - 如果1月1日是周日,第一周只有1天(1月1日当天) + * 3. 最后一周:从最后一周的周一(12月31日往前推)开始,到12月31日结束 + * - 如果12月31日是周一,最后一周只有1天(12月31日当天) + * 4. 其他周:按正常的周一到周日计算 + */ + private Date[] calculateWeekRangeByMySQL(int year, int week) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, Calendar.JANUARY); + calendar.set(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + // 获取1月1日是星期几(1=周日,2=周一,...,7=周六) + int jan1DayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); + // 转换为:0=周日,1=周一,...,6=周六 + int jan1Weekday = (jan1DayOfWeek == Calendar.SUNDAY) ? 0 : (jan1DayOfWeek - 1); + + // 计算12月31日 + Calendar dec31Cal = Calendar.getInstance(); + dec31Cal.set(Calendar.YEAR, year); + dec31Cal.set(Calendar.MONTH, Calendar.DECEMBER); + dec31Cal.set(Calendar.DAY_OF_MONTH, 31); + dec31Cal.set(Calendar.HOUR_OF_DAY, 0); + dec31Cal.set(Calendar.MINUTE, 0); + dec31Cal.set(Calendar.SECOND, 0); + dec31Cal.set(Calendar.MILLISECOND, 0); + + // 第一周:从1月1日开始 + if (week == 1) { + Date startDate = calendar.getTime(); + // 计算第一周的结束日期(周日) + // 如果1月1日是周日(0),则第一周只有1天(当天) + // 否则,计算到本周日 + int daysToSunday; + if (jan1Weekday == 0) { + daysToSunday = 0; // 1月1日是周日,第一周只有1天 + } else { + daysToSunday = 7 - jan1Weekday; // 到本周日 + } + calendar.add(Calendar.DAY_OF_MONTH, daysToSunday); + Date endDate = calendar.getTime(); + return new Date[]{startDate, endDate}; + } + + // 计算第一周结束日期(1月1日所在周的周日) + Calendar firstWeekEndCal = (Calendar) calendar.clone(); + int firstWeekDays; + if (jan1Weekday == 0) { + firstWeekDays = 0; // 1月1日是周日,第一周只有1天 + } else { + firstWeekDays = 7 - jan1Weekday; // 到本周日 + } + firstWeekEndCal.add(Calendar.DAY_OF_MONTH, firstWeekDays); + + // 计算第二周开始日期(第一周结束后的周一) + Calendar secondWeekStartCal = (Calendar) firstWeekEndCal.clone(); + secondWeekStartCal.add(Calendar.DAY_OF_MONTH, 1); + + // 计算目标周的开始和结束 + // 从第二周开始,每周都是周一到周日 + Calendar targetWeekStartCal = (Calendar) secondWeekStartCal.clone(); + targetWeekStartCal.add(Calendar.WEEK_OF_YEAR, week - 2); + + // 如果开始日期已经跨年了(大于12月31日),则返回null + if (targetWeekStartCal.after(dec31Cal)) { + return null; + } + + Date startDate = targetWeekStartCal.getTime(); + + Calendar targetWeekEndCal = (Calendar) targetWeekStartCal.clone(); + targetWeekEndCal.add(Calendar.DAY_OF_MONTH, 6); + Date endDate = targetWeekEndCal.getTime(); + + // 如果目标周的结束超过了12月31日,则调整到12月31日 + if (targetWeekEndCal.after(dec31Cal)) { + endDate = dec31Cal.getTime(); + } + + return new Date[]{startDate, endDate}; + } + + /** + * 计算月的开始和结束日期 + */ + private Date[] calculateMonthRange(int year, int month) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + Date startDate = calendar.getTime(); + + calendar.add(Calendar.MONTH, 1); + calendar.add(Calendar.DAY_OF_MONTH, -1); + Date endDate = calendar.getTime(); + + return new Date[]{startDate, endDate}; + } + + /** + * 格式化日期为 yyyy-MM-dd 字符串 + */ + private String formatDate(Date date) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH) + 1; + int day = calendar.get(Calendar.DAY_OF_MONTH); + return String.format("%d-%02d-%02d", year, month, day); + } +} diff --git a/excel-handle/src/main/resources/mapper/wecom/CustomerExportDataMapper.xml b/excel-handle/src/main/resources/mapper/wecom/CustomerExportDataMapper.xml index a58e19d..55ef4af 100644 --- a/excel-handle/src/main/resources/mapper/wecom/CustomerExportDataMapper.xml +++ b/excel-handle/src/main/resources/mapper/wecom/CustomerExportDataMapper.xml @@ -139,7 +139,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/excel-handle/src/main/resources/mapper/wecom/CustomerStatisticsDataMapper.xml b/excel-handle/src/main/resources/mapper/wecom/CustomerStatisticsDataMapper.xml index 5e5e645..e2be14c 100644 --- a/excel-handle/src/main/resources/mapper/wecom/CustomerStatisticsDataMapper.xml +++ b/excel-handle/src/main/resources/mapper/wecom/CustomerStatisticsDataMapper.xml @@ -61,6 +61,20 @@ ORDER BY cur_date, sort_no + + + + SELECT + FROM customer_statistics_data_v2 + WHERE corp_id = #{corpId} + AND cur_date = #{curDate} + AND group_name = #{groupName} + AND ( + (tag_name = #{tagName} AND #{tagName} IS NOT NULL AND #{tagName} != '') + OR (tag_name IS NULL AND (#{tagName} IS NULL OR #{tagName} = '')) + ) + + + + + + + + + + + + + + + + + INSERT INTO customer_statistics_data_v2 ( + corp_id, cur_date, group_name, tag_name, tag_group_id, tag_id, data_level, parent_id, + total_cost, single_cost, order_cost, cost_input_type, + order_count, customer_count, timely_order_count, non_timely_order_count, + conversion_rate, timely_rate, non_timely_rate, + customer_attr_count, parent_count, student_count, teacher_count, unknown_attr_count, + parent_rate, student_rate, teacher_rate, unknown_rate, + parent_order_count, student_order_count, teacher_order_count, unknown_order_count, + parent_daily_count, student_daily_count, teacher_daily_count, unknown_daily_count, + parent_daily_order_count, student_daily_order_count, teacher_daily_order_count, unknown_daily_order_count, + parent_order_rate, student_order_rate, teacher_order_rate, unknown_order_rate, + intention_count, active_quote_count, passive_quote_count, no_quote_count, deleted_quote_count, + active_quote_rate, passive_quote_rate, no_quote_rate, deleted_quote_rate, + grade_count, primary_count, middle_count, high_count, + primary_rate, middle_rate, high_rate, + sort_no + ) VALUES + + ( + #{item.corpId}, #{item.curDate}, #{item.groupName}, #{item.tagName}, #{item.tagGroupId}, #{item.tagId}, + #{item.dataLevel}, #{item.parentId}, + #{item.totalCost}, #{item.singleCost}, #{item.orderCost}, #{item.costInputType}, + #{item.orderCount}, #{item.customerCount}, #{item.timelyOrderCount}, #{item.nonTimelyOrderCount}, + #{item.conversionRate}, #{item.timelyRate}, #{item.nonTimelyRate}, + #{item.customerAttrCount}, #{item.parentCount}, #{item.studentCount}, #{item.teacherCount}, #{item.unknownAttrCount}, + #{item.parentRate}, #{item.studentRate}, #{item.teacherRate}, #{item.unknownRate}, + #{item.parentOrderCount}, #{item.studentOrderCount}, #{item.teacherOrderCount}, #{item.unknownOrderCount}, + #{item.parentDailyCount}, #{item.studentDailyCount}, #{item.teacherDailyCount}, #{item.unknownDailyCount}, + #{item.parentDailyOrderCount}, #{item.studentDailyOrderCount}, #{item.teacherDailyOrderCount}, #{item.unknownDailyOrderCount}, + #{item.parentOrderRate}, #{item.studentOrderRate}, #{item.teacherOrderRate}, #{item.unknownOrderRate}, + #{item.intentionCount}, #{item.activeQuoteCount}, #{item.passiveQuoteCount}, #{item.noQuoteCount}, #{item.deletedQuoteCount}, + #{item.activeQuoteRate}, #{item.passiveQuoteRate}, #{item.noQuoteRate}, #{item.deletedQuoteRate}, + #{item.gradeCount}, #{item.primaryCount}, #{item.middleCount}, #{item.highCount}, + #{item.primaryRate}, #{item.middleRate}, #{item.highRate}, + #{item.sortNo} + ) + + + + + + DELETE FROM customer_statistics_data_v2 + WHERE corp_id = #{corpId} + + AND cur_date >= #{startDate} + + + AND cur_date <= #{endDate} + + + + + + + + + + + + + + + + + + diff --git a/excel-handle/src/main/resources/sql/customer_statistics_data_v2.sql b/excel-handle/src/main/resources/sql/customer_statistics_data_v2.sql new file mode 100644 index 0000000..fb6ada7 --- /dev/null +++ b/excel-handle/src/main/resources/sql/customer_statistics_data_v2.sql @@ -0,0 +1,114 @@ +-- 客户统计数据表V2(支持标签级成本,行列转换存储) +DROP TABLE IF EXISTS `customer_statistics_data_v2`; + +CREATE TABLE `customer_statistics_data_v2` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `corp_id` VARCHAR(100) NOT NULL COMMENT '企业ID', + `cur_date` DATE NOT NULL COMMENT '统计日期', + + -- 维度信息 + `group_name` VARCHAR(50) NOT NULL COMMENT '组名(N组、O组等)', + `tag_name` VARCHAR(100) DEFAULT NULL COMMENT '标签名(NULL表示组级汇总)', + `tag_group_id` VARCHAR(100) DEFAULT NULL COMMENT '标签组ID(关联wecom_tag_group)', + `tag_id` VARCHAR(100) DEFAULT NULL COMMENT '标签ID(关联wecom_tag)', + + -- 层级关系 + `data_level` TINYINT DEFAULT 1 COMMENT '数据级别:1-组级汇总,2-标签级明细', + `parent_id` BIGINT(20) DEFAULT NULL COMMENT '父记录ID(标签级数据对应组级记录的ID)', + + -- 成本数据(支持标签级成本) + `total_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '总成本(手工录入)', + `single_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '单条成本(计算得出)', + `order_cost` DECIMAL(12,2) DEFAULT NULL COMMENT '成单成本(计算得出)', + `cost_input_type` VARCHAR(20) DEFAULT NULL COMMENT '成本录入类型:total-总成本,single-单条成本', + + -- 数量指标 + `order_count` INT DEFAULT 0 COMMENT '成单数', + `customer_count` INT DEFAULT 0 COMMENT '进粉数', + `timely_order_count` INT DEFAULT 0 COMMENT '及时单数', + `non_timely_order_count` INT DEFAULT 0 COMMENT '非及时单数', + + -- 比率指标 + `conversion_rate` VARCHAR(10) DEFAULT '0%' COMMENT '转化率', + `timely_rate` VARCHAR(10) DEFAULT '0%' COMMENT '及时单占比', + `non_timely_rate` VARCHAR(10) DEFAULT '0%' COMMENT '非及时单占比', + + -- 客户属性指标 + `customer_attr_count` INT DEFAULT 0 COMMENT '客户属性数量', + `parent_count` INT DEFAULT 0 COMMENT '家长数量', + `student_count` INT DEFAULT 0 COMMENT '学生数量', + `teacher_count` INT DEFAULT 0 COMMENT '老师数量', + `unknown_attr_count` INT DEFAULT 0 COMMENT '未知属性数量', + `parent_rate` VARCHAR(10) DEFAULT '0%' COMMENT '家长占比', + `student_rate` VARCHAR(10) DEFAULT '0%' COMMENT '学生占比', + `teacher_rate` VARCHAR(10) DEFAULT '0%' COMMENT '老师占比', + `unknown_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未知占比', + + -- 出单率指标 + `parent_order_count` INT DEFAULT 0 COMMENT '家长出单数量', + `student_order_count` INT DEFAULT 0 COMMENT '学生出单数量', + `teacher_order_count` INT DEFAULT 0 COMMENT '老师出单数量', + `unknown_order_count` INT DEFAULT 0 COMMENT '未知出单数量', + `parent_daily_count` INT DEFAULT 0 COMMENT '家长当日数量', + `student_daily_count` INT DEFAULT 0 COMMENT '学生当日数量', + `teacher_daily_count` INT DEFAULT 0 COMMENT '老师当日数量', + `unknown_daily_count` INT DEFAULT 0 COMMENT '未知当日数量', + `parent_daily_order_count` INT DEFAULT 0 COMMENT '家长当日出单数量', + `student_daily_order_count` INT DEFAULT 0 COMMENT '学生当日出单数量', + `teacher_daily_order_count` INT DEFAULT 0 COMMENT '老师当日出单数量', + `unknown_daily_order_count` INT DEFAULT 0 COMMENT '未知当日出单数量', + `parent_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '家长出单率', + `student_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '学生出单率', + `teacher_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '老师出单率', + `unknown_order_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未知出单率', + + -- 意向度指标 + `intention_count` INT DEFAULT 0 COMMENT '意向度数量', + `active_quote_count` INT DEFAULT 0 COMMENT '主动报价数量', + `passive_quote_count` INT DEFAULT 0 COMMENT '被动报价数量', + `no_quote_count` INT DEFAULT 0 COMMENT '未开口报价数量', + `deleted_quote_count` INT DEFAULT 0 COMMENT '已删除报价数量', + `active_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '主动报价占比', + `passive_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '被动报价占比', + `no_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '未开口报价占比', + `deleted_quote_rate` VARCHAR(10) DEFAULT '0%' COMMENT '已删除报价占比', + + -- 年级指标 + `grade_count` INT DEFAULT 0 COMMENT '年级数量', + `primary_count` INT DEFAULT 0 COMMENT '小学数量', + `middle_count` INT DEFAULT 0 COMMENT '初中数量', + `high_count` INT DEFAULT 0 COMMENT '高中数量', + `primary_rate` VARCHAR(10) DEFAULT '0%' COMMENT '小学占比', + `middle_rate` VARCHAR(10) DEFAULT '0%' COMMENT '初中占比', + `high_rate` VARCHAR(10) DEFAULT '0%' COMMENT '高中占比', + + `sort_no` INT DEFAULT 0 COMMENT '排序号', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_corp_date_group_tag` (`corp_id`, `cur_date`, `group_name`, `tag_name`), + INDEX `idx_corp_date` (`corp_id`, `cur_date`), + INDEX `idx_group_name` (`group_name`), + INDEX `idx_data_level` (`data_level`), + INDEX `idx_parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户统计数据表V2(支持标签级成本,行列转换)'; + +-- 成本录入记录表(用于追溯) +DROP TABLE IF EXISTS `cost_input_record_v2`; + +CREATE TABLE `cost_input_record_v2` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT, + `corp_id` VARCHAR(100) NOT NULL COMMENT '企业ID', + `cur_date` DATE NOT NULL COMMENT '统计日期', + `group_name` VARCHAR(50) NOT NULL COMMENT '组名', + `tag_name` VARCHAR(100) DEFAULT NULL COMMENT '标签名(NULL表示组级)', + `cost_type` VARCHAR(20) NOT NULL COMMENT 'total-总成本,single-单条成本', + `input_value` DECIMAL(12,2) NOT NULL COMMENT '录入值', + `actual_total_cost` DECIMAL(12,2) NOT NULL COMMENT '实际总成本', + `input_by` VARCHAR(50) DEFAULT NULL COMMENT '录入人', + `input_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + INDEX `idx_corp_date_group` (`corp_id`, `cur_date`, `group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成本录入记录表V2'; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/wocom/CustomerStatisticsDataV2Controller.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/wocom/CustomerStatisticsDataV2Controller.java new file mode 100644 index 0000000..8feea86 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/wocom/CustomerStatisticsDataV2Controller.java @@ -0,0 +1,195 @@ +package com.ruoyi.web.controller.wocom; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.CorpContextHolder; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.excel.wecom.domain.CustomerStatisticsDataV2; +import com.ruoyi.excel.wecom.domain.dto.TagTreeDTO; +import com.ruoyi.excel.wecom.service.ICustomerStatisticsDataV2Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 客户统计数据V2 Controller + * 支持标签级成本,行列转换存储 + */ +@RestController +@RequestMapping("/wecom/customerStatisticsV2") +public class CustomerStatisticsDataV2Controller extends BaseController { + + private static final Logger log = LoggerFactory.getLogger(CustomerStatisticsDataV2Controller.class); + + @Autowired + private ICustomerStatisticsDataV2Service customerStatisticsDataV2Service; + + /** + * 查询客户统计数据V2列表(支持按组、标签筛选,支持天/周/月维度) + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:list')") + @GetMapping("/list") + public TableDataInfo list( + @RequestParam(value = "dataType", required = false) String dataType, + @RequestParam(value = "year", required = false) Integer year, + @RequestParam(value = "week", required = false) Integer week, + @RequestParam(value = "yearMonth", required = false) String yearMonth, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate, + @RequestParam(value = "groupName", required = false) String groupName, + @RequestParam(value = "tagName", required = false) String tagName) { + String corpId = CorpContextHolder.getCurrentCorpId(); + + log.info("list接口被调用: dataType={}, year={}, week={}, yearMonth={}, groupName={}, tagName={}", + dataType, year, week, yearMonth, groupName, tagName); + + List list; + if ("week".equals(dataType)) { + list = customerStatisticsDataV2Service.selectByWeekAggregation(corpId, year, week, groupName, tagName); + log.info("周聚合查询结果: {}条记录", list.size()); + return getDataTable(list); + } else if ("month".equals(dataType)) { + list = customerStatisticsDataV2Service.selectByMonthAggregation(corpId, yearMonth, groupName, tagName); + log.info("月聚合查询结果: {}条记录", list.size()); + return getDataTable(list); + } else if ("all".equals(dataType)) { + list = customerStatisticsDataV2Service.selectAllAggregation(corpId, groupName, tagName); + log.info("全部数据查询结果: {}条记录", list.size()); + return getDataTable(list); + } else { + startPage(); + list = customerStatisticsDataV2Service + .selectCustomerStatisticsDataV2List(corpId, startDate, endDate, groupName, tagName); + return getDataTable(list); + } + } + + /** + * 查询标签树(只返回组-标签结构,不返回统计数据) + * 用于前端左侧树状筛选面板 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:tree')") + @GetMapping("/tree") + public AjaxResult tree( + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate) { + String corpId = CorpContextHolder.getCurrentCorpId(); + List treeData = customerStatisticsDataV2Service + .selectTagTree(corpId, startDate, endDate); + return success(treeData); + } + + /** + * 导出客户统计数据V2列表 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:export')") + @Log(title = "客户统计数据V2", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate) { + String corpId = CorpContextHolder.getCurrentCorpId(); + List dataList = customerStatisticsDataV2Service + .selectCustomerStatisticsDataV2List(corpId, startDate, endDate); + ExcelUtil util = new ExcelUtil<>(CustomerStatisticsDataV2.class); + util.exportExcel(response, dataList, "流量看板数据V2"); + } + + /** + * 获取客户统计数据V2详细信息 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:query')") + @GetMapping(value = "/{id}") + public AjaxResult getInfo(@PathVariable("id") Long id) { + return success(customerStatisticsDataV2Service.selectCustomerStatisticsDataV2ById(id)); + } + + /** + * 新增客户统计数据V2 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:add')") + @Log(title = "客户统计数据V2", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody CustomerStatisticsDataV2 data) { + return toAjax(customerStatisticsDataV2Service.insertCustomerStatisticsDataV2(data)); + } + + /** + * 修改客户统计数据V2 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:edit')") + @Log(title = "客户统计数据V2", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody CustomerStatisticsDataV2 data) { + return toAjax(customerStatisticsDataV2Service.updateCustomerStatisticsDataV2(data)); + } + + /** + * 删除客户统计数据V2 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:remove')") + @Log(title = "客户统计数据V2", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + return toAjax(customerStatisticsDataV2Service.deleteCustomerStatisticsDataV2ByIds(ids)); + } + + /** + * 录入成本(支持组级和标签级) + * @param date 日期 + * @param groupName 组名 + * @param tagName 标签名(为空表示组级) + * @param costValue 成本值 + * @param inputType 录入类型:total-总成本,single-单条成本 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:cost')") + @Log(title = "客户统计数据V2-成本录入", businessType = BusinessType.UPDATE) + @PostMapping("/cost") + public AjaxResult inputCost( + @RequestParam(value = "date") @DateTimeFormat(pattern = "yyyy-MM-dd") Date date, + @RequestParam(value = "groupName") String groupName, + @RequestParam(value = "tagName", required = false) String tagName, + @RequestParam(value = "costValue") BigDecimal costValue, + @RequestParam(value = "inputType") String inputType) { + String corpId = CorpContextHolder.getCurrentCorpId(); + return toAjax(customerStatisticsDataV2Service.inputCost( + corpId, date, groupName, tagName, costValue, inputType)); + } + + /** + * 重新计算指定日期的统计数据 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:recalculate')") + @Log(title = "客户统计数据V2-重新计算", businessType = BusinessType.UPDATE) + @PostMapping("/recalculate") + public AjaxResult recalculate( + @RequestParam(value = "date") @DateTimeFormat(pattern = "yyyy-MM-dd") Date date) { + String corpId = CorpContextHolder.getCurrentCorpId(); + return toAjax(customerStatisticsDataV2Service.recalculateStatistics(corpId, date)); + } + + /** + * 重新计算指定日期范围的统计数据 + */ + @PreAuthorize("@ss.hasPermi('wecom:customerStatisticsV2:recalculate')") + @Log(title = "客户统计数据V2-重新计算", businessType = BusinessType.UPDATE) + @PostMapping("/recalculateRange") + public AjaxResult recalculateRange( + @RequestParam(value = "startDate") @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @RequestParam(value = "endDate") @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate) { + String corpId = CorpContextHolder.getCurrentCorpId(); + return toAjax(customerStatisticsDataV2Service.recalculateStatisticsRange( + corpId, startDate, endDate)); + } +} diff --git a/ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/WeComTask.java b/ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/WeComTask.java index 733c9d6..6079a16 100644 --- a/ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/WeComTask.java +++ b/ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/WeComTask.java @@ -3,6 +3,7 @@ package com.ruoyi.quartz.task; import com.alibaba.fastjson.JSON; import com.ruoyi.excel.wecom.domain.CorpInfo; import com.ruoyi.excel.wecom.helper.HandleAllData; +import com.ruoyi.excel.wecom.helper.HandleAllDataV2; import com.ruoyi.excel.wecom.mapper.CorpInfoMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -18,6 +19,8 @@ public class WeComTask { @Autowired private HandleAllData handleAllData; @Autowired + private HandleAllDataV2 handleAllDataV2; + @Autowired private CorpInfoMapper corpInfoMapper; public void initData() throws IOException { System.out.println("初始化项目数据 包括 部门 人员"); @@ -39,6 +42,11 @@ public class WeComTask { handleAllData.createAllDepartmentReportData(); } + public void createAllDepartmentReportDataV2() throws IOException { + System.out.println("计算所有流量看板数据V2"); + handleAllDataV2.createAllReportDataV2(); + } + public void createCurDateCustomerReport() throws IOException { Date from = Date.from(LocalDate.now().atStartOfDay() .atZone(ZoneId.systemDefault()).toInstant()); @@ -59,4 +67,15 @@ public class WeComTask { .atZone(ZoneId.systemDefault()).toInstant())); }); } + + public void createCurDateCustomerReportV2() throws IOException { + System.out.println("计算所有流量看板数据V2"); + Date from = Date.from(LocalDate.now().atStartOfDay() + .atZone(ZoneId.systemDefault()).toInstant()); + System.out.println("计算" + JSON.toJSONString(from) + "流量看板数据"); + List corpInfos = corpInfoMapper.selectCorpInfoList(new CorpInfo()); + corpInfos.forEach(item->{ + handleAllDataV2.createReportDataV2(item.getCorpId(), from); + }); + } } diff --git a/ruoyi-ui/dist.zip b/ruoyi-ui/dist.zip new file mode 100644 index 0000000..2db2743 Binary files /dev/null and b/ruoyi-ui/dist.zip differ diff --git a/ruoyi-ui/src/api/wecom/customerStatisticsV2.js b/ruoyi-ui/src/api/wecom/customerStatisticsV2.js new file mode 100644 index 0000000..2cd7423 --- /dev/null +++ b/ruoyi-ui/src/api/wecom/customerStatisticsV2.js @@ -0,0 +1,93 @@ +import request from '@/utils/request' + +// 查询流量看板V2数据列表(组级) +export function listCustomerStatisticsV2(query) { + return request({ + url: '/wecom/customerStatisticsV2/list', + method: 'get', + params: query + }) +} + +// 查询流量看板V2树状数据(组+标签) +export function treeCustomerStatisticsV2(query) { + return request({ + url: '/wecom/customerStatisticsV2/tree', + method: 'get', + params: query + }) +} + +// 查询流量看板V2数据详细 +export function getCustomerStatisticsV2(id) { + return request({ + url: '/wecom/customerStatisticsV2/' + id, + method: 'get' + }) +} + +// 新增流量看板V2数据 +export function addCustomerStatisticsV2(data) { + return request({ + url: '/wecom/customerStatisticsV2', + method: 'post', + data: data + }) +} + +// 修改流量看板V2数据 +export function updateCustomerStatisticsV2(data) { + return request({ + url: '/wecom/customerStatisticsV2', + method: 'put', + data: data + }) +} + +// 删除流量看板V2数据 +export function delCustomerStatisticsV2(ids) { + return request({ + url: '/wecom/customerStatisticsV2/' + ids, + method: 'delete' + }) +} + +// 导出流量看板V2数据 +export function exportCustomerStatisticsV2(query) { + return request({ + url: '/wecom/customerStatisticsV2/export', + method: 'post', + params: query, + responseType: 'blob' + }) +} + +// 成本录入(支持组级和标签级) +export function inputCost(data) { + return request({ + url: '/wecom/customerStatisticsV2/cost', + method: 'post', + params: data + }) +} + +// 重新计算指定日期的统计数据 +export function recalculateStatistics(date) { + return request({ + url: '/wecom/customerStatisticsV2/recalculate', + method: 'post', + params: { date: date } + }) +} + +// 重新计算指定日期范围的统计数据 +export function recalculateStatisticsRange(startDate, endDate) { + return request({ + url: '/wecom/customerStatisticsV2/recalculateRange', + method: 'post', + params: { + startDate: startDate, + endDate: endDate + } + }) +} diff --git a/ruoyi-ui/src/views/wecom/customerStatistics/index.vue b/ruoyi-ui/src/views/wecom/customerStatistics/index.vue index bef0001..8ae76ff 100644 --- a/ruoyi-ui/src/views/wecom/customerStatistics/index.vue +++ b/ruoyi-ui/src/views/wecom/customerStatistics/index.vue @@ -310,14 +310,85 @@ export default { if (this.queryParams.year && this.queryParams.week) { const year = parseInt(this.queryParams.year) const week = this.queryParams.week - const startDate = this.getWeekStartDate(year, week) - const endDate = new Date(startDate) - endDate.setDate(endDate.getDate() + 6) - this.weekDateRange = this.formatDate(startDate) + ' 至 ' + this.formatDate(endDate) + const weekRange = this.calculateWeekRangeFixed(year, week) + this.weekDateRange = weekRange.startDate + ' 至 ' + weekRange.endDate } else { this.weekDateRange = '' } }, + // 计算周范围(修复跨年问题) + // 规则: + // 1. 周的第一天是周一(不是周日) + // 2. 第一周:从1月1日开始,到1月1日所在周的周日结束 + // - 如果1月1日是周日,第一周只有1天(1月1日当天) + // 3. 最后一周:从最后一周的周一(12月31日往前推)开始,到12月31日结束 + // - 如果12月31日是周一,最后一周只有1天(12月31日当天) + // 4. 不显示跨年的日期范围 + calculateWeekRangeFixed(year, week) { + const jan1 = new Date(year, 0, 1) + const jan1Weekday = jan1.getDay() // 0=周日,1=周一,...,6=周六 + const dec31 = new Date(year, 11, 31) + const dec31Weekday = dec31.getDay() // 0=周日,1=周一,...,6=周六 + + // 第一周:从1月1日开始 + if (week === 1) { + const startDate = this.formatDate(jan1) + // 计算第一周的结束日期(周日) + // 如果1月1日是周日(0),则第一周只有1天(当天) + // 否则,计算到本周日 + let daysToSunday + if (jan1Weekday === 0) { + // 1月1日是周日,第一周只有1天 + daysToSunday = 0 + } else { + // 1月1日不是周日,计算到本周日 + daysToSunday = 7 - jan1Weekday + } + const firstWeekEnd = new Date(jan1) + firstWeekEnd.setDate(jan1.getDate() + daysToSunday) + const endDate = this.formatDate(firstWeekEnd) + return { startDate, endDate } + } + + // 计算第一周的结束日期(周日) + let firstWeekDays + if (jan1Weekday === 0) { + firstWeekDays = 0 // 1月1日是周日,第一周只有1天 + } else { + firstWeekDays = 7 - jan1Weekday // 到本周日 + } + const firstWeekEnd = new Date(jan1) + firstWeekEnd.setDate(jan1.getDate() + firstWeekDays) + + // 计算第二周开始日期(第一周结束后的周一) + const secondWeekStart = new Date(firstWeekEnd) + secondWeekStart.setDate(firstWeekEnd.getDate() + 1) + + // 计算目标周的开始和结束 + // 从第二周开始,每周都是周一到周日 + const targetWeekStart = new Date(secondWeekStart) + targetWeekStart.setDate(secondWeekStart.getDate() + (week - 2) * 7) + + const targetWeekEnd = new Date(targetWeekStart) + targetWeekEnd.setDate(targetWeekStart.getDate() + 6) + + // 如果目标周的结束超过了12月31日,则调整到12月31日 + let endDate = this.formatDate(targetWeekEnd) + if (targetWeekEnd > dec31) { + endDate = this.formatDate(dec31) + } + + // 如果开始日期已经跨年了(大于12月31日),则返回空 + if (targetWeekStart > dec31) { + return { startDate: '', endDate: '' } + } + + return { + startDate: this.formatDate(targetWeekStart), + endDate: endDate + } + }, + // 获取指定周的开始日期(周一)- 旧方法,保留用于兼容 getWeekStartDate(year, week) { const date = new Date(year, 0, 1) const dayOfWeek = date.getDay() @@ -371,12 +442,75 @@ export default { }) }, handleDataTypeChange() { - this.queryParams.year = undefined - this.queryParams.week = undefined - this.queryParams.yearMonth = undefined - this.queryParams.startDate = undefined - this.queryParams.endDate = undefined - this.weekDateRange = '' + const dataType = this.queryParams.dataType + + if (dataType === 'day' || !dataType) { + // 按天:设置默认日期为今天 + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const day = String(today.getDate()).padStart(2, '0') + this.queryParams.startDate = `${year}-${month}-${day}` + this.queryParams.endDate = `${year}-${month}-${day}` + // 清除其他类型的参数 + this.queryParams.year = undefined + this.queryParams.week = undefined + this.queryParams.yearMonth = undefined + this.weekDateRange = '' + } else if (dataType === 'week') { + // 按周:计算当前周,清除天数参数 + const now = new Date() + // 使用字符串格式与 el-date-picker 的 value-format 保持一致 + this.queryParams.year = String(now.getFullYear()) + // 计算当前是第几周 + const jan1 = new Date(now.getFullYear(), 0, 1) + const jan1Weekday = jan1.getDay() // 0=周日,1=周一,...,6=周六 + + // 计算第一周的结束日期(周日) + let firstWeekDays + if (jan1Weekday === 0) { + firstWeekDays = 0 // 1月1日是周日,第一周只有1天 + } else { + firstWeekDays = 7 - jan1Weekday // 到本周日 + } + const firstWeekEnd = new Date(jan1) + firstWeekEnd.setDate(jan1.getDate() + firstWeekDays) + + // 计算第二周开始日期(第一周结束后的周一) + const secondWeekStart = new Date(firstWeekEnd) + secondWeekStart.setDate(firstWeekEnd.getDate() + 1) + + // 计算今天是第几周 + const diffTime = now - secondWeekStart + const diffWeeks = Math.floor(diffTime / (7 * 24 * 60 * 60 * 1000)) + this.queryParams.week = diffWeeks + 2 // 第2周开始计数 + + this.updateWeekDateRange() + // 清除天数参数 + this.queryParams.startDate = undefined + this.queryParams.endDate = undefined + this.queryParams.yearMonth = undefined + } else if (dataType === 'month') { + // 按月:设置当前年月,清除天数参数 + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + this.queryParams.yearMonth = `${year}-${month}` + // 清除天数参数 + this.queryParams.startDate = undefined + this.queryParams.endDate = undefined + this.queryParams.year = undefined + this.queryParams.week = undefined + this.weekDateRange = '' + } else if (dataType === 'all') { + // 全部数据:清除所有日期参数 + this.queryParams.startDate = undefined + this.queryParams.endDate = undefined + this.queryParams.year = undefined + this.queryParams.week = undefined + this.queryParams.yearMonth = undefined + this.weekDateRange = '' + } }, handleQuery() { this.queryParams.pageNum = 1 diff --git a/ruoyi-ui/src/views/wecom/customerStatisticsV2/index.vue b/ruoyi-ui/src/views/wecom/customerStatisticsV2/index.vue new file mode 100644 index 0000000..63ba535 --- /dev/null +++ b/ruoyi-ui/src/views/wecom/customerStatisticsV2/index.vue @@ -0,0 +1,907 @@ + + + + +