feat(crm): 支持合同编号自定义配置
- 新增合同编号字段的数据库验证和日志记录功能
- 实现合同新增时支持自定义编号,留空则自动生成
- 实现合同编辑时支持修改编号并校验唯一性
- 添加合同编号重复的错误码定义
- 更新数据字典文档中的合同表描述信息
fix(mes): 修复IPQC质检模板物料匹配逻辑
- 调整质检模板查询接口参数命名和注释说明
- 实现MES物料ID到MDM物料ID的自动转换
- 优化IPQC场景下的物料识别和模板匹配机制
- 移除冗余的物料对象依赖引入
| | |
| | | {"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 业绩"}, |
| | |
| | | {"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 业绩"}, |
| | |
| | | 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; |
| | |
| | | @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 = "客户编号不能为空") |
| | |
| | | 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, "线索不存在"); |
| | |
| | | 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 插入合同 |
| | |
| | | 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 更新合同 |
| | |
| | | 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; |
| | |
| | | /** |
| | | * 产品物料编号 |
| | | * |
| | | * 关联 {@link MesMdItemDO#getId()} |
| | | * 关联 MDM 物料编号(mdm_item.id),非 MES 物料 |
| | | */ |
| | | private Long itemId; |
| | | /** |
| | |
| | | // 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); |
| | | } |
| | |
| | | /** |
| | | * 根据产品物料 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); |
| | | |
| | | } |
| | |
| | | } |
| | | |
| | | @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); |
| | | } |
| | |
| | | } |
| | | |
| | | @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() |
| | |
| | | .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; |
| | | } |
| | | |
| | | } |