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;
    }
@@ -327,46 +336,58 @@
        CrmContractImportExcelVO head = rows.get(0);
        // 1. 主表必填
        if (StrUtil.isBlank(head.getName())) {
            throw new IllegalArgumentException("合同名称不能为空");
            throw new IllegalArgumentException("合同名称不能为空:请填写模板中该行的「合同名称」列;"
                    + "若同一张合同的多行使用了合并单元格,需填写在合并区域的首行");
        }
        if (StrUtil.isBlank(head.getCustomerName())) {
            throw new IllegalArgumentException("客户名称不能为空");
            throw new IllegalArgumentException("客户名称不能为空:请填写模板中该行的「客户名称」列;"
                    + "若同一张合同的多行使用了合并单元格,需填写在合并区域的首行");
        }
        Long customerId = customerIdMap.get(head.getCustomerName().trim());
        if (customerId == null) {
            throw new IllegalArgumentException("客户不存在:" + head.getCustomerName());
            throw new IllegalArgumentException("客户不存在:" + head.getCustomerName()
                    + "。请先在「CRM 客户管理」中创建该客户(名称需与模板完全一致),或修改模板中的客户名称后重新导入");
        }
        customerService.validateCustomer(customerId);
        // 2. 明细
        List<CrmContractSaveReqVO.Product> products = new ArrayList<>();
        List<CrmContractProductDO> productDOs = new ArrayList<>();
        for (CrmContractImportExcelVO row : rows) {
            if (StrUtil.isBlank(row.getProductCode())) {
                throw new IllegalArgumentException("产品编码不能为空");
                throw new IllegalArgumentException("产品编码不能为空:请填写模板中该行的「产品编码」列");
            }
            CommonResult<MdmItemRespDTO> itemResult = mdmItemApi.getItemByCode(row.getProductCode().trim());
            MdmItemRespDTO item = itemResult == null ? null : itemResult.getData();
            if (item == null) {
                throw new IllegalArgumentException("产品不存在:" + row.getProductCode());
                throw new IllegalArgumentException("产品不存在:" + row.getProductCode()
                        + "。请先在「物料管理」中维护该物料(编码需与模板完全一致),或修改模板中的产品编码后重新导入");
            }
            if (row.getCount() == null || row.getCount() <= 0) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的数量必须为正整数");
            if (row.getCount() == null) {
                throw new IllegalArgumentException("产品[" + row.getProductCode()
                        + "]的数量不能为空:请填写模板中该行的「数量」列");
            }
            if (row.getCount().compareTo(BigDecimal.ZERO) <= 0) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的数量必须大于 0,当前为 "
                        + row.getCount().stripTrailingZeros().toPlainString() + "。请修改模板后重新导入");
            }
            BigDecimal contractPrice = row.getContractPrice() != null ? row.getContractPrice()
                    : (item.getSalesPrice() != null ? item.getSalesPrice() : BigDecimal.ZERO);
            CrmContractSaveReqVO.Product product = new CrmContractSaveReqVO.Product();
            CrmContractProductDO product = new CrmContractProductDO();
            product.setItemId(item.getId());
            product.setItemUnitId(item.getUnitMeasureId());
            product.setItemPrice(item.getSalesPrice() != null ? item.getSalesPrice() : contractPrice);
            product.setContractPrice(contractPrice);
            product.setCount(row.getCount());
            products.add(product);
            product.setTotalPrice(MoneyUtils.priceMultiply(contractPrice, row.getCount()));
            productDOs.add(product);
        }
        // 3. 合同编号
        String no = StrUtil.isNotBlank(head.getNo()) ? head.getNo().trim()
                : noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX);
        if (contractMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("合同编号已存在:" + no);
            throw new IllegalArgumentException("合同编号已存在:" + no
                    + "。请修改模板中的合同编号,或先在系统中删除该编号的合同后重新导入");
        }
        // 4. 组装主表(负责人=登录用户;状态=未提交)
@@ -384,7 +405,6 @@
        contract.setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        // 5. 明细落库 + 自动算价
        List<CrmContractProductDO> productDOs = validateContractProducts(products);
        calculateTotalPrice(contract, productDOs);
        contractMapper.insert(contract);
        productDOs.forEach(product -> product.setContractId(contract.getId()));
@@ -472,7 +492,9 @@
        if (quotation != null) {
            crmSaleQuotationService.clearContractReference(quotation.getId());
        }
        // 2.2 删除数据权限
        // 2.3 删除合同产品明细
        contractProductMapper.delete(CrmContractProductDO::getContractId, id);
        // 2.4 删除数据权限
        crmPermissionService.deletePermission(CrmBizTypeEnum.CRM_CONTRACT.getType(), id);
        // 3. 记录操作日志上下文
@@ -533,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 是否属于该分类且处于激活状态
@@ -644,6 +675,11 @@
    }
    @Override
    public CrmContractDO getContractByNo(String no) {
        return contractMapper.selectByNo(no);
    }
    @Override
    public CrmContractDO validateContract(Long id) {
        return validateContractExists(id);
    }
@@ -700,6 +736,16 @@
    @Override
    public Long getContractCountByBusinessId(Long businessId) {
        return contractMapper.selectCountByBusinessId(businessId);
    }
    @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
@@ -790,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)