云
2 天以前 87733eb3623593da247c051593d64b8ff9e1d3a4
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -5,12 +5,21 @@
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedReturnQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.*;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
@@ -31,6 +40,8 @@
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.qc.approver.MesQcApproverService;
import cn.iocoder.yudao.module.mes.service.wm.materialstockbag.MesWmMaterialStockBagService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesLineService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
@@ -41,10 +52,15 @@
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@@ -55,11 +71,23 @@
@Service @Validated @Slf4j
public class MesQcSeedQualityServiceImpl implements MesQcSeedQualityService {
    private static final Set<String> REQUIRED = Set.of("purity", "cleanliness", "germination_rate", "moisture");
    private static final int MAX_BATCH_SIZE = 500, WRITE_BATCH_SIZE = 100;
    private static final int WAIT_RETEST = 10, WAIT_PROCESS = 20, WAIT_INITIAL_FAIL_REVIEW = 25, WAIT_COMPANY = 30,
            RELEASED = 40, WAIT_INITIAL_RECHECK = 50, WAIT_COMPANY_FAIL = 60, SCRAPPED = 70;
    /** 质检线:1 加工部;2 销售部 */
    private static final int QC_LINE_PROCESS = 1, QC_LINE_SALES = 2;
    /** 业务类型:1 生产入库质检;2 经销商退货质检 */
    private static final int BIZ_TYPE_PRODUCTION = 1, BIZ_TYPE_RETURN = 2;
    /** 本线领导审批流程定义标识 */
    private static final String LINE_PROCESS_KEY = "mes_seed_quality_line_approve";
    /** 公司质检审批流程定义标识 */
    private static final String COMPANY_PROCESS_KEY = "mes_seed_quality_company_approve";
    /** 经销商退货质检审批流程定义标识 */
    private static final String RETURN_PROCESS_KEY = "mes_seed_return_approve";
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private BpmProcessInstanceApi processInstanceApi;
    @Resource private MesWmReturnSalesService returnSalesService;
    @Resource private MesWmReturnSalesLineService returnSalesLineService;
    @Resource private MesQcApproverService approverService;
    @Resource private MesQcSeedQualityActionMapper actionMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
@@ -75,14 +103,93 @@
    @Resource private MesWmWarehouseService warehouseService;
    @Resource private MesWmWarehouseLocationService warehouseLocationService;
    @Resource private MesWmWarehouseAreaService warehouseAreaService;
    @Resource private MesMdmItemService mdmItemService;
    @Resource private MesQcSeedIndicatorRangeService rangeService;
    @Override
    public PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        return qualityMapper.selectPage(reqVO);
    }
    @Override
    public PageResult<MesQcSeedQualityBatchRespVO> getQualityBatchPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
        int pageSize = reqVO.getPageSize() == null || reqVO.getPageSize() < 1 ? 10 : Math.min(reqVO.getPageSize(), 100);
        long total = qualityMapper.countBatchGroups(reqVO);
        if (total == 0) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        List<MesQcSeedQualityBatchRespVO> list = qualityMapper.selectBatchGroups(reqVO, (long) (pageNo - 1) * pageSize, pageSize)
                .stream().map(this::toBatchRespVO).collect(Collectors.toList());
        // 回填批次与品种信息
        Set<Long> batchIds = list.stream().map(MesQcSeedQualityBatchRespVO::getBatchId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesProBatchDO> batchMap = batchIds.isEmpty() ? Collections.emptyMap()
                : batchMapper.selectBatchIds(batchIds).stream()
                .collect(Collectors.toMap(MesProBatchDO::getId, Function.identity(), (first, ignored) -> first));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(list.stream()
                .map(MesQcSeedQualityBatchRespVO::getItemId).filter(Objects::nonNull).collect(Collectors.toSet()));
        for (MesQcSeedQualityBatchRespVO vo : list) {
            vo.setBatchLabel(StrUtil.blankToDefault(vo.getBatchCode(), "未绑定批次"));
            MesProBatchDO batch = batchMap.get(vo.getBatchId());
            if (batch != null) {
                vo.setLineName(batch.getLineName());
                vo.setProduceDate(batch.getProduceDate());
            }
            MesMdmItemDO item = itemMap.get(vo.getItemId());
            if (item != null) {
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
            }
        }
        return new PageResult<>(list, total);
    }
    private void resolveBagScanCode(MesQcSeedQualityPageReqVO reqVO) {
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(reqVO.getCode());
            if (bag != null) reqVO.setCode(bag.getCode());
            if (bag != null) {
                reqVO.setCode(bag.getCode());
            }
        }
        return qualityMapper.selectPage(reqVO);
    }
    private MesQcSeedQualityBatchRespVO toBatchRespVO(Map<String, Object> row) {
        MesQcSeedQualityBatchRespVO vo = new MesQcSeedQualityBatchRespVO();
        vo.setBatchCode((String) row.get("batchCode"));
        vo.setBatchId(toLong(row.get("batchId")));
        vo.setItemId(toLong(row.get("itemId")));
        vo.setTotalCount(toInt(row.get("totalCount")));
        vo.setPassCount(toInt(row.get("passCount")));
        vo.setFailCount(toInt(row.get("failCount")));
        vo.setProcessingCount(toInt(row.get("processingCount")));
        vo.setPendingRetestCount(toInt(row.get("pendingRetestCount")));
        vo.setPendingProcessReviewCount(toInt(row.get("pendingProcessReviewCount")));
        vo.setPendingCompanyReviewCount(toInt(row.get("pendingCompanyReviewCount")));
        vo.setReleasedCount(toInt(row.get("releasedCount")));
        vo.setScrappedCount(toInt(row.get("scrappedCount")));
        vo.setLastInspectedAt(toLocalDateTime(row.get("lastInspectedAt")));
        return vo;
    }
    private static Long toLong(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private static Integer toInt(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
    private static LocalDateTime toLocalDateTime(Object value) {
        if (value instanceof LocalDateTime dateTime) {
            return dateTime;
        }
        if (value instanceof Timestamp timestamp) {
            return timestamp.toLocalDateTime();
        }
        return null;
    }
    @Override
@@ -95,9 +202,28 @@
        result.put("batchCode", bag.getBatchCode());
        result.put("inbound", bag.getInboundEventId() != null);
        result.put("canceled", Integer.valueOf(30).equals(bag.getStatus()));
        MesQcSeedQualityDO quality = qualityMapper.selectByCode(bag.getCode());
        Long itemId = bag.getItemId();
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(bag.getCode());
        MesQcSeedQualityDO quality = qualities.stream()
                .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        result.put("qualityLines", qualities.stream()
                .filter(q -> !isReturnQuality(q))
                .map(q -> {
                    Map<String, Object> line = new HashMap<>();
                    line.put("id", q.getId());
                    line.put("qcLine", q.getQcLine());
                    line.put("workflowStatus", q.getWorkflowStatus());
                    return line;
                })
                .toList());
        return result;
    }
@@ -127,9 +253,17 @@
    @Override
    public Map<String, Object> resolveScan(String code, String scanType) {
        return resolveScan(code, scanType, QC_LINE_PROCESS);
    }
    @Override
    public Map<String, Object> resolveScan(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        Scope scope = scope(code, scanType, false);
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        // 一次提交生成两条质检线记录,任一已存在即视为该袋已完成初检。
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .filter(q -> !isReturnQuality(q))
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        Map<String, Object> result = new HashMap<>();
        result.put("scanType", scanType); result.put("code", scope.code()); result.put("businessCode", scope.businessCode());
@@ -138,9 +272,19 @@
        result.put("inbound", !effective.isEmpty() && effective.stream().allMatch(b -> b.getInboundEventId() != null));
        result.put("pendingBagIds", effective.stream().filter(b -> b.getInboundEventId() != null && !inspected.contains(b.getCode())).map(MesProBagCodeDO::getId).sorted().toList());
        result.put("completedBagIds", effective.stream().filter(b -> inspected.contains(b.getCode())).map(MesProBagCodeDO::getId).sorted().toList());
        Long itemId = resolveItemId(scope);
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        if ("BAG".equals(scanType)) {
            MesQcSeedQualityDO quality = qualityMapper.selectByCode(scope.code());
            result.put("qualityId", quality == null ? null : quality.getId()); result.put("result", quality == null ? null : quality.getResult());
            List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(scope.code());
            MesQcSeedQualityDO quality = qualities.stream()
                    .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
            result.put("qualityId", quality == null ? null : quality.getId());
            result.put("result", quality == null ? null : quality.getResult());
            result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        }
        return result;
    }
@@ -152,7 +296,9 @@
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        if (effective.isEmpty()) throw new IllegalArgumentException("没有有效袋码");
        // 一次提交同时生成两条质检线记录,任一已存在即视为该袋已质检。
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .filter(q -> !isReturnQuality(q))
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesProBagCodeDO> pendingPreview = effective.stream().filter(b -> b.getInboundEventId() != null && !inspected.contains(b.getCode()))
                .sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
@@ -168,7 +314,8 @@
        Set<Long> currentIds = current.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).map(MesProBagCodeDO::getId).collect(java.util.stream.Collectors.toSet());
        if (!currentIds.containsAll(confirmed))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描核对");
        if (!qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).isEmpty())
        if (qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        List<MesProBagCodeDO> pending = locked.stream().sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        submitInitialBatch(pending, req);
@@ -177,13 +324,14 @@
    private void submitInitialBatch(List<MesProBagCodeDO> bags,
                                    cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        boolean passed = validateIndicators(req.getIndicators());
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        Long batchId = bags.get(0).getBatchId();
        if (batchId == null || bags.stream().anyMatch(bag -> !Objects.equals(batchId, bag.getBatchId())))
            throw new IllegalArgumentException("批量初检只允许同一生产批次的袋码");
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        if (batch == null || batch.getItemId() == null) throw new IllegalArgumentException("未根据袋码解析到物料信息");
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(req.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        if (Integer.valueOf(40).equals(batch.getStatus())) throw new IllegalArgumentException("生产批次已作废,无法质检");
        MesWmBatchDO wmBatch = mesWmBatchMapper.selectByCode(batch.getCode());
        if (wmBatch == null || materialStockMapper.selectTempStockCountByBatchId(wmBatch.getId()) == 0)
@@ -193,37 +341,48 @@
        if (qualifiedWarehouse == null) throw exception(WM_WAREHOUSE_NOT_EXISTS);
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType()))
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        // 提前校验合格库已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        int status = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        List<MesQcSeedQualityDO> qualities = bags.stream().map(bag -> MesQcSeedQualityDO.builder()
                .code(bag.getCode()).itemId(batch.getItemId()).batchId(batch.getId()).batchCode(batch.getCode())
                .result(result).reason(passed ? "" : req.getReason().trim()).workflowStatus(status)
                .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId).inspectedAt(now).build()).toList();
        int status = passed ? WAIT_PROCESS : WAIT_RETEST;
        // 一次提交同时生成加工部与销售部两条记录,指标与结论完全一致。
        List<MesQcSeedQualityDO> qualities = new ArrayList<>(bags.size() * 2);
        for (MesProBagCodeDO bag : bags) {
            for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
                qualities.add(MesQcSeedQualityDO.builder()
                        .code(bag.getCode()).qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                        .itemId(batch.getItemId()).batchId(batch.getId()).batchCode(batch.getCode())
                        .result(result).reason(passed ? "" : req.getReason().trim()).workflowStatus(status)
                        .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId).inspectedAt(now).build());
            }
        }
        qualityMapper.insertBatch(qualities, WRITE_BATCH_SIZE);
        if (qualities.stream().anyMatch(quality -> quality.getId() == null))
            throw new IllegalStateException("批量初检记录编号生成失败");
        List<MesQcSeedQualityIndicatorDO> indicators = new ArrayList<>(qualities.size() * REQUIRED.size());
        List<MesQcSeedQualityIndicatorDO> indicators = new ArrayList<>(qualities.size() * expected.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        for (MesQcSeedQualityDO quality : qualities) {
            for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicators.add(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            actions.add(newAction(quality.getId(), "INITIAL", result, req.getReason(), req.getIndicators(), operatorId, now));
        }
        indicatorMapper.insertBatch(indicators, WRITE_BATCH_SIZE * REQUIRED.size());
        indicatorMapper.insertBatch(indicators, WRITE_BATCH_SIZE * expected.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Long submit(MesQcSeedQualitySubmitReqVO reqVO) {
        if (reqVO.getIndicators().size() != 4) throw new IllegalArgumentException("必须提交四项指标");
        // 种子质检必须以袋码(单袋)提交,物料信息由袋码自动解析,前端不再单独选择物料
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(reqVO.getCode());
        if (bag == null) {
@@ -232,7 +391,9 @@
        if (Integer.valueOf(30).equals(bag.getStatus()) || bag.getInboundEventId() == null)
            throw new IllegalArgumentException("袋码已作废或尚未入库,无法质检");
        // 双码锁定同一袋,并以追溯码记录结果,防止换码或并发重复扣减库存。
        if (qualityMapper.selectByCodeForUpdate(bag.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        if (qualityMapper.selectListByCodeForUpdate(bag.getCode()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        MesProBatchDO batch = bag.getBatchId() == null ? null : proBatchService.getBatch(bag.getBatchId());
        if (batch == null || batch.getItemId() == null) {
            throw new IllegalArgumentException("未根据袋码解析到物料信息,请确认扫码内容或批次号正确");
@@ -254,35 +415,39 @@
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType())) {
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        }
        // 提前校验合格库已配置库区/库位,避免复检审核放行时才失败
        resolveQualifiedLocation(qualifiedWarehouse);
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode()).itemId(batch.getItemId())
                .batchCode(batch.getCode()).batchId(batch.getId()).reason(reqVO.getReason()).result(0)
                .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(getLoginUserId())
                .inspectedAt(LocalDateTime.now()).build();
        qualityMapper.insert(quality);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
            if (item == null || item.getCode() == null || item.getValue() == null || item.getMinValue() == null || item.getMaxValue() == null)
                throw new IllegalArgumentException("四项指标的实测值和本次合格范围必须完整");
            if (!REQUIRED.contains(item.getCode()) || !codes.add(item.getCode())) throw new IllegalArgumentException("指标必须为纯度、净度、发芽率、水分且不可重复");
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("指标下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getValue().compareTo(new BigDecimal("100")) > 0
                    || item.getMinValue().compareTo(BigDecimal.ZERO) < 0 || item.getMaxValue().compareTo(new BigDecimal("100")) > 0)
                throw new IllegalArgumentException("百分比指标必须处于 0–100");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
            indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        if (!codes.equals(REQUIRED)) throw new IllegalArgumentException("四项指标不完整");
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        quality.setResult(passed ? 1 : 2);
        quality.setWorkflowStatus(passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW);
        qualityMapper.updateById(quality);
        recordAction(quality.getId(), "INITIAL", quality.getResult(), reqVO.getReason(), reqVO.getIndicators());
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        int status = passed ? WAIT_PROCESS : WAIT_RETEST;
        // 一次提交同时生成加工部与销售部两条记录,指标与结论完全一致。
        Long firstQualityId = null;
        for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
            MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode())
                    .qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                    .itemId(batch.getItemId()).batchCode(batch.getCode()).batchId(batch.getId())
                    .reason(passed ? "" : reqVO.getReason().trim()).result(result).workflowStatus(status)
                    .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId)
                    .inspectedAt(now).build();
            qualityMapper.insert(quality);
            for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
            if (firstQualityId == null) firstQualityId = quality.getId();
        }
        // 初检仅记录结论;袋仍留在暂存库,待初检审核、复检及最终审核后再放行或报废。
        return quality.getId();
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
        return firstQualityId;
    }
    private void finalizeStock(MesQcSeedQualityDO quality, boolean passed) {
@@ -370,25 +535,47 @@
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能提交复检,请刷新后重试");
        boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
        if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
            throw new IllegalArgumentException("经销商退货质检当前状态不能复检,请刷新后重试");
        boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
        if (!Objects.equals(quality.getCode(), req.getCode())) throw new IllegalArgumentException("检验袋码与原质检记录不一致");
        boolean passed = validateIndicators(req.getIndicators());
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("检验不合格必须填写原因");
        recordAction(id, initialRecheck ? "INITIAL_RECHECK" : "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim())
                .inspectorId(getLoginUserId()).inspectedAt(LocalDateTime.now())
                .workflowStatus(initialRecheck ? (passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW)
                        : (passed ? WAIT_COMPANY : WAIT_COMPANY_FAIL)).build();
        if (!initialRecheck) {
            update.setRetestResult(passed ? 1 : 2);
            update.setRetestedAt(LocalDateTime.now());
        }
        qualityMapper.updateById(update);
        // 页面、公开追溯读取主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        // 复检沿用原记录的指标集合,避免配置变更导致历史记录无法复检
        Map<String, MesQcSeedQualityIndicatorDO> existing = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityId(id))
            existing.put(indicator.getIndicatorCode(), indicator);
        boolean passed = validateIndicators(req.getIndicators(), toMetaMap(existing.values(), quality.getItemId()));
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("检验不合格必须填写原因");
        recordAction(id, initialRecheck ? "INITIAL_RECHECK" : "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        Long operatorId = getLoginUserId();
        LocalDateTime now = LocalDateTime.now();
        int nextStatus;
        if (returnQuality) {
            // 复检结论统一交由销售部领导审批,不合格时审批后不予退货。
            nextStatus = WAIT_PROCESS;
        } else if (initialRecheck) {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        } else {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        }
        MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim())
                .inspectorId(operatorId).inspectedAt(now)
                .workflowStatus(nextStatus).build();
        if (!initialRecheck) {
            update.setRetestResult(passed ? 1 : 2);
            update.setRetestedAt(now);
        }
        qualityMapper.updateById(update);
        if (returnQuality) {
            afterCommit(() -> startReturnProcess(quality, operatorId));
        } else if (initialRecheck) {
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        } else if (!initialRecheck) {
            // 初检不合格的复检完成后,先交本线部门审批。
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        }
        // 页面、公开追溯读取主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
            MesQcSeedQualityIndicatorDO indicator = existing.get(item.getCode());
            if (indicator == null) throw new IllegalArgumentException("质检指标记录缺失");
@@ -404,6 +591,7 @@
        validateLockedIds(ids, qualities);
        String batchCode = null;
        Integer workflowStatus = null;
        Integer batchQcLine = null;
        boolean first = true;
        for (MesQcSeedQualityDO quality : qualities) {
            if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
@@ -413,9 +601,12 @@
            if (first) {
                batchCode = quality.getBatchCode();
                workflowStatus = quality.getWorkflowStatus();
                batchQcLine = quality.getQcLine();
                first = false;
            } else if (!Objects.equals(batchCode, quality.getBatchCode()) || !Objects.equals(workflowStatus, quality.getWorkflowStatus())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同流程状态的记录");
            } else if (!Objects.equals(batchCode, quality.getBatchCode())
                    || !Objects.equals(workflowStatus, quality.getWorkflowStatus())
                    || !Objects.equals(batchQcLine, quality.getQcLine())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同质检线和同流程状态的记录");
            }
        }
        applyRetestBatch(qualities, req.getIndicators(), req.getReason());
@@ -428,12 +619,19 @@
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType) {
        return resolveRetestScope(code, scanType, QC_LINE_PROCESS);
    }
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        checkRetestScanType(scanType);
        Scope scope = scope(code, scanType, false);
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<String> codes = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() != null)
                .map(MesProBagCodeDO::getCode).toList();
        List<MesQcSeedQualityDO> qualities = codes.isEmpty() ? List.of() : qualityMapper.selectListByCodes(codes);
        List<MesQcSeedQualityDO> qualities = codes.isEmpty() ? List.of() : qualityMapper.selectListByCodes(codes).stream()
                .filter(q -> Objects.equals(q.getQcLine(), qcLine) && !isReturnQuality(q)).toList();
        List<Long> retestIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_RETEST))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> initialRecheckIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_RECHECK))
