feat: 客户签订合同统计 + MES 首页质检合格率
- CRM 客户列表实时统计已签订(审核通过)合同数量与金额合计
- 客户列表重点客户(A)排前
- MES 首页新增来料/过程/出货质检合格率与检验数量统计
- 同步业务可视化文档
Co-Authored-By: Claude Code <noreply@anthropic.com>
| | |
| | | "objects": [ |
| | | {"name": "物料", "module": "基础主数据", "table": "mdm_item", "doc": "MDM 权威物料/产品/半成品档案,含 SKU、批次属性、价格、安全库存", "upstream": ["MES 物料反向同步"], "downstream": ["报价单", "销售订单", "采购订单", "工单BOM", "库存台账"], "keywords": "物料 产品 原材料 item"}, |
| | | {"name": "仓库", "module": "基础主数据", "table": "mdm_warehouse", "doc": "仓库主数据,type: 1原料/2成品/3半成品/4线边;MES 自有 mes_wm_warehouse 承载库区库位", "upstream": [], "downstream": ["库存台账", "出入库单据"], "keywords": "仓库 warehouse 原料仓 成品仓 线边仓"}, |
| | | {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"}, |
| | | {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限;客户列表按「审核通过」合同实时统计已签订合同份数与金额合计", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"}, |
| | | {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"}, |
| | | {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"}, |
| | | {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"}, |
| | |
| | | "objects": [ |
| | | {"name": "物料", "module": "基础主数据", "table": "mdm_item", "doc": "MDM 权威物料/产品/半成品档案,含 SKU、批次属性、价格、安全库存", "upstream": ["MES 物料反向同步"], "downstream": ["报价单", "销售订单", "采购订单", "工单BOM", "库存台账"], "keywords": "物料 产品 原材料 item"}, |
| | | {"name": "仓库", "module": "基础主数据", "table": "mdm_warehouse", "doc": "仓库主数据,type: 1原料/2成品/3半成品/4线边;MES 自有 mes_wm_warehouse 承载库区库位", "upstream": [], "downstream": ["库存台账", "出入库单据"], "keywords": "仓库 warehouse 原料仓 成品仓 线边仓"}, |
| | | {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"}, |
| | | {"name": "客户", "module": "销售管理", "table": "crm_customer", "doc": "客户主档,公海池/锁定/成交状态,负责人数据权限;客户列表按「审核通过」合同实时统计已签订合同份数与金额合计", "upstream": ["线索"], "downstream": ["联系人", "商机", "报价单", "合同", "售后工单", "收款单"], "keywords": "客户 customer 公海"}, |
| | | {"name": "线索", "module": "销售管理", "table": "crm_clue", "doc": "原始销售线索,仅一次转化为客户", "upstream": [], "downstream": ["客户"], "keywords": "线索 clue"}, |
| | | {"name": "联系人", "module": "销售管理", "table": "crm_contact", "doc": "客户下联系人,与商机多对多", "upstream": ["客户"], "downstream": ["商机"], "keywords": "联系人 contact"}, |
| | | {"name": "商机", "module": "销售管理", "table": "crm_business", "doc": "销售机会,DB 配置式阶段推进,结束态: 赢/输/无效", "upstream": ["客户"], "downstream": ["报价单", "合同"], "keywords": "商机 business 赢单 输单 阶段"}, |
| | |
| | | import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.customer.*; |
| | | import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; |
| | | import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerPoolConfigDO; |
| | | import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; |
| | | import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerPoolConfigService; |
| | | import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; |
| | | import cn.iocoder.yudao.module.system.api.dept.DeptApi; |
| | |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import java.io.IOException; |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Arrays; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.stream.Stream; |
| | | |
| | | import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; |
| | |
| | | private CrmCustomerService customerService; |
| | | @Resource |
| | | private CrmCustomerPoolConfigService customerPoolConfigService; |
| | | @Resource |
| | | private CrmContractService contractService; |
| | | |
| | | @Resource |
| | | private DeptApi deptApi; |
| | |
| | | Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); |
| | | // 1.2 获取距离进入公海的时间 |
| | | Map<Long, Long> poolDayMap = getPoolDayMap(list); |
| | | // 1.3 获取「已签订合同」的数量与金额合计(按本页客户批量统计,审核通过的合同才算) |
| | | Set<Long> customerIds = convertSet(list, CrmCustomerDO::getId); |
| | | Map<Long, Long> contractCountMap = contractService.getContractCountMapByCustomerIds(customerIds); |
| | | Map<Long, BigDecimal> contractPriceMap = contractService.getContractPriceMapByCustomerIds(customerIds); |
| | | // 2. 转换成 VO |
| | | return BeanUtils.toBean(list, CrmCustomerRespVO.class, customerVO -> { |
| | | customerVO.setAreaName(AreaUtils.format(customerVO.getAreaId())); |
| | |
| | | if (customerVO.getOwnerUserId() != null) { |
| | | customerVO.setPoolDay(poolDayMap.get(customerVO.getId())); |
| | | } |
| | | // 2.3 设置已签订合同的数量与金额合计(没有合同的客户:数量 0、金额 null,前端显示「—」) |
| | | customerVO.setContractCount( |
| | | contractCountMap.getOrDefault(customerVO.getId(), 0L).intValue()); |
| | | customerVO.setContractTotalPrice(contractPriceMap.get(customerVO.getId())); |
| | | }); |
| | | } |
| | | |
| | |
| | | import io.swagger.v3.oas.annotations.media.Schema; |
| | | import lombok.Data; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | |
| | | @Schema(description = "管理后台 - CRM 客户 Response VO") |
| | |
| | | @Schema(description = "距离加入公海时间", example = "1") |
| | | private Long poolDay; |
| | | |
| | | // ========== 签订合同(由 crm_contract 的审核通过合同实时统计,非客户表字段) ========== |
| | | |
| | | @Schema(description = "已签订合同数量(审批状态为审核通过),没有则为 0", example = "2") |
| | | private Integer contractCount; |
| | | |
| | | @Schema(description = "已签订合同金额合计(审批状态为审核通过),没有则为 null", example = "3235289.45") |
| | | private BigDecimal contractTotalPrice; |
| | | |
| | | } |
| | |
| | | import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; |
| | | import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; |
| | | import cn.iocoder.yudao.module.crm.util.CrmPermissionUtils; |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Collection; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| | | |
| | | /** |
| | | * CRM 合同 Mapper |
| | |
| | | .reduce(BigDecimal.ZERO, BigDecimal::add); |
| | | } |
| | | |
| | | // ========== 按客户批量统计(客户列表展示「签订合同」) ========== |
| | | |
| | | /** |
| | | * 批量统计各客户「已签订」(审核通过)的合同数量 |
| | | * |
| | | * @param customerIds 客户编号集合 |
| | | * @return key:客户编号,value:合同数量。没有合同的客户不在 map 中 |
| | | */ |
| | | default Map<Long, Long> selectContractCountMapByCustomerIds(Collection<Long> customerIds) { |
| | | if (CollUtil.isEmpty(customerIds)) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | List<Map<String, Object>> result = selectMaps(new QueryWrapper<CrmContractDO>() |
| | | .select("customer_id, COUNT(*) AS total") |
| | | .eq("audit_status", CrmAuditStatusEnum.APPROVE.getStatus()) |
| | | .groupBy("customer_id") |
| | | .in("customer_id", customerIds)); |
| | | return convertMap(result, obj -> (Long) obj.get("customer_id"), |
| | | obj -> ((Number) obj.get("total")).longValue()); |
| | | } |
| | | |
| | | /** |
| | | * 批量统计各客户「已签订」(审核通过)的合同金额合计 |
| | | * |
| | | * @param customerIds 客户编号集合 |
| | | * @return key:客户编号,value:金额合计。没有合同的客户不在 map 中 |
| | | */ |
| | | default Map<Long, BigDecimal> selectContractPriceMapByCustomerIds(Collection<Long> customerIds) { |
| | | if (CollUtil.isEmpty(customerIds)) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | // 金额用 IFNULL 兜底:convertMap 内部是 Collectors.toMap,value 为 null 会抛 NPE |
| | | List<Map<String, Object>> result = selectMaps(new QueryWrapper<CrmContractDO>() |
| | | .select("customer_id, IFNULL(SUM(total_price), 0) AS total_price") |
| | | .eq("audit_status", CrmAuditStatusEnum.APPROVE.getStatus()) |
| | | .groupBy("customer_id") |
| | | .in("customer_id", customerIds)); |
| | | return convertMap(result, obj -> (Long) obj.get("customer_id"), |
| | | obj -> (BigDecimal) obj.get("total_price")); |
| | | } |
| | | |
| | | } |
| | |
| | | throw new IllegalArgumentException("未知联系状态:" + pageReqVO.getContactStatus()); |
| | | } |
| | | } |
| | | // 重点客户(A/level=1)默认排在前面 |
| | | query.orderByAsc(CrmCustomerDO::getLevel); |
| | | return selectJoinPage(pageReqVO, CrmCustomerDO.class, query); |
| | | } |
| | | |
| | |
| | | import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; |
| | | import jakarta.validation.Valid; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Collection; |
| | | import java.util.List; |
| | |
| | | Long getContractCountByCustomerId(Long customerId); |
| | | |
| | | /** |
| | | * 批量统计各客户「已签订」的合同数量 |
| | | * |
| | | * 「已签订」= 审批状态为审核通过的合同 |
| | | * |
| | | * @param customerIds 客户编号集合 |
| | | * @return key:客户编号,value:合同数量。没有已签订合同的客户不在 map 中 |
| | | */ |
| | | Map<Long, Long> getContractCountMapByCustomerIds(Collection<Long> customerIds); |
| | | |
| | | /** |
| | | * 批量统计各客户「已签订」的合同金额合计 |
| | | * |
| | | * 「已签订」= 审批状态为审核通过的合同 |
| | | * |
| | | * @param customerIds 客户编号集合 |
| | | * @return key:客户编号,value:金额合计。没有已签订合同的客户不在 map 中 |
| | | */ |
| | | Map<Long, BigDecimal> getContractPriceMapByCustomerIds(Collection<Long> customerIds); |
| | | |
| | | /** |
| | | * 根据商机编号,获取关联客户的合同数量 |
| | | * |
| | | * @param 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 |
| | | public List<CrmContractProductDO> getContractProductListByContractId(Long contactId) { |
| | | return contractProductMapper.selectListByContractId(contactId); |
| | | } |
| | |
| | | import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum; |
| | | import cn.iocoder.yudao.module.crm.enums.common.CrmSceneTypeEnum; |
| | | import cn.iocoder.yudao.module.crm.controller.admin.customer.CrmCustomerController; |
| | | import cn.iocoder.yudao.module.crm.service.contract.CrmContractService; |
| | | import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| | | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| | | import cn.iocoder.yudao.module.system.api.dept.DeptApi; |
| | |
| | | })); |
| | | var config = CrmCustomerPoolConfigService.class.cast(Proxy.newProxyInstance(getClass().getClassLoader(), |
| | | new Class<?>[]{CrmCustomerPoolConfigService.class}, (proxy, method, args) -> null)); |
| | | // 客户列表拼接会顺带统计「已签订合同」,这里桩掉,返回空 Map 表示本用例的客户没有合同 |
| | | var contracts = CrmContractService.class.cast(Proxy.newProxyInstance(getClass().getClassLoader(), |
| | | new Class<?>[]{CrmContractService.class}, (proxy, method, args) -> |
| | | switch (method.getName()) { |
| | | case "getContractCountMapByCustomerIds", "getContractPriceMapByCustomerIds" -> Map.of(); |
| | | default -> java.lang.reflect.InvocationHandler.invokeDefault(proxy, method, args); |
| | | })); |
| | | ReflectionTestUtils.setField(controller, "adminUserApi", users); |
| | | ReflectionTestUtils.setField(controller, "deptApi", departments); |
| | | ReflectionTestUtils.setField(controller, "customerPoolConfigService", config); |
| | | ReflectionTestUtils.setField(controller, "contractService", contracts); |
| | | var customer = controller.buildCustomerDetailList(List.of(new CrmCustomerDO().setId(1L) |
| | | .setOwnerUserId(10L).setAutoPoolEnabled(true))).getFirst(); |
| | | assertEquals(10L, customer.getOwnerUserId()); |
| | |
| | | @Schema(description = "今日不良品数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "34") |
| | | private BigDecimal todayUnqualifiedQuantity; |
| | | |
| | | @Schema(description = "来料检验合格率(%),无检验数据时为 null", example = "99.93") |
| | | private BigDecimal iqcQualifiedRate; |
| | | |
| | | @Schema(description = "过程检验合格率(%),无检验数据时为 null", example = "99.97") |
| | | private BigDecimal ipqcQualifiedRate; |
| | | |
| | | @Schema(description = "出货检验合格率(%),无检验数据时为 null", example = "100.00") |
| | | private BigDecimal oqcQualifiedRate; |
| | | |
| | | @Schema(description = "来料检验的检验数量合计(合格率的分母),无数据时为 0", example = "16782") |
| | | private Long iqcCheckQuantity; |
| | | |
| | | @Schema(description = "过程检验的检验数量合计(合格率的分母),无数据时为 0", example = "12485") |
| | | private Long ipqcCheckQuantity; |
| | | |
| | | @Schema(description = "出货检验的检验数量合计(合格率的分母),无数据时为 0", example = "2389") |
| | | private Long oqcCheckQuantity; |
| | | |
| | | // ========== 设备统计 ========== |
| | | |
| | | @Schema(description = "设备总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") |
| | |
| | | Long selectRepairActiveCount(); |
| | | |
| | | /** |
| | | * 按检验类型统计质检单的检验量与合格量 |
| | | * |
| | | * 不过滤单据状态(草稿与已完成都算),也不做时间范围限制(全量累计)。 |
| | | * |
| | | * @return [{ "type": "IQC", "checkQuantity": 16782, "qualifiedQuantity": 16770 }, ...] |
| | | */ |
| | | List<Map<String, Object>> selectQcQualifiedSummary(); |
| | | |
| | | /** |
| | | * 按天聚合报工产量(近 N 天) |
| | | * |
| | | * @param beginTime >= 开始时间 |
| | |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.format.DateTimeFormatter; |
| | |
| | | summary.setAndonActiveCount(homeStatisticsMapper.selectAndonActiveCount()); |
| | | summary.setRepairActiveCount(homeStatisticsMapper.selectRepairActiveCount()); |
| | | |
| | | // 6. 质检合格率(来料 / 过程 / 出货) |
| | | // 数量口径 = 合格数量 / 检验数量;不过滤单据状态,也不做时间范围限制(全量累计) |
| | | Map<String, Map<String, BigDecimal>> qcSummary = new HashMap<>(); |
| | | for (Map<String, Object> row : homeStatisticsMapper.selectQcQualifiedSummary()) { |
| | | Map<String, BigDecimal> metrics = new HashMap<>(); |
| | | metrics.put("checkQuantity", getDecimalFromObj(row.get("checkQuantity"))); |
| | | metrics.put("qualifiedQuantity", getDecimalFromObj(row.get("qualifiedQuantity"))); |
| | | qcSummary.put(String.valueOf(row.get("type")), metrics); |
| | | } |
| | | summary.setIqcQualifiedRate(calcQcQualifiedRate(qcSummary.get("IQC"))); |
| | | summary.setIpqcQualifiedRate(calcQcQualifiedRate(qcSummary.get("IPQC"))); |
| | | summary.setOqcQualifiedRate(calcQcQualifiedRate(qcSummary.get("OQC"))); |
| | | // 三个环节各自的检验数量合计(合格率的分母),供卡片副行展示样本量 |
| | | summary.setIqcCheckQuantity(calcCheckQuantity(qcSummary.get("IQC"))); |
| | | summary.setIpqcCheckQuantity(calcCheckQuantity(qcSummary.get("IPQC"))); |
| | | summary.setOqcCheckQuantity(calcCheckQuantity(qcSummary.get("OQC"))); |
| | | |
| | | return summary; |
| | | } |
| | | |
| | |
| | | return new BigDecimal(obj.toString()); |
| | | } |
| | | |
| | | /** |
| | | * 计算质检合格率(百分比,保留两位小数) |
| | | * |
| | | * 检验数量为 0(即没有已完成的质检单)时返回 null, |
| | | * 让页面显示「—」,而不是误导性的 0%。 |
| | | */ |
| | | private BigDecimal calcQcQualifiedRate(Map<String, BigDecimal> qcSummary) { |
| | | if (qcSummary == null) { |
| | | return null; |
| | | } |
| | | BigDecimal checkedQuantity = getDecimal(qcSummary, "checkQuantity"); |
| | | if (checkedQuantity.compareTo(BigDecimal.ZERO) <= 0) { |
| | | return null; |
| | | } |
| | | return getDecimal(qcSummary, "qualifiedQuantity") |
| | | .multiply(BigDecimal.valueOf(100)) |
| | | .divide(checkedQuantity, 2, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * 取该检验环节的检验数量合计(合格率的分母,即卡片副行展示的样本量) |
| | | * |
| | | * 该环节没有任何质检单时返回 0。 |
| | | */ |
| | | private Long calcCheckQuantity(Map<String, BigDecimal> qcSummary) { |
| | | if (qcSummary == null) { |
| | | return 0L; |
| | | } |
| | | return getDecimal(qcSummary, "checkQuantity").longValue(); |
| | | } |
| | | |
| | | } |
| | |
| | | ${@cn.iocoder.yudao.module.mes.enums.dv.MesDvRepairStatusEnum@APPROVING.status}) |
| | | </select> |
| | | |
| | | <!-- 按检验类型统计质检单的检验量与合格量(不过滤状态,无时间范围限制) --> |
| | | <select id="selectQcQualifiedSummary" resultType="java.util.Map"> |
| | | SELECT 'IQC' AS type, |
| | | IFNULL(SUM(check_quantity), 0) AS checkQuantity, |
| | | IFNULL(SUM(qualified_quantity), 0) AS qualifiedQuantity |
| | | FROM mes_qc_iqc |
| | | WHERE deleted = 0 |
| | | UNION ALL |
| | | SELECT 'IPQC', |
| | | IFNULL(SUM(check_quantity), 0), |
| | | IFNULL(SUM(qualified_quantity), 0) |
| | | FROM mes_qc_ipqc |
| | | WHERE deleted = 0 |
| | | UNION ALL |
| | | SELECT 'OQC', |
| | | IFNULL(SUM(check_quantity), 0), |
| | | IFNULL(SUM(qualified_quantity), 0) |
| | | FROM mes_qc_oqc |
| | | WHERE deleted = 0 |
| | | </select> |
| | | |
| | | <!-- 按天聚合报工产量 --> |
| | | <select id="selectDailyFeedbackTrend" resultType="java.util.Map"> |
| | | SELECT DATE_FORMAT(feedback_time, '%Y-%m-%d') AS date, |