package cn.iocoder.yudao.module.crm.service.salesTarget; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetChangeStatusReqVO; import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetPageReqVO; import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetRespVO; import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetSaveReqVO; import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.analysis.CrmSalesTargetAnalysisReqVO; import cn.iocoder.yudao.module.crm.dal.dataobject.salesTarget.CrmSalesTargetDO; import cn.iocoder.yudao.module.crm.dal.mysql.salesTarget.CrmSalesTargetAchievementMapper; import cn.iocoder.yudao.module.crm.dal.mysql.salesTarget.CrmSalesTargetMapper; import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetDimensionEnum; import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetPeriodTypeEnum; import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetStatusEnum; import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi; import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO; import cn.iocoder.yudao.module.system.api.dept.DeptApi; import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*; /** * 销售目标 Service 实现类 * * @author 超级管理员 */ @Service @Validated public class CrmSalesTargetServiceImpl implements CrmSalesTargetService { private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); private static final Pattern YEAR_PATTERN = Pattern.compile("^\\d{4}$"); private static final Pattern QUARTER_PATTERN = Pattern.compile("^(\\d{4})-Q([1-4])$"); private static final Pattern MONTH_PATTERN = Pattern.compile("^(\\d{4})-(0[1-9]|1[0-2])$"); private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM"); @Resource private CrmSalesTargetMapper salesTargetMapper; @Resource private CrmSalesTargetAchievementMapper achievementMapper; @Resource private AdminUserApi adminUserApi; @Resource private DeptApi deptApi; @Resource private MdmItemApi mdmItemApi; @Override @Transactional(rollbackFor = Exception.class) public Long createSalesTarget(CrmSalesTargetSaveReqVO createReqVO) { // 1. 校验目标维度对象 validateTargetDimension(createReqVO); // 2. 计算周期起止,并插入 CrmSalesTargetDO salesTarget = BeanUtils.toBean(createReqVO, CrmSalesTargetDO.class); LocalDateTime[] period = calculatePeriod(createReqVO.getPeriodType(), createReqVO.getPeriodValue()); salesTarget.setStartTime(period[0]).setEndTime(period[1]) .setStatus(calcStatusByPeriod(period[0], period[1])); salesTargetMapper.insert(salesTarget); return salesTarget.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updateSalesTarget(CrmSalesTargetSaveReqVO updateReqVO) { // 1. 校验存在 CrmSalesTargetDO salesTarget = validateSalesTargetExists(updateReqVO.getId()); // 2. 校验目标维度对象 validateTargetDimension(updateReqVO); // 3. 更新 CrmSalesTargetDO updateDO = BeanUtils.toBean(updateReqVO, CrmSalesTargetDO.class); LocalDateTime[] period = calculatePeriod(updateReqVO.getPeriodType(), updateReqVO.getPeriodValue()); updateDO.setStartTime(period[0]).setEndTime(period[1]); // 手动作废的状态不因编辑而覆盖 updateDO.setStatus(Objects.equals(salesTarget.getStatus(), CrmSalesTargetStatusEnum.INVALID.getStatus()) ? CrmSalesTargetStatusEnum.INVALID.getStatus() : calcStatusByPeriod(period[0], period[1])); salesTargetMapper.updateById(updateDO); } @Override public void changeSalesTargetStatus(CrmSalesTargetChangeStatusReqVO reqVO) { // 1. 校验存在 validateSalesTargetExists(reqVO.getId()); // 2. 更新状态 salesTargetMapper.updateById(new CrmSalesTargetDO().setId(reqVO.getId()).setStatus(reqVO.getStatus())); } @Override public void deleteSalesTarget(Long id) { // 1. 校验存在 validateSalesTargetExists(id); // 2. 删除 salesTargetMapper.deleteById(id); } @Override public CrmSalesTargetRespVO getSalesTarget(Long id) { CrmSalesTargetDO salesTarget = validateSalesTargetExists(id); return convertListToRespVO(Collections.singletonList(salesTarget)).get(0); } @Override public PageResult getSalesTargetPage(CrmSalesTargetPageReqVO pageReqVO) { PageResult pageResult = salesTargetMapper.selectPage(pageReqVO); if (CollUtil.isEmpty(pageResult.getList())) { return PageResult.empty(); } return new PageResult<>(convertListToRespVO(pageResult.getList()), pageResult.getTotal()); } @Override public List getSalesTargetList(CrmSalesTargetPageReqVO reqVO) { List list = salesTargetMapper.selectList(reqVO); return convertListToRespVO(list); } @Override public List getSalesTargetAnalysis(CrmSalesTargetAnalysisReqVO reqVO) { // 按周期 + 维度过滤查询全部目标(含已结束),用于达成分析看板 List list = salesTargetMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() .eqIfPresent(CrmSalesTargetDO::getStatus, reqVO.getStatus()) .eqIfPresent(CrmSalesTargetDO::getTargetDimension, reqVO.getTargetDimension()) .eq(CrmSalesTargetDO::getPeriodType, reqVO.getPeriodType()) .eq(CrmSalesTargetDO::getPeriodValue, reqVO.getPeriodValue()) .orderByDesc(CrmSalesTargetDO::getId)); return convertListToRespVO(list); } // ========== 私有方法 ========== private CrmSalesTargetDO validateSalesTargetExists(Long id) { CrmSalesTargetDO salesTarget = salesTargetMapper.selectById(id); if (salesTarget == null) { throw exception(CRM_SALES_TARGET_NOT_EXISTS); } return salesTarget; } private void validateTargetDimension(CrmSalesTargetSaveReqVO reqVO) { if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType()) && reqVO.getTargetUserId() == null) { throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "用户"); } if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType()) && reqVO.getTargetDeptId() == null) { throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "部门"); } if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType()) && reqVO.getTargetItemId() == null) { throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "物料"); } } /** * 根据周期类型 + 周期值计算周期起止时间(半开区间 [start, end)) */ private LocalDateTime[] calculatePeriod(Integer periodType, String periodValue) { if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.YEAR.getType())) { if (!YEAR_PATTERN.matcher(periodValue).matches()) { throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026"); } int year = Integer.parseInt(periodValue); return new LocalDateTime[]{LocalDateTime.of(year, 1, 1, 0, 0), LocalDateTime.of(year + 1, 1, 1, 0, 0)}; } if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.QUARTER.getType())) { Matcher matcher = QUARTER_PATTERN.matcher(periodValue); if (!matcher.matches()) { throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026-Q1"); } int year = Integer.parseInt(matcher.group(1)); int startMonth = (Integer.parseInt(matcher.group(2)) - 1) * 3 + 1; return new LocalDateTime[]{ YearMonth.of(year, startMonth).atDay(1).atStartOfDay(), YearMonth.of(year, startMonth).plusMonths(3).atDay(1).atStartOfDay()}; } if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.MONTH.getType())) { if (!MONTH_PATTERN.matcher(periodValue).matches()) { throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026-07"); } YearMonth yearMonth = YearMonth.parse(periodValue, MONTH_FORMAT); return new LocalDateTime[]{ yearMonth.atDay(1).atStartOfDay(), yearMonth.plusMonths(1).atDay(1).atStartOfDay()}; } throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "年/季度/月"); } /** * 根据当前时间与周期起止,自动判定目标状态 */ private Integer calcStatusByPeriod(LocalDateTime startTime, LocalDateTime endTime) { LocalDateTime now = LocalDateTime.now(); if (now.isBefore(startTime)) { return CrmSalesTargetStatusEnum.NOT_ACTIVE.getStatus(); } if (now.isAfter(endTime)) { return CrmSalesTargetStatusEnum.END.getStatus(); } return CrmSalesTargetStatusEnum.ACTIVE.getStatus(); } /** * 将 DO 列表转换为 RespVO 列表,填充对象名称、创建人名称与实际达成数据 */ private List convertListToRespVO(List targetList) { if (CollUtil.isEmpty(targetList)) { return Collections.emptyList(); } // 1. 收集关联 ID,批量查询名称 Set userIds = new HashSet<>(); Set deptIds = new HashSet<>(); Set itemIds = new HashSet<>(); for (CrmSalesTargetDO target : targetList) { if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) { userIds.add(target.getTargetUserId()); } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType())) { deptIds.add(target.getTargetDeptId()); } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) { itemIds.add(target.getTargetItemId()); } if (StrUtil.isNumeric(target.getCreator())) { // 创建人(用户编号字符串) userIds.add(Long.valueOf(target.getCreator())); } } Map userMap = CollUtil.isNotEmpty(userIds) ? adminUserApi.getUserMap(userIds) : Collections.emptyMap(); Map deptMap = CollUtil.isNotEmpty(deptIds) ? deptApi.getDeptMap(deptIds) : Collections.emptyMap(); Map itemMap = CollUtil.isNotEmpty(itemIds) ? mdmItemApi.getItemMap(itemIds) : Collections.emptyMap(); // 2. 转换并填充 return convertList(targetList, target -> { CrmSalesTargetRespVO respVO = BeanUtils.toBean(target, CrmSalesTargetRespVO.class); // 目标对象名称 if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) { AdminUserRespDTO user = userMap.get(target.getTargetUserId()); if (user != null) { respVO.setTargetUserName(user.getNickname()); } } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType())) { DeptRespDTO dept = deptMap.get(target.getTargetDeptId()); if (dept != null) { respVO.setTargetDeptName(dept.getName()); } } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) { MdmItemRespDTO item = itemMap.get(target.getTargetItemId()); if (item != null) { respVO.setTargetItemName(item.getName()); } } // 创建人名称 if (StrUtil.isNumeric(target.getCreator())) { AdminUserRespDTO creator = userMap.get(Long.valueOf(target.getCreator())); if (creator != null) { respVO.setCreatorName(creator.getNickname()); } } // 实际达成与达成率 fillAchievement(target, respVO); return respVO; }); } /** * 计算实际达成金额/数量与达成率 */ private void fillAchievement(CrmSalesTargetDO target, CrmSalesTargetRespVO respVO) { if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) { // 产品维度:仅合同金额与合同数可按物料统计 BigDecimal contractPrice = achievementMapper.selectContractPriceByItemId(target.getTargetItemId(), target.getStartTime(), target.getEndTime()); Long contractCount = achievementMapper.selectContractCountByItemId(target.getTargetItemId(), target.getStartTime(), target.getEndTime()); respVO.setActualContractPrice(contractPrice); respVO.setActualContractCount(contractCount); respVO.setActualReceivablePrice(BigDecimal.ZERO); respVO.setActualBusinessPrice(BigDecimal.ZERO); respVO.setActualCustomerCount(0L); } else { Set userIds = resolveTargetUserIds(target); if (CollUtil.isEmpty(userIds)) { // 部门下无用户 respVO.setActualContractPrice(BigDecimal.ZERO); respVO.setActualContractCount(0L); respVO.setActualReceivablePrice(BigDecimal.ZERO); respVO.setActualBusinessPrice(BigDecimal.ZERO); respVO.setActualCustomerCount(0L); } else { respVO.setActualContractPrice(achievementMapper.selectContractPriceByUserIds(userIds, target.getStartTime(), target.getEndTime())); respVO.setActualContractCount(achievementMapper.selectContractCountByUserIds(userIds, target.getStartTime(), target.getEndTime())); respVO.setActualReceivablePrice(achievementMapper.selectReceivablePriceByUserIds(userIds, target.getStartTime(), target.getEndTime())); respVO.setActualBusinessPrice(achievementMapper.selectBusinessPriceByUserIds(userIds, target.getStartTime(), target.getEndTime())); respVO.setActualCustomerCount(achievementMapper.selectCustomerCountByUserIds(userIds, target.getStartTime(), target.getEndTime())); } } // 达成率 = 实际 / 目标 * 100 respVO.setContractRate(calcRate(respVO.getActualContractPrice(), target.getContractTarget())); respVO.setReceivableRate(calcRate(respVO.getActualReceivablePrice(), target.getReceivableTarget())); respVO.setBusinessRate(calcRate(respVO.getActualBusinessPrice(), target.getBusinessTarget())); respVO.setCustomerRate(calcRate(respVO.getActualCustomerCount(), target.getCustomerTarget())); respVO.setContractCountRate(calcRate(respVO.getActualContractCount(), target.getContractCountTarget())); } /** * 解析目标的统计用户集合:个人维度为单用户,部门维度为部门及子部门下所有用户 */ private Set resolveTargetUserIds(CrmSalesTargetDO target) { if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) { return target.getTargetUserId() == null ? Collections.emptySet() : Collections.singleton(target.getTargetUserId()); } // 部门维度:含子部门 List deptIds = new ArrayList<>(); deptIds.add(target.getTargetDeptId()); List childDepts = deptApi.getChildDeptList(target.getTargetDeptId()); if (CollUtil.isNotEmpty(childDepts)) { deptIds.addAll(convertList(childDepts, DeptRespDTO::getId)); } return convertSet(adminUserApi.getUserListByDeptIds(deptIds), AdminUserRespDTO::getId); } private BigDecimal calcRate(BigDecimal actual, BigDecimal target) { if (target == null || target.compareTo(BigDecimal.ZERO) <= 0) { return null; } return (actual == null ? BigDecimal.ZERO : actual) .multiply(HUNDRED).divide(target, 2, RoundingMode.HALF_UP); } private BigDecimal calcRate(Long actual, Integer target) { if (target == null || target <= 0) { return null; } return BigDecimal.valueOf(actual == null ? 0 : actual) .multiply(HUNDRED).divide(BigDecimal.valueOf(target), 2, RoundingMode.HALF_UP); } }