@@ -454,11 +652,19 @@
        result.put("pendingCompanyIds", pendingCompanyIds); result.put("pendingCompanyFailIds", pendingCompanyFailIds);
        result.put("pendingProcessCount", pendingProcessIds.size() + pendingInitialFailIds.size());
        result.put("pendingCompanyCount", pendingCompanyIds.size() + pendingCompanyFailIds.size());
        Long itemId = qualities.stream().map(MesQcSeedQualityDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        result.put("itemId", itemId);
        MesQcSeedQualityDO actionable = qualities.stream()
                .filter(q -> Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(q.getWorkflowStatus()))
                .findFirst().orElse(null);
        result.put("indicators", actionable == null ? List.of()
                : toIndicatorPayload(indicatorMapper.selectListByQualityId(actionable.getId()), itemId));
        return result;
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> retestScope(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO req) {
        requireQcLine(req.getQcLine());
        checkRetestScanType(req.getScanType());
        if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(req.getWorkflowStatus()))
            throw new IllegalArgumentException("批量质检状态无效");
@@ -470,7 +676,8 @@
        if (effective.isEmpty()) throw new IllegalArgumentException("当前范围没有已入库的有效袋码");
        Set<String> effectiveCodes = effective.stream().map(MesProBagCodeDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesQcSeedQualityDO> pendingPreview = qualityMapper.selectListByCodes(effectiveCodes).stream()
                .filter(q -> Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
                .filter(q -> Objects.equals(q.getQcLine(), req.getQcLine())
                        && !isReturnQuality(q) && Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
                .sorted(Comparator.comparing(MesQcSeedQualityDO::getId)).toList();
        List<Long> expectedChunk = pendingPreview.stream().limit(MAX_BATCH_SIZE).map(MesQcSeedQualityDO::getId).toList();
        if (expectedChunk.isEmpty() || !confirmed.equals(expectedChunk))
@@ -478,6 +685,7 @@
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, pending);
        if (pending.stream().anyMatch(quality -> !Objects.equals(quality.getWorkflowStatus(), req.getWorkflowStatus())
                || !Objects.equals(quality.getQcLine(), req.getQcLine()) || isReturnQuality(quality)
                || !effectiveCodes.contains(quality.getCode())))
            throw new IllegalArgumentException("待质检记录状态或归属已变化,请重新扫描核对");
        applyRetestBatch(pending, req.getIndicators(), req.getReason());
@@ -486,29 +694,48 @@
    private void applyRetestBatch(List<MesQcSeedQualityDO> qualities,
                                  List<MesQcSeedQualitySubmitReqVO.Indicator> submittedIndicators, String reason) {
        boolean passed = validateIndicators(submittedIndicators);
        if (!passed && StrUtil.isBlank(reason)) throw new IllegalArgumentException("检验不合格必须填写原因");
        List<Long> ids = qualities.stream().map(MesQcSeedQualityDO::getId).toList();
        Map<Long, Map<String, MesQcSeedQualityIndicatorDO>> existingByQuality = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityIds(ids)) {
            existingByQuality.computeIfAbsent(indicator.getQualityId(), ignored -> new HashMap<>())
                    .put(indicator.getIndicatorCode(), indicator);
        }
        Map<String, MesQcSeedQualityIndicatorDO> firstIndicators = existingByQuality.get(qualities.get(0).getId());
        Map<String, IndicatorMeta> expected = toMetaMap(
                firstIndicators == null ? Collections.<MesQcSeedQualityIndicatorDO>emptyList() : firstIndicators.values(),
                qualities.get(0).getItemId());
        boolean passed = validateIndicators(submittedIndicators, expected);
        if (!passed && StrUtil.isBlank(reason)) throw new IllegalArgumentException("检验不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        List<MesQcSeedQualityDO> qualityUpdates = new ArrayList<>(qualities.size());
        List<MesQcSeedQualityIndicatorDO> indicatorUpdates = new ArrayList<>(qualities.size() * REQUIRED.size());
        List<MesQcSeedQualityIndicatorDO> indicatorUpdates = new ArrayList<>(qualities.size() * submittedIndicators.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        Set<String> lineProcessTargets = new HashSet<>();
        List<MesQcSeedQualityDO> returnReady = new ArrayList<>();
        for (MesQcSeedQualityDO quality : qualities) {
            boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
            boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
            if (!initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
            if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            if (!returnQuality && !initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            int nextStatus;
            if (returnQuality) {
                nextStatus = WAIT_PROCESS;
                returnReady.add(quality);
            } else if (initialRecheck) {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            } else {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            }
            MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(quality.getId()).result(result)
                    .reason(passed ? "" : reason.trim()).inspectorId(operatorId).inspectedAt(now)
                    .workflowStatus(initialRecheck ? (passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW)
                            : (passed ? WAIT_COMPANY : WAIT_COMPANY_FAIL)).build();
                    .workflowStatus(nextStatus).build();
            if (!initialRecheck) {
                update.setRetestResult(result);
                update.setRetestedAt(now);
@@ -525,8 +752,14 @@
                    reason, submittedIndicators, operatorId, now));
        }
        qualityMapper.updateBatch(qualityUpdates, WRITE_BATCH_SIZE);
        indicatorMapper.updateBatch(indicatorUpdates, WRITE_BATCH_SIZE * REQUIRED.size());
        indicatorMapper.updateBatch(indicatorUpdates, WRITE_BATCH_SIZE * submittedIndicators.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        // 复检按记录所属质检线分别发起本线审批,避免提前触发另一条线。
        lineProcessTargets.forEach(target -> {
            String[] parts = target.split("#", 2);
            afterCommit(() -> startLineProcess(Integer.valueOf(parts[0]), parts[1], operatorId));
        });
        returnReady.forEach(quality -> afterCommit(() -> startReturnProcess(quality, operatorId)));
    }
    @Override @Transactional(rollbackFor = Exception.class)
@@ -534,28 +767,84 @@
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行初检审核");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        requireLegacyApproval(quality);
        applyProcessApproval(quality, approved, opinion, true);
    }
    /**
     * 本线领导审批:生产质检通过后进入公司质检审批,驳回后重新初检;经销商退货质检则直接终审。
     */
    private void applyProcessApproval(MesQcSeedQualityDO quality, boolean approved, String opinion, boolean validateApprover) {
        if (validateApprover) {
            approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                    MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        }
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        int next = approved ? WAIT_RETEST : WAIT_INITIAL_RECHECK;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).workflowStatus(next)
                .processApprovedAt(LocalDateTime.now()).build());
        recordAction(id, approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null);
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw new IllegalArgumentException("经销商退货质检当前状态不能进行销售部审批");
            applyReturnApproval(quality, approved, opinion, operatorId, now);
            return;
        }
        int next = approved ? (Objects.equals(quality.getQcLine(), 0) ? WAIT_RETEST
                : Objects.equals(quality.getResult(), 1) ? WAIT_COMPANY : WAIT_COMPANY_FAIL)
                : WAIT_INITIAL_RECHECK;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId()).workflowStatus(next)
                .processApprovedAt(now).build());
        if (!approved) qualityMapper.clearLineProcessInstance(quality.getId());
        actionMapper.insert(newAction(quality.getId(), approved ? "PROCESS_APPROVE" : "PROCESS_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
        if (approved && !Objects.equals(quality.getQcLine(), 0))
            afterCommit(() -> maybeStartCompanyProcess(quality.getBatchCode(), operatorId));
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
        if (quality == null) throw new IllegalArgumentException("质检记录不存在");
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        if (!Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行复检审核");
        requireLegacyApproval(quality);
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        applyCompanyApproval(quality.getCode(), approved, opinion, getLoginUserId());
    }
    /**
     * 公司质检审批:以「袋」为单位成对审批,两条质检线都走完才允许审批。
     * 两条线均合格才放行入库,任一条线不合格则整袋报废;驳回则两条线全部退回复检。
     */
    private void applyCompanyApproval(String code, boolean approved, String opinion, Long operatorId) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved) finalizeStock(quality, quality.getWorkflowStatus() == WAIT_COMPANY);
        int next = approved ? (quality.getWorkflowStatus() == WAIT_COMPANY ? RELEASED : SCRAPPED) : WAIT_RETEST;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).workflowStatus(next)
                .companyApprovedAt(LocalDateTime.now()).build());
        recordAction(id, approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null);
        List<MesQcSeedQualityDO> pair = qualityMapper.selectListByCodeForUpdate(code).stream()
                .filter(q -> !isReturnQuality(q)).toList();
        if (pair.size() != 2 || pair.stream().anyMatch(q -> !Objects.equals(q.getQcLine(), QC_LINE_PROCESS)
                && !Objects.equals(q.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("袋码 " + code + " 的双线质检记录不完整,无法公司审批");
        if (pair.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(q.getWorkflowStatus())))
            throw new IllegalArgumentException("袋码 " + code + " 的双线质检尚未全部完成,不能进行公司质检审批");
        boolean passed = pair.stream().allMatch(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY));
        MesQcSeedQualityDO representative = pair.stream()
                .filter(q -> Objects.equals(q.getQcLine(), QC_LINE_PROCESS)).findFirst().orElse(pair.get(0));
        LocalDateTime now = LocalDateTime.now();
        if (approved) {
            finalizeStock(representative, passed);
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(passed ? RELEASED : SCRAPPED)
                    .companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        } else {
            qualityMapper.clearCompanyProcessInstance(code);
            pair.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(WAIT_RETEST).companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        }
        actionMapper.insertBatch(pair.stream().map(quality -> newAction(quality.getId(),
                approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
    @Override @Transactional(rollbackFor = Exception.class)
@@ -563,6 +852,7 @@
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())))
            throw new IllegalArgumentException("所选记录包含不能进行初检审核的状态,请刷新后重试");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
@@ -570,10 +860,17 @@
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        List<MesQcSeedQualityDO> production = qualities.stream()
                .filter(quality -> !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)).toList();
        qualities.stream().filter(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
                .forEach(quality -> applyProcessApproval(quality, approved, opinion, false));
        if (production.isEmpty()) return;
        int next = approved ? WAIT_RETEST : WAIT_INITIAL_RECHECK;
        qualityMapper.updateBatch(qualities.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(next).processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        actionMapper.insertBatch(qualities.stream().map(quality -> newAction(quality.getId(),
        qualityMapper.updateBatch(production.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) production.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(production.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
@@ -583,23 +880,340 @@
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        if (qualities.stream().anyMatch(quality -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus())))
            throw new IllegalArgumentException("所选记录包含不能进行复检审核的状态,请刷新后重试");
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved) {
            for (MesQcSeedQualityDO quality : qualities) finalizeStock(quality, quality.getWorkflowStatus() == WAIT_COMPANY);
        }
        Long operatorId = getLoginUserId();
        // 公司审批以袋为单位成对处理,同一袋的两条线记录一次审批
        qualities.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, opinion, operatorId));
    }
    // ==================== 经销商退货质检 ====================
    @Override
    public Map<String, Object> resolveReturnQuality(Long returnSalesId) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(returnSalesId);
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSalesId);
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst().orElse(null);
        Long itemId = first == null ? null : first.getItemId();
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesId(returnSalesId);
        Map<String, Object> result = new HashMap<>();
        result.put("returnSalesId", returnSales.getId());
        result.put("returnSalesCode", returnSales.getCode());
        result.put("returnSalesStatus", returnSales.getStatus());
        result.put("itemId", itemId);
        result.put("batchId", first == null ? null : first.getBatchId());
        result.put("batchCode", first == null ? null : first.getBatchCode());
        result.put("indicators", toRangePayload(itemId));
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("workflowStatus", quality == null ? null : quality.getWorkflowStatus());
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long submitReturnQuality(MesQcSeedReturnQualitySubmitReqVO reqVO) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(reqVO.getReturnSalesId());
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        if (qualityMapper.selectByReturnSalesId(returnSales.getId()) != null)
            throw new IllegalArgumentException("该销售退货单已完成退货质检,禁止重复提交");
        if (!Set.of(MesWmReturnSalesStatusEnum.PREPARE.getStatus(),
                MesWmReturnSalesStatusEnum.CONFIRMED.getStatus()).contains(returnSales.getStatus()))
            throw new IllegalArgumentException("当前销售退货单状态不能进行退货质检");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSales.getId());
        if (CollUtil.isEmpty(lines)) throw new IllegalArgumentException("销售退货单没有明细,无法质检");
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst()
                .orElseThrow(() -> new IllegalArgumentException("销售退货单明细缺少物料,无法质检"));
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(first.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        List<MesQcSeedQualityDO> updates = qualities.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(approved ? (quality.getWorkflowStatus() == WAIT_COMPANY ? RELEASED : SCRAPPED) : WAIT_RETEST)
                .companyApprovedAt(now).build()).toList();
        qualityMapper.updateBatch(updates, WRITE_BATCH_SIZE);
        actionMapper.insertBatch(qualities.stream().map(quality -> newAction(quality.getId(),
                approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
        int result = passed ? 1 : 2;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder()
                .code(returnSales.getCode()).qcLine(QC_LINE_SALES).bizType(BIZ_TYPE_RETURN)
                .itemId(first.getItemId()).batchId(first.getBatchId()).batchCode(first.getBatchCode())
                .result(result).reason(passed ? "" : reqVO.getReason().trim())
                // 初检合格进入销售部审批;不合格先复检。
                .workflowStatus(passed ? WAIT_PROCESS : WAIT_RETEST)
                .returnSalesId(returnSales.getId()).returnSalesCode(returnSales.getCode())
                .inspectorId(operatorId).inspectedAt(now).build();
        qualityMapper.insert(quality);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
            IndicatorMeta meta = expected.get(item.getCode());
            indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId())
                    .indicatorCode(item.getCode()).indicatorName(meta.name()).unit(meta.unit())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
        returnSalesService.updateReturnSalesStatus(returnSales.getId(), MesWmReturnSalesStatusEnum.CONFIRMED.getStatus());
        if (passed) afterCommit(() -> startReturnProcess(quality, operatorId));
        return quality.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void returnApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("质检记录不存在或不是经销商退货质检");
        requireLegacyApproval(quality);
        if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
            throw new IllegalArgumentException("当前状态不能进行销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        applyReturnApproval(quality, approved, opinion, getLoginUserId(), LocalDateTime.now());
    }
    /**
     * 销售部领导审批:通过则退货单进入执行;不通过则不予退货并取消退货单,质检记录留存。
     */
    private void applyReturnApproval(MesQcSeedQualityDO quality, boolean approved, String opinion,
                                     Long operatorId, LocalDateTime now) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved && Objects.equals(quality.getResult(), 1)) {
            returnSalesService.updateReturnSalesStatus(quality.getReturnSalesId(),
                    MesWmReturnSalesStatusEnum.APPROVING.getStatus());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(RELEASED).processApprovedAt(now).build());
        } else {
            cancelReturnSalesQuietly(quality.getReturnSalesId());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(SCRAPPED).processApprovedAt(now).build());
        }
        actionMapper.insert(newAction(quality.getId(), approved ? "RETURN_APPROVE" : "RETURN_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
    }
    // ==================== BPM 流程回调 ====================
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleLineProcessResult(String processInstanceId, Integer qcLine, String batchCode, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || qcLine == null || StrUtil.isBlank(batchCode)) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByProcessInstanceId(processInstanceId).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && Objects.equals(quality.getBatchCode(), batchCode)
                        && !isReturnQuality(quality)
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleLineProcessResult][批次 {} 质检线 {} 无待审批记录,忽略 BPM 事件]", batchCode, qcLine);
            return;
        }
        List<Long> ids = pending.stream().map(MesQcSeedQualityDO::getId).sorted().toList();
        List<MesQcSeedQualityDO> locked = qualityMapper.selectListByIdsForUpdate(ids);
        validateLockedIds(ids, locked);
        LocalDateTime now = LocalDateTime.now();
        int next = approved ? WAIT_COMPANY : WAIT_INITIAL_RECHECK;
        String finalReason = StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回");
        qualityMapper.updateBatch(locked.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(approved && Objects.equals(quality.getResult(), 2) ? WAIT_COMPANY_FAIL : next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) locked.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(locked.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), finalReason, null, null, now)).toList(),
                WRITE_BATCH_SIZE);
        if (approved) {
            Long inspectorId = locked.stream().map(MesQcSeedQualityDO::getInspectorId)
                    .filter(Objects::nonNull).findFirst().orElse(null);
            afterCommit(() -> maybeStartCompanyProcess(batchCode, inspectorId));
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleCompanyProcessResult(String processInstanceId, String businessKey, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId)) return;
        // 公司审批按生产批次发起,一个流程实例覆盖该批次的所有袋;
        // 按流程实例编号回查可同时兼容历史按袋码发起的审批。
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByCompanyProcessInstanceId(processInstanceId).stream()
                .filter(quality -> !isReturnQuality(quality)
                        && Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleCompanyProcessResult][流程 {} 无待公司审批记录,忽略 BPM 事件]", processInstanceId);
            return;
        }
        String finalReason = StrUtil.blankToDefault(reason, approved ? "公司质检审批通过" : "公司质检审批驳回");
        // 放行/报废以袋为单位落库,同一批次的多个袋依次处理
        pending.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, finalReason, null));
        log.info("[handleCompanyProcessResult][公司质检审批 {} 完成,批次/袋码 {} 共处理 {} 条记录]",
                processInstanceId, businessKey, pending.size());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleReturnProcessResult(String processInstanceId, Long returnSalesId, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || returnSalesId == null) return;
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesIdForUpdate(returnSalesId);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)
                || !Objects.equals(quality.getProcessInstanceId(), processInstanceId)
                || !Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS)) {
            log.info("[handleReturnProcessResult][销售退货单 {} 无待审批退货质检,忽略 BPM 事件]", returnSalesId);
            return;
        }
        applyReturnApproval(quality, approved, StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回"),
                null, LocalDateTime.now());
    }
    private void startLineProcess(Integer qcLine, String batchCode, Long operatorId) {
        if (qcLine == null || StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && !isReturnQuality(quality)
                        && StrUtil.isBlank(quality.getProcessInstanceId())
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("qcLine", qcLine);
        variables.put("batchCode", batchCode);
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(LINE_PROCESS_KEY)
                        .setBusinessKey(qcLine + "#" + batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : pending) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .processInstanceId(processInstanceId).build());
        }
        log.info("[startLineProcess][批次 {} 质检线 {} 已发起本线领导审批 {}", batchCode, qcLine, processInstanceId);
    }
    /**
     * 双线部门审批全部完成后,按生产批次统一发起一次公司质检审批。
     * 同一批次的多袋共用同一个流程实例,避免每袋各发一条审批。
     */
    private void maybeStartCompanyProcess(String batchCode, Long operatorId) {
        if (StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> records = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> !isReturnQuality(quality))
                // 已放行/已报废的记录属于已终结的批次,不再参与新的公司审批
                .filter(quality -> !Set.of(RELEASED, SCRAPPED).contains(quality.getWorkflowStatus()))
                .toList();
        if (records.isEmpty()) return;
        if (records.stream().anyMatch(quality -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                .contains(quality.getWorkflowStatus()))) return;
        if (records.stream().anyMatch(quality -> StrUtil.isNotBlank(quality.getCompanyProcessInstanceId()))) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("batchCode", batchCode);
        // 兼容流程条件中引用的 code 变量
        variables.put("code", batchCode);
        variables.put("passed", records.stream().allMatch(quality -> Objects.equals(quality.getWorkflowStatus(), WAIT_COMPANY)));
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(COMPANY_PROCESS_KEY)
                        .setBusinessKey(batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : records) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .companyProcessInstanceId(processInstanceId).build());
        }
        log.info("[maybeStartCompanyProcess][批次 {} 双线质检完成,已发起公司质检审批 {},覆盖 {} 条记录]",
                batchCode, processInstanceId, records.size());
    }
    private void startReturnProcess(MesQcSeedQualityDO quality, Long operatorId) {
        if (quality == null || quality.getReturnSalesId() == null || StrUtil.isNotBlank(quality.getProcessInstanceId()))
            return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("returnSalesId", quality.getReturnSalesId());
        variables.put("returnSalesCode", quality.getReturnSalesCode());
        variables.put("code", quality.getCode());
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(RETURN_PROCESS_KEY)
                        .setBusinessKey(String.valueOf(quality.getReturnSalesId()))
                        .setVariables(variables));
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                .processInstanceId(processInstanceId).build());
        log.info("[startReturnProcess][销售退货单 {} 已发起销售部领导审批 {}]", quality.getReturnSalesId(), processInstanceId);
    }
    @Override
    public void retryBpmProcess(Long id) {
        MesQcSeedQualityDO quality = qualityMapper.selectById(id);
        if (quality == null) throw invalidParamException("质检记录不存在");
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw invalidParamException("当前退货质检不需要发起审批");
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("退货质检审批流程已经发起");
            startReturnProcess(quality, operatorId);
        } else if (Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())) {
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("部门审批流程已经发起");
            startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId);
        } else if (Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus())) {
            String batchCode = quality.getBatchCode();
            if (StrUtil.isBlank(batchCode))
                throw invalidParamException("质检记录未绑定生产批次,不能发起公司审批");
            List<MesQcSeedQualityDO> batchRecords = qualityMapper.selectListByBatchCode(batchCode).stream()
                    .filter(q -> !isReturnQuality(q))
                    .filter(q -> !Set.of(RELEASED, SCRAPPED).contains(q.getWorkflowStatus()))
                    .toList();
            if (batchRecords.isEmpty() || batchRecords.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                    .contains(q.getWorkflowStatus())))
                throw invalidParamException("同批次双线部门审批尚未全部完成,不能发起公司审批");
            if (batchRecords.stream().anyMatch(q -> StrUtil.isNotBlank(q.getCompanyProcessInstanceId())))
                throw invalidParamException("公司质检审批流程已经发起");
            maybeStartCompanyProcess(batchCode, operatorId);
        } else {
            throw invalidParamException("当前质检状态不需要发起审批");
        }
    }
    private void cancelReturnSalesQuietly(Long returnSalesId) {
        if (returnSalesId == null) return;
        try {
            returnSalesService.cancelReturnSales(returnSalesId);
        } catch (Exception ex) {
            log.warn("[cancelReturnSalesQuietly][销售退货单 {} 取消失败: {}]", returnSalesId, ex.getMessage());
        }
    }
    private void afterCommit(Runnable action) {
        TransactionSynchronization synchronization = new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    action.run();
                } catch (Exception ex) {
                    log.error("[afterCommit][BPM 流程启动失败,请修复流程配置并在质检记录中重新发起审批]", ex);
                }
            }
        };
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(synchronization);
        } else {
            synchronization.afterCommit();
        }
    }
    /** 历史数据 bizType 为空,按生产入库质检处理 */
    private static void requireQcLine(Integer qcLine) {
        if (!Objects.equals(qcLine, QC_LINE_PROCESS) && !Objects.equals(qcLine, QC_LINE_SALES))
            throw new IllegalArgumentException("质检线必须为加工部或销售部");
    }
    /** 生产双线记录只能走 BPM 审批;经销商退货质检不拆分质检线,仍可使用人工审批接口。 */
    private static void requireLegacyApproval(MesQcSeedQualityDO quality) {
        if (!isReturnQuality(quality) && (Objects.equals(quality.getQcLine(), QC_LINE_PROCESS)
                || Objects.equals(quality.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("该质检记录由 BPM 审批,请在流程待办中处理");
    }
    private static boolean isReturnQuality(MesQcSeedQualityDO quality) {
        return Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
    }
    private List<Long> approvalIds(List<Long> ids) {
@@ -626,21 +1240,95 @@
        return quality;
    }
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators) {
        if (indicators == null || indicators.size() != REQUIRED.size()) throw new IllegalArgumentException("必须提交四项指标");
    private record IndicatorMeta(String code, String name, String unit) { }
    private Map<String, IndicatorMeta> requireEffectiveIndicators(Long itemId) {
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            result.put(range.getIndicatorCode(), new IndicatorMeta(range.getIndicatorCode(),
                    StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()),
                    StrUtil.blankToDefault(range.getUnit(), "%")));
        }
        if (result.isEmpty())
            throw new IllegalArgumentException("该产品尚未配置质检项,请先在「质检项合格范围配置」中维护");
        return result;
    }
    private Map<String, IndicatorMeta> toMetaMap(Collection<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            String name = StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName());
            String unit = StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit());
            result.put(indicator.getIndicatorCode(), new IndicatorMeta(indicator.getIndicatorCode(), name, unit));
        }
        return result;
    }
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators,
                                       Map<String, IndicatorMeta> expected) {
        if (indicators == null || indicators.isEmpty()) throw new IllegalArgumentException("请至少提交一项质检指标");
        if (expected.isEmpty()) throw new IllegalArgumentException("该产品尚未配置质检项");
        if (indicators.size() != expected.size())
            throw new IllegalArgumentException("质检项数量与产品配置不一致,请刷新后重新录入");
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        for (MesQcSeedQualitySubmitReqVO.Indicator item : indicators) {
            if (item == null || !REQUIRED.contains(item.getCode()) || !codes.add(item.getCode())
            if (item == null || StrUtil.isBlank(item.getCode()) || !codes.add(item.getCode())
                    || item.getValue() == null || item.getMinValue() == null || item.getMaxValue() == null)
                throw new IllegalArgumentException("指标必须为纯度、净度、发芽率、水分且不可重复");
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("指标下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getValue().compareTo(new BigDecimal("100")) > 0
                    || item.getMinValue().compareTo(BigDecimal.ZERO) < 0 || item.getMaxValue().compareTo(new BigDecimal("100")) > 0)
                throw new IllegalArgumentException("百分比指标必须处于 0–100");
                throw new IllegalArgumentException("质检项的实测值和合格范围必须完整且不可重复");
            if (!expected.containsKey(item.getCode()))
                throw new IllegalArgumentException("质检项未在产品配置中启用:" + item.getCode());
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("质检项下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getMinValue().compareTo(BigDecimal.ZERO) < 0
                    || item.getMaxValue().compareTo(BigDecimal.ZERO) < 0)
                throw new IllegalArgumentException("质检项数值不能为负数");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
        }
        if (!codes.equals(expected.keySet())) throw new IllegalArgumentException("产品质检项不完整,请刷新后重新录入");
        return passed;
    }
    private Long resolveItemId(Scope scope) {
        Long itemId = scope.bags().stream().map(MesProBagCodeDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        if (itemId != null || scope.bags().isEmpty()) return itemId;
        Long batchId = scope.bags().get(0).getBatchId();
        if (batchId == null) return null;
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        return batch == null ? null : batch.getItemId();
    }
    private List<Map<String, Object>> toRangePayload(Long itemId) {
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            Map<String, Object> item = new HashMap<>();
            item.put("code", range.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()));
            item.put("unit", StrUtil.blankToDefault(range.getUnit(), "%"));
            item.put("minValue", range.getMinValue());
            item.put("maxValue", range.getMaxValue());
            result.add(item);
        }
        return result;
    }
    private List<Map<String, Object>> toIndicatorPayload(List<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            Map<String, Object> item = new HashMap<>();
            item.put("code", indicator.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName()));
            item.put("unit", StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit()));
            item.put("minValue", indicator.getMinValue());
            item.put("maxValue", indicator.getMaxValue());
            result.add(item);
        }
        return result;
    }
    private void recordAction(Long id, String action, Integer result, String opinion,
@@ -711,20 +1399,20 @@
    /**
     * 解析合格库的默认入库库区与库位:取第一个同时配置了库位的非冻结库区。
     *
     * 质检按仓库维度选择入库目标,库区/库位由系统自动落点;合格库缺少可用库区或库位时,
     * 库存事务无法写入,直接提示管理员完善仓库配置。
     * 库区/库位为可选项,仓库未配置时按仓库层级直接入库,返回 null。
     */
    private Long[] resolveQualifiedLocation(MesWmWarehouseDO qualifiedWarehouse) {
        List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId())
                .stream().filter(location -> !Boolean.TRUE.equals(location.getFrozen())).toList();
        if (CollUtil.isEmpty(locations))
            throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库区,请先在仓库管理中完善库区与库位");
        if (CollUtil.isEmpty(locations)) {
            return new Long[]{null, null};
        }
        for (MesWmWarehouseLocationDO location : locations) {
            List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(location.getId())
                    .stream().filter(area -> !Boolean.TRUE.equals(area.getFrozen())).toList();
            if (CollUtil.isNotEmpty(areas)) return new Long[]{location.getId(), areas.get(0).getId()};
        }
        throw new IllegalArgumentException("合格库「" + qualifiedWarehouse.getName() + "」未配置可用库位,请先在仓库管理中完善库区与库位");
        return new Long[]{null, null};
    }
    private MesWmMaterialStockDO getOrCreateQualifiedStock(MesWmMaterialStockDO tempStock, Long qualifiedWarehouseId,