|
@@ -2,6 +2,7 @@ package com.storlead.trade.service.impl;
|
|
|
|
|
|
|
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
@@ -12,7 +13,11 @@ import com.storlead.trade.dto.SopDetailCreateDTO;
|
|
|
import com.storlead.trade.dto.SopDetailDTO;
|
|
import com.storlead.trade.dto.SopDetailDTO;
|
|
|
import com.storlead.trade.dto.SopDetailResponseDTO;
|
|
import com.storlead.trade.dto.SopDetailResponseDTO;
|
|
|
import com.storlead.trade.dto.SopDetailUpdateDTO;
|
|
import com.storlead.trade.dto.SopDetailUpdateDTO;
|
|
|
|
|
+import com.storlead.trade.entity.MarketingCampaignSopDetailEntity;
|
|
|
|
|
+import com.storlead.trade.entity.MarketEmailsEntity;
|
|
|
import com.storlead.trade.entity.SopDetailEntity;
|
|
import com.storlead.trade.entity.SopDetailEntity;
|
|
|
|
|
+import com.storlead.trade.mapper.MarketingCampaignSopDetailMapper;
|
|
|
|
|
+import com.storlead.trade.mapper.MarketEmailsMapper;
|
|
|
import com.storlead.trade.mapper.SopDetailMapper;
|
|
import com.storlead.trade.mapper.SopDetailMapper;
|
|
|
import com.storlead.trade.service.SopDetailService;
|
|
import com.storlead.trade.service.SopDetailService;
|
|
|
import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
|
|
import com.storlead.trade.service.impl.support.TradeDataSourceServiceImpl;
|
|
@@ -23,9 +28,14 @@ import org.springframework.transaction.annotation.Transactional;
|
|
|
import org.springframework.util.CollectionUtils;
|
|
import org.springframework.util.CollectionUtils;
|
|
|
import org.springframework.util.ObjectUtils;
|
|
import org.springframework.util.ObjectUtils;
|
|
|
|
|
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.math.RoundingMode;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.Collections;
|
|
import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* SOP明细 Service实现类
|
|
* SOP明细 Service实现类
|
|
@@ -40,6 +50,17 @@ public class SopDetailServiceImpl
|
|
|
extends TradeDataSourceServiceImpl<SopDetailMapper, SopDetailEntity>
|
|
extends TradeDataSourceServiceImpl<SopDetailMapper, SopDetailEntity>
|
|
|
implements SopDetailService {
|
|
implements SopDetailService {
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 这里注入 Mapper 而不是 Service:
|
|
|
|
|
+ * MarketingCampaignSopDetailServiceImpl 已经注入了 MarketEmailsService,
|
|
|
|
|
+ * 而 MarketEmailsServiceImpl 又依赖 SopDetailService —— 若此处再注入 Service 会形成
|
|
|
|
|
+ * BeanCurrentlyInCreationException 循环依赖,Mapper 无此问题。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MarketingCampaignSopDetailMapper marketingCampaignSopDetailMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MarketEmailsMapper marketEmailsMapper;
|
|
|
|
|
+
|
|
|
@Override
|
|
@Override
|
|
|
public Result<Object> getList(SopDetailDTO dto) {
|
|
public Result<Object> getList(SopDetailDTO dto) {
|
|
|
if (dto == null) {
|
|
if (dto == null) {
|
|
@@ -160,6 +181,151 @@ public class SopDetailServiceImpl
|
|
|
return Result.ok();
|
|
return Result.ok();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public int countEmailStats() {
|
|
|
|
|
+ // 1. marketing_campaign_sop_detail.id → sop_detail_id
|
|
|
|
|
+ LambdaQueryWrapper<MarketingCampaignSopDetailEntity> mcsWrapper = new LambdaQueryWrapper<>();
|
|
|
|
|
+ mcsWrapper.eq(MarketingCampaignSopDetailEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
|
|
|
|
|
+ mcsWrapper.isNotNull(MarketingCampaignSopDetailEntity::getSopDetailId);
|
|
|
|
|
+ List<MarketingCampaignSopDetailEntity> mcsList = marketingCampaignSopDetailMapper.selectList(mcsWrapper);
|
|
|
|
|
+
|
|
|
|
|
+ Map<Long, Long> sopDetailIdByStep = new HashMap<>();
|
|
|
|
|
+ if (!CollectionUtils.isEmpty(mcsList)) {
|
|
|
|
|
+ for (MarketingCampaignSopDetailEntity e : mcsList) {
|
|
|
|
|
+ if (e.getId() == null || e.getSopDetailId() == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ sopDetailIdByStep.put(e.getId(), e.getSopDetailId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 全量取出待刷新的 sop_detail(无邮件的步骤也要重置为 0)
|
|
|
|
|
+ LambdaQueryWrapper<SopDetailEntity> sopWrapper = new LambdaQueryWrapper<>();
|
|
|
|
|
+ sopWrapper.eq(SopDetailEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
|
|
|
|
|
+ sopWrapper.select(SopDetailEntity::getId, SopDetailEntity::getSendNumber, SopDetailEntity::getOpenRate);
|
|
|
|
|
+ List<SopDetailEntity> sopDetailList = this.list(sopWrapper);
|
|
|
|
|
+ if (CollectionUtils.isEmpty(sopDetailList)) {
|
|
|
|
|
+ log.info("[sopDetail统计] 无有效 SOP 步骤,跳过");
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 按 marketing_campaign_sop_detail_id 聚合邮件(一条 SQL,只扫一遍表)
|
|
|
|
|
+ Map<Long, int[]> statBySopDetail = new HashMap<>();
|
|
|
|
|
+ if (!sopDetailIdByStep.isEmpty()) {
|
|
|
|
|
+ QueryWrapper<MarketEmailsEntity> qw = new QueryWrapper<>();
|
|
|
|
|
+ qw.select(
|
|
|
|
|
+ "marketing_campaign_sop_detail_id AS step_id",
|
|
|
|
|
+ "COUNT(CASE WHEN status = 1 THEN 1 END) AS send_cnt",
|
|
|
|
|
+ "COUNT(CASE WHEN status = 1 AND IFNULL(has_reader, 0) = 1 THEN 1 END) AS open_cnt");
|
|
|
|
|
+ qw.eq("is_delete", CommonConstant.DEL_FLAG_0);
|
|
|
|
|
+ qw.isNotNull("marketing_campaign_sop_detail_id");
|
|
|
|
|
+ qw.groupBy("marketing_campaign_sop_detail_id");
|
|
|
|
|
+ List<Map<String, Object>> rows = marketEmailsMapper.selectMaps(qw);
|
|
|
|
|
+
|
|
|
|
|
+ if (!CollectionUtils.isEmpty(rows)) {
|
|
|
|
|
+ for (Map<String, Object> row : rows) {
|
|
|
|
|
+ if (row == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Long stepId = toLong(row.get("step_id"));
|
|
|
|
|
+ Long sopDetailId = stepId == null ? null : sopDetailIdByStep.get(stepId);
|
|
|
|
|
+ if (sopDetailId == null) {
|
|
|
|
|
+ // 邮件挂的步骤已删除 / 未关联 sop_detail,不计入任何步骤
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ int[] agg = statBySopDetail.computeIfAbsent(sopDetailId, k -> new int[2]);
|
|
|
|
|
+ agg[0] += toInteger(row.get("send_cnt"));
|
|
|
|
|
+ agg[1] += toInteger(row.get("open_cnt"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 逐个写回(值没变的不发 SQL)
|
|
|
|
|
+ int updated = 0;
|
|
|
|
|
+ for (SopDetailEntity sopDetail : sopDetailList) {
|
|
|
|
|
+ int[] agg = statBySopDetail.get(sopDetail.getId());
|
|
|
|
|
+ int sendNumber = agg == null ? 0 : agg[0];
|
|
|
|
|
+ int openNumber = agg == null ? 0 : agg[1];
|
|
|
|
|
+ BigDecimal openRate = calcRate(openNumber, sendNumber);
|
|
|
|
|
+
|
|
|
|
|
+ if (eqInt(sendNumber, sopDetail.getSendNumber()) && eqRate(openRate, sopDetail.getOpenRate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ LambdaUpdateWrapper<SopDetailEntity> updateWrapper = new LambdaUpdateWrapper<>();
|
|
|
|
|
+ updateWrapper.eq(SopDetailEntity::getId, sopDetail.getId());
|
|
|
|
|
+ updateWrapper.set(SopDetailEntity::getSendNumber, sendNumber);
|
|
|
|
|
+ updateWrapper.set(SopDetailEntity::getOpenRate, openRate);
|
|
|
|
|
+ if (this.update(updateWrapper)) {
|
|
|
|
|
+ updated++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("[sopDetail统计] 完成,sop步骤总数={},写库={}条", sopDetailList.size(), updated);
|
|
|
|
|
+ return updated;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开率:已读邮件数 / 发送数 * 100,四舍五入保留整数。
|
|
|
|
|
+ * sop_detail.open_rate 是 decimal(10,0),小数位会被 MySQL 直接截断,所以这里统一先取整。
|
|
|
|
|
+ * 发送数为 0 时记 0,避免出现除零错误。
|
|
|
|
|
+ */
|
|
|
|
|
+ private BigDecimal calcRate(int numerator, int denominator) {
|
|
|
|
|
+ if (denominator <= 0 || numerator <= 0) {
|
|
|
|
|
+ return BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ return BigDecimal.valueOf(numerator)
|
|
|
|
|
+ .multiply(BigDecimal.valueOf(100))
|
|
|
|
|
+ .divide(BigDecimal.valueOf(denominator), 0, RoundingMode.HALF_UP);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean eqInt(Integer a, Integer b) {
|
|
|
|
|
+ int left = a == null ? 0 : a;
|
|
|
|
|
+ int right = b == null ? 0 : b;
|
|
|
|
|
+ return left == right;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开率比较:把 null 视作 0。MySQL 返回的 decimal 精度可能带小数位,统一用 intValue 比对。
|
|
|
|
|
+ */
|
|
|
|
|
+ private boolean eqRate(BigDecimal a, BigDecimal b) {
|
|
|
|
|
+ int left = a == null ? 0 : a.intValue();
|
|
|
|
|
+ int right = b == null ? 0 : b.intValue();
|
|
|
|
|
+ return left == right;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 聚合结果转 int:MySQL 的 COUNT 经 JDBC 可能是 Long / BigInteger / BigDecimal,null 记 0。
|
|
|
|
|
+ */
|
|
|
|
|
+ private int toInteger(Object value) {
|
|
|
|
|
+ if (ObjectUtils.isEmpty(value)) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value instanceof Number) {
|
|
|
|
|
+ return ((Number) value).intValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Integer.parseInt(value.toString().trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 分组键转 Long:非数字(脏数据)返回 null,由调用方跳过。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Long toLong(Object value) {
|
|
|
|
|
+ if (ObjectUtils.isEmpty(value)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value instanceof Number) {
|
|
|
|
|
+ return ((Number) value).longValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Long.valueOf(value.toString().trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Entity → ResponseDTO
|
|
* Entity → ResponseDTO
|
|
|
*/
|
|
*/
|