yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java
@@ -10,11 +10,13 @@
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.crm.api.contract.dto.CrmContractCreateFromSaleOrderReqDTO;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractImportExcelVO;
@@ -56,6 +58,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -272,6 +275,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_CONTRACT_TYPE, subType = CRM_CONTRACT_IMPORT_SUB_TYPE, bizNo = "0",
            success = CRM_CONTRACT_IMPORT_SUCCESS)
    public CrmContractImportRespVO importContractList(List<CrmContractImportExcelVO> importList) {
        CrmContractImportRespVO respVO = CrmContractImportRespVO.builder()
                .createList(new ArrayList<>())
@@ -279,6 +284,8 @@
                .failureList(new LinkedHashMap<>())
                .build();
        if (CollUtil.isEmpty(importList)) {
            // 空文件也要给日志文案兜底,避免 {{#importSummary}} 取不到值
            OperateLogUtils.putImportSummary(respVO.getCreateList(), respVO.getUpdateList(), respVO.getFailureList());
            return respVO;
        }
@@ -317,6 +324,8 @@
                respVO.getFailureList().put(label, resolveImportErrorMessage(ex));
            }
        }
        // 记录导入结果汇总,供 @LogRecord 的 success 文案引用
        OperateLogUtils.putImportSummary(respVO.getCreateList(), respVO.getUpdateList(), respVO.getFailureList());
        return respVO;
    }
@@ -546,6 +555,15 @@
        }
        // 2. 校验分类和流程定义是否存在
        // 全局免审批:业务校验通过后直接执行审批通过逻辑,无需流程模型。
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            contractMapper.updateById(new CrmContractDO().setId(id).setProcessInstanceId("")
                    .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
            updateContractAuditStatus(id, cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum.APPROVE.getStatus());
            LogRecordContext.putVariable("contractName", contract.getName());
            return;
        }
        validateContractApproveCategoryAndProcessDefinition();
        // 3. 校验传入的流程定义 Key 是否属于该分类且处于激活状态
@@ -721,6 +739,16 @@
    }
    @Override
    public Map<Long, Long> getContractCountMapByCustomerIds(Collection<Long> customerIds) {
        return contractMapper.selectContractCountMapByCustomerIds(customerIds);
    }
    @Override
    public Map<Long, BigDecimal> getContractPriceMapByCustomerIds(Collection<Long> customerIds) {
        return contractMapper.selectContractPriceMapByCustomerIds(customerIds);
    }
    @Override
    public List<CrmContractProductDO> getContractProductListByContractId(Long contactId) {
        return contractProductMapper.selectListByContractId(contactId);
    }
@@ -808,6 +836,73 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
    public CrmContractDO createContractDraftFromSaleOrder(CrmContractCreateFromSaleOrderReqDTO reqDTO) {
        // 1. 幂等:同一销售订单只生成一张合同
        CrmContractDO existed = contractMapper.selectByOrderId(reqDTO.getOrderId());
        if (existed != null) {
            log.info("[createContractDraftFromSaleOrder] 销售订单({}) 已生成合同({}),跳过创建",
                    reqDTO.getOrderNo(), existed.getNo());
            return existed;
        }
        // 2. 校验客户与负责人
        CrmCustomerDO customer = customerService.getCustomer(reqDTO.getCustomerId());
        if (customer == null) {
            throw new IllegalArgumentException("销售订单[" + reqDTO.getOrderNo()
                    + "]关联的客户不存在或已被删除,无法自动生成销售合同");
        }
        if (reqDTO.getOwnerUserId() == null) {
            throw new IllegalArgumentException("销售订单[" + reqDTO.getOrderNo()
                    + "]未填写销售员,无法确定自动生成销售合同的负责人");
        }
        if (CollUtil.isEmpty(reqDTO.getItems())) {
            throw new IllegalArgumentException("销售订单[" + reqDTO.getOrderNo()
                    + "]没有明细,无法自动生成销售合同");
        }
        // 3. 组装合同明细
        List<CrmContractProductDO> products = new ArrayList<>(reqDTO.getItems().size());
        for (CrmContractCreateFromSaleOrderReqDTO.Item item : reqDTO.getItems()) {
            CrmContractProductDO product = new CrmContractProductDO();
            product.setItemId(item.getItemId());
            product.setItemUnitId(item.getItemUnitId());
            product.setItemPrice(item.getPrice());
            product.setContractPrice(item.getPrice());
            product.setCount(item.getCount());
            product.setTotalPrice(MoneyUtils.priceMultiply(item.getPrice(), item.getCount()));
            products.add(product);
        }
        // 4. 组装合同(未提交草稿,不发起审批)
        CrmContractDO contract = new CrmContractDO();
        contract.setNo(noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX));
        contract.setName(customer.getName() + "-销售合同-" + reqDTO.getOrderNo());
        contract.setCustomerId(reqDTO.getCustomerId());
        contract.setOwnerUserId(reqDTO.getOwnerUserId());
        contract.setOrderDate(reqDTO.getOrderTime());
        contract.setDiscountPercent(reqDTO.getDiscountPercent() != null ? reqDTO.getDiscountPercent() : BigDecimal.ZERO);
        contract.setDepositPrice(reqDTO.getDepositPrice() != null ? reqDTO.getDepositPrice() : BigDecimal.ZERO);
        contract.setOrderId(reqDTO.getOrderId());
        contract.setOrderNo(reqDTO.getOrderNo());
        contract.setRemark("由销售订单【" + reqDTO.getOrderNo() + "】审批通过后自动生成");
        contract.setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        calculateTotalPrice(contract, products);
        // 5. 落库
        contractMapper.insert(contract);
        products.forEach(product -> product.setContractId(contract.getId()));
        contractProductMapper.insertBatch(products);
        // 6. 创建数据权限,保证负责人能看到该合同
        crmPermissionService.createPermission(new CrmPermissionCreateReqBO().setUserId(contract.getOwnerUserId())
                .setBizType(CrmBizTypeEnum.CRM_CONTRACT.getType()).setBizId(contract.getId())
                .setLevel(CrmPermissionLevelEnum.OWNER.getLevel()));
        log.info("[createContractDraftFromSaleOrder] 销售订单({}) 自动生成合同({}) 成功",
                reqDTO.getOrderNo(), contract.getNo());
        return contract;
    }
    @Override
    public void updateContractStatus(Long id, Integer status) {
        contractMapper.updateById(new CrmContractDO()
                .setId(id)