8 小时以前 086b2186789ea04362db4bce71c6112686c43caa
feat(crm): 支持合同编号自定义配置

- 新增合同编号字段的数据库验证和日志记录功能
- 实现合同新增时支持自定义编号,留空则自动生成
- 实现合同编辑时支持修改编号并校验唯一性
- 添加合同编号重复的错误码定义
- 更新数据字典文档中的合同表描述信息

fix(mes): 修复IPQC质检模板物料匹配逻辑

- 调整质检模板查询接口参数命名和注释说明
- 实现MES物料ID到MDM物料ID的自动转换
- 优化IPQC场景下的物料识别和模板匹配机制
- 移除冗余的物料对象依赖引入
已修改9个文件
107 ■■■■■ 文件已修改
docs/project-business/data/objects.json 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/template/MesQcTemplateItemDO.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/template/MesQcTemplateItemService.java 14 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/template/MesQcTemplateItemServiceImpl.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json
@@ -7,7 +7,7 @@
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号(新增/编辑均可自定义,留空则新增自动生成、编辑保持原编号),BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM 审批,累计开票≤合同额,附件必传", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 收款计划 receivable"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "年/季/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target 业绩"},
docs/project-business/index.html
@@ -442,7 +442,7 @@
    {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"},
    {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"},
    {"name": "报价单", "module": "销售管理", "table": "crm_sale_quotation", "doc": "QT编号,选 MDM 物料报价,AI OCR 录入,草稿→已转合同", "upstream": ["商机"], "downstream": ["合同"], "keywords": "报价 quotation QT"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号,BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "合同", "module": "销售管理", "table": "crm_contract", "doc": "HT编号(新增/编辑均可自定义,留空则新增自动生成、编辑保持原编号),BPM 审批(0草稿/10审批中/20通过/30驳回/40取消/50作废),通过后可生成销售订单", "upstream": ["报价单"], "downstream": ["销售订单", "回款计划", "开票"], "keywords": "合同 contract 审批"},
    {"name": "发票", "module": "销售管理", "table": "crm_invoice", "doc": "KP编号,BPM 审批,累计开票≤合同额,附件必传", "upstream": ["合同"], "downstream": ["回款"], "keywords": "开票 发票 invoice"},
    {"name": "回款", "module": "销售管理", "table": "crm_receivable", "doc": "HK编号,创建即审批中(手动审核),强制先开票,金额双校验", "upstream": ["合同", "发票"], "downstream": ["收款单(关联)"], "keywords": "回款 收款计划 receivable"},
    {"name": "销售目标", "module": "销售管理", "table": "crm_sales_target", "doc": "年/季/月×个人/部门/产品维度的合同回款等目标", "upstream": [], "downstream": ["业绩统计"], "keywords": "目标 target 业绩"},
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/vo/contract/CrmContractSaveReqVO.java
@@ -7,6 +7,7 @@
import com.mzt.logapi.starter.annotation.DiffLogField;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -30,6 +31,11 @@
    @NotNull(message = "合同名称不能为空")
    private String name;
    @Schema(description = "合同编号,留空则自动生成;新增和更新均可自定义,更新时留空则保持原编号", example = "HT20260921001")
    @DiffLogField(name = "合同编号")
    @Size(max = 100, message = "合同编号长度不能超过 100 个字符")
    private String no;
    @Schema(description = "客户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18336")
    @DiffLogField(name = "客户", function = CrmCustomerParseFunction.NAME)
    @NotNull(message = "客户编号不能为空")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -20,6 +20,7 @@
    ErrorCode CONTRACT_GENERATE_ORDER_FAIL_EXISTS_ORDER = new ErrorCode(1_020_000_007, "生成销售订单失败,原因:已关联销售订单【{}】");
    ErrorCode CONTRACT_GENERATE_ORDER_FAIL_NO_PRODUCT = new ErrorCode(1_020_000_008, "生成销售订单失败,原因:合同没有产品信息");
    ErrorCode CONTRACT_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_020_000_009, "合同审批流程不存在或未激活,请检查 BPM 配置");
    ErrorCode CONTRACT_NO_DUPLICATE = new ErrorCode(1_020_000_010, "合同编号【{}】已存在,请更换后重试");
    // ========== 线索管理 1-020-001-000 ==========
    ErrorCode CLUE_NOT_EXISTS = new ErrorCode(1_020_001_000, "线索不存在");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractServiceImpl.java
@@ -150,10 +150,15 @@
        List<CrmContractProductDO> contractProducts = validateContractProducts(createReqVO.getProducts());
        // 1.2 校验关联字段
        validateRelationDataExists(createReqVO);
        // 1.3 生成序号
        String no = noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX);
        if (contractMapper.selectByNo(no) != null) {
            throw exception(CONTRACT_NO_EXISTS);
        // 1.3 生成或校验合同编号:支持自定义编号,未填写时自动生成
        String no = StrUtil.trimToNull(createReqVO.getNo());
        if (no == null) {
            no = noRedisDAO.generate(CrmNoRedisDAO.CONTRACT_NO_PREFIX);
            if (contractMapper.selectByNo(no) != null) {
                throw exception(CONTRACT_NO_EXISTS);
            }
        } else if (contractMapper.selectByNo(no) != null) {
            throw exception(CONTRACT_NO_DUPLICATE, no);
        }
        // 2.1 插入合同
@@ -188,14 +193,26 @@
        updateReqVO.setOwnerUserId(null); // 不允许更新的字段
        // 1.1 校验存在
        CrmContractDO oldContract = validateContractExists(updateReqVO.getId());
        // 1.2 只有草稿、审批中,可以编辑;
        // 1.2 合同编号:留空则保持原编号,填写则校验唯一性(排除自身)
        //     留空时回填原编号而非置空,避免操作日志 diff 出现“编号被清空”
        String no = StrUtil.trimToNull(updateReqVO.getNo());
        if (no == null) {
            no = oldContract.getNo();
        } else {
            CrmContractDO noContract = contractMapper.selectByNo(no);
            if (noContract != null && !ObjUtil.equal(noContract.getId(), updateReqVO.getId())) {
                throw exception(CONTRACT_NO_DUPLICATE, no);
            }
        }
        updateReqVO.setNo(no);
        // 1.3 只有草稿、审批中,可以编辑;
        if (!ObjectUtils.equalsAny(oldContract.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(),
                CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(CONTRACT_UPDATE_FAIL_NOT_DRAFT);
        }
        // 1.3 校验产品项的有效性
        // 1.4 校验产品项的有效性
        List<CrmContractProductDO> contractProducts = validateContractProducts(updateReqVO.getProducts());
        // 1.4 校验关联字段
        // 1.5 校验关联字段
        validateRelationDataExists(updateReqVO);
        // 2.1 更新合同
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/qc/template/MesQcTemplateItemDO.java
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.qc.template;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -38,7 +37,7 @@
    /**
     * 产品物料编号
     *
     * 关联 {@link MesMdItemDO#getId()}
     * 关联 MDM 物料编号(mdm_item.id),非 MES 物料
     */
    private Long itemId;
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/ipqc/MesQcIpqcServiceImpl.java
@@ -131,8 +131,9 @@
        // 1.3 确定检验物料:有任务时取任务的物料,无任务时取工单的产品
        Long itemId = (ctx.task() != null) ? ctx.task().getItemId() : workOrder.getProductId();
        // 1.4 根据物料 + IPQC 类型查找可用质检方案(支持前端显式指定)
        // 工单产品/任务物料是 MES 物料,由模板服务内部转换为 MDM 物料后匹配,故传工单/任务 ID
        List<MesQcTemplateItemDO> templateItems = templateItemService.getTemplateItemList(
                itemId, MesQcTypeEnum.IPQC.getType(), null, null);
                null, MesQcTypeEnum.IPQC.getType(), createReqVO.getWorkOrderId(), createReqVO.getTaskId());
        if (CollUtil.isEmpty(templateItems)) {
            throw exception(QC_NO_TEMPLATE);
        }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/template/MesQcTemplateItemService.java
@@ -64,21 +64,21 @@
    /**
     * 根据产品物料 ID 和 QC 类型查找检验模板的产品关联(必须存在,否则抛异常)
     *
     * @param itemId 产品物料 ID
     * @param qcType 检测种类(枚举 {@link MesQcTypeEnum})
     * @param mdmItemId MDM 产品物料 ID(mdm_item.id)
     * @param qcType    检测种类(枚举 {@link MesQcTypeEnum})
     * @return 匹配的产品关联
     */
    MesQcTemplateItemDO getRequiredTemplateByItemIdAndType(Long itemId, Integer qcType);
    MesQcTemplateItemDO getRequiredTemplateByItemIdAndType(Long mdmItemId, Integer qcType);
    /**
     * 根据产品物料(或工单/任务)和 QC 类型,获取所有可用的质检方案产品关联列表
     *
     * @param itemId      产品物料 ID(mes_md_item.id);与 workOrderId/taskId 至少其一
     * @param mdmItemId   MDM 产品物料 ID(mdm_item.id);与 workOrderId/taskId 至少其一
     * @param qcType      检测种类(枚举 {@link MesQcTypeEnum})
     * @param workOrderId 生产工单 ID(可选,IPQC 场景用于解析产品物料)
     * @param taskId      生产任务 ID(可选,优先用于解析产品物料)
     * @param workOrderId 生产工单 ID(可选,IPQC 场景用于解析产品物料;工单产品为 MES 物料,内部转换为 MDM 物料)
     * @param taskId      生产任务 ID(可选,优先用于解析产品物料;任务物料为 MES 物料,内部转换为 MDM 物料)
     * @return 匹配的产品关联列表(保持原顺序),无匹配返回空列表
     */
    List<MesQcTemplateItemDO> getTemplateItemList(Long itemId, Integer qcType, Long workOrderId, Long taskId);
    List<MesQcTemplateItemDO> getTemplateItemList(Long mdmItemId, Integer qcType, Long workOrderId, Long taskId);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/template/MesQcTemplateItemServiceImpl.java
@@ -126,8 +126,8 @@
    }
    @Override
    public MesQcTemplateItemDO getRequiredTemplateByItemIdAndType(Long itemId, Integer qcType) {
        List<MesQcTemplateItemDO> templateItems = getTemplateItemList(itemId, qcType, null, null);
    public MesQcTemplateItemDO getRequiredTemplateByItemIdAndType(Long mdmItemId, Integer qcType) {
        List<MesQcTemplateItemDO> templateItems = getTemplateItemList(mdmItemId, qcType, null, null);
        if (CollUtil.isEmpty(templateItems)) {
            throw exception(QC_NO_TEMPLATE);
        }
@@ -135,28 +135,27 @@
    }
    @Override
    public List<MesQcTemplateItemDO> getTemplateItemList(Long itemId, Integer qcType, Long workOrderId, Long taskId) {
        // 1. 解析真实产品物料 ID:任务优先,其次工单,最后直接用入参
        Long resolvedItemId = itemId;
    public List<MesQcTemplateItemDO> getTemplateItemList(Long mdmItemId, Integer qcType, Long workOrderId, Long taskId) {
        // 1. 解析 MDM 产品物料 ID:任务优先,其次工单;两者存的是 MES 物料 ID,需转换为 MDM 物料 ID。
        //    未传工单/任务时,入参本身即 MDM 物料 ID(质检单前端选择的就是 MDM 物料)
        Long resolvedMdmItemId;
        if (taskId != null) {
            resolvedItemId = proTaskService.validateTaskExists(taskId).getItemId();
            resolvedMdmItemId = convertMesItemIdToMdmItemId(proTaskService.validateTaskExists(taskId).getItemId());
        } else if (workOrderId != null) {
            resolvedItemId = proWorkOrderService.validateWorkOrderExists(workOrderId).getProductId();
            resolvedMdmItemId = convertMesItemIdToMdmItemId(
                    proWorkOrderService.validateWorkOrderExists(workOrderId).getProductId());
        } else {
            resolvedMdmItemId = mdmItemId;
        }
        if (resolvedItemId == null) {
        if (resolvedMdmItemId == null) {
            return Collections.emptyList();
        }
        // 2. mes_md_item.id 转 mdm_item.id(产品关联表存的是 mdm 物料)
        MesMdItemDO item = mesMdItemService.getItem(resolvedItemId);
        if (item == null || item.getMdmItemId() == null) {
            return Collections.emptyList();
        }
        // 3. 查出该物料关联的所有产品关联
        List<MesQcTemplateItemDO> templateItems = templateItemMapper.selectListByItemId(item.getMdmItemId());
        // 2. 查出该物料关联的所有产品关联
        List<MesQcTemplateItemDO> templateItems = templateItemMapper.selectListByItemId(resolvedMdmItemId);
        if (CollUtil.isEmpty(templateItems)) {
            return Collections.emptyList();
        }
        // 4. 过滤:方案启用且检测种类包含 qcType,返回全部匹配项
        // 3. 过滤:方案启用且检测种类包含 qcType,返回全部匹配项
        List<MesQcTemplateDO> templates = templateService.getTemplateList(
                convertSet(templateItems, MesQcTemplateItemDO::getTemplateId));
        Set<Long> matchedTemplateIds = templates.stream()
@@ -169,4 +168,18 @@
                .collect(Collectors.toList());
    }
    /**
     * MES 物料 ID 转 MDM 物料 ID
     *
     * @param mesItemId MES 物料 ID(mes_md_item.id)
     * @return MDM 物料 ID(mdm_item.id);MES 物料不存在或未关联 MDM 物料时返回 null
     */
    private Long convertMesItemIdToMdmItemId(Long mesItemId) {
        if (mesItemId == null) {
            return null;
        }
        MesMdItemDO item = mesMdItemService.getItem(mesItemId);
        return item != null ? item.getMdmItemId() : null;
    }
}