云
12 小时以前 724a3d9d2cdb001bd9fae4eaea0ffabfa5ffaf6d
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -22,11 +22,14 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstockbag.MesWmMaterialStockBagMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeQcStatusEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcApproveNodeEnum;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcApprovalTypeEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmStockStateEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmTransactionTypeEnum;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmWarehouseTypeEnum;
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.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
@@ -44,6 +47,7 @@
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_QUALIFIED_WAREHOUSE_NOT_CONFIGURED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_WAREHOUSE_NOT_EXISTS;
@@ -52,9 +56,11 @@
@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 WAIT_RETEST = 10, WAIT_PROCESS = 20, WAIT_COMPANY = 30,
            RELEASED = 40, WAIT_PROCESS_FAIL = 50, WAIT_COMPANY_FAIL = 60, SCRAPPED = 70;
    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;
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private MesQcApproverService approverService;
    @Resource private MesQcSeedQualityActionMapper actionMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
    @Resource private MesProBagCodeMapper bagCodeMapper;
@@ -100,23 +106,23 @@
    private Scope scope(String code, String type, boolean lock) {
        if ("BAG".equals(type)) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
            if (bag == null) throw new IllegalArgumentException("未找到袋码,请核对扫码类型");
            if (bag == null) throw invalidParamException("未找到袋码,请核对扫码类型和码值");
            return new Scope(bag.getCode(), bag.getBusinessCode(), bag.getBatchCode(), Integer.valueOf(30).equals(bag.getStatus()), List.of(bag));
        }
        if ("PALLET".equals(type)) {
            var pallet = lock ? palletMapper.selectByScanCodeForUpdate(code) : palletMapper.selectByScanCode(code);
            if (pallet == null) throw new IllegalArgumentException("未找到托盘码,请核对扫码类型");
            if (pallet.getBatchId() == null) throw new IllegalArgumentException("托盘尚未绑定生产批次,无法质检");
            if (pallet == null) throw invalidParamException("未找到托盘码,请核对扫码类型和码值");
            if (pallet.getBatchId() == null) throw invalidParamException("托盘尚未绑定生产批次,无法质检");
            return new Scope(StrUtil.blankToDefault(pallet.getCode(), pallet.getTempCode()), pallet.getBusinessCode(), pallet.getBatchCode(),
                    Integer.valueOf(20).equals(pallet.getStatus()), lock ? bagCodeMapper.selectListByPalletIdForUpdate(pallet.getId()) : bagCodeMapper.selectListByPalletId(pallet.getId()));
        }
        if ("BATCH".equals(type)) {
            var batch = lock ? batchMapper.selectByScanCodeForUpdate(code) : batchMapper.selectByScanCode(code);
            if (batch == null) throw new IllegalArgumentException("未找到生产批次码,请核对扫码类型");
            if (batch == null) throw invalidParamException("未找到生产批次码,请核对扫码类型和码值");
            return new Scope(StrUtil.blankToDefault(batch.getCode(), batch.getTempCode()), batch.getBusinessCode(), batch.getCode(),
                    Integer.valueOf(40).equals(batch.getStatus()), lock ? bagCodeMapper.selectListByBatchIdForUpdate(batch.getId()) : bagCodeMapper.selectListByBatchId(batch.getId()));
        }
        throw new IllegalArgumentException("不支持的质检扫码类型");
        throw invalidParamException("不支持的质检扫码类型");
    }
    @Override
@@ -141,32 +147,76 @@
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> submitGroup(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        Scope scope = scope(req.getCode(), req.getScanType(), true);
        if (scope.canceled()) throw new IllegalArgumentException("质检对象已作废");
        List<Long> confirmed = batchIds(req.getBagIds(), "袋码");
        Scope scope = scope(req.getCode(), req.getScanType(), false);
        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("没有有效袋码");
        List<MesProBagCodeDO> locked = bagCodeMapper.selectListByIdsForUpdate(effective.stream().map(MesProBagCodeDO::getId).toList());
        if (locked.size() != effective.size() || locked.stream().anyMatch(b -> Integer.valueOf(30).equals(b.getStatus()) || b.getInboundEventId() == null))
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .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();
        List<Long> expectedChunk = pendingPreview.stream().limit(MAX_BATCH_SIZE).map(MesProBagCodeDO::getId).toList();
        if (expectedChunk.isEmpty() || !confirmed.equals(expectedChunk))
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        List<MesProBagCodeDO> locked = bagCodeMapper.selectListByIdsForUpdate(confirmed);
        if (locked.size() != confirmed.size() || locked.stream().anyMatch(b -> Integer.valueOf(30).equals(b.getStatus()) || b.getInboundEventId() == null))
            throw new IllegalArgumentException("袋码已变化或尚未全部入库,请重新扫描核对");
        // 在袋码锁之后重新核对归属;其他绑定操作不得改变已确认的检验范围。
        Scope current = scope(req.getCode(), req.getScanType(), false);
        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.equals(new HashSet<>(locked.stream().map(MesProBagCodeDO::getId).toList())))
        if (!currentIds.containsAll(confirmed))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描核对");
        Set<String> inspected = qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesProBagCodeDO> pending = locked.stream().filter(b -> !inspected.contains(b.getCode())).sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        List<Long> confirmed = req.getBagIds().stream().sorted().toList();
        if (pending.isEmpty() || !confirmed.equals(pending.stream().map(MesProBagCodeDO::getId).toList()))
        if (!qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).isEmpty())
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        for (MesProBagCodeDO bag : pending) {
            MesQcSeedQualitySubmitReqVO single = new MesQcSeedQualitySubmitReqVO();
            single.setCode(bag.getCode());single.setIndicators(req.getIndicators());single.setReason(req.getReason());
            single.setQualifiedWarehouseId(req.getQualifiedWarehouseId());
            // 外层事务覆盖全部袋码;任何一袋库存流转失败都会回滚整次操作。
            submit(single);
        }
        List<MesProBagCodeDO> pending = locked.stream().sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        submitInitialBatch(pending, req);
        return Map.of("processedCount", pending.size());
    }
    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("未根据袋码解析到物料信息");
        if (Integer.valueOf(40).equals(batch.getStatus())) throw new IllegalArgumentException("生产批次已作废,无法质检");
        MesWmBatchDO wmBatch = mesWmBatchMapper.selectByCode(batch.getCode());
        if (wmBatch == null || materialStockMapper.selectTempStockCountByBatchId(wmBatch.getId()) == 0)
            throw new IllegalArgumentException("该批次库存不在暂存库中,无法进行质检");
        if (warehouseService.getFirstQualifiedWarehouse() == null) throw exception(WM_QUALIFIED_WAREHOUSE_NOT_CONFIGURED);
        MesWmWarehouseDO qualifiedWarehouse = warehouseService.getWarehouse(req.getQualifiedWarehouseId());
        if (qualifiedWarehouse == null) throw exception(WM_WAREHOUSE_NOT_EXISTS);
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType()))
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        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();
        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<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        for (MesQcSeedQualityDO quality : qualities) {
            for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
                indicators.add(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .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());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
    }
    @Override @Transactional(rollbackFor = Exception.class)
@@ -210,8 +260,13 @@
                .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());
@@ -219,10 +274,10 @@
        if (!codes.equals(REQUIRED)) throw new IllegalArgumentException("四项指标不完整");
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        quality.setResult(passed ? 1 : 2);
        quality.setWorkflowStatus(passed ? WAIT_PROCESS : WAIT_RETEST);
        quality.setWorkflowStatus(passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW);
        qualityMapper.updateById(quality);
        recordAction(quality.getId(), "INITIAL", quality.getResult(), reqVO.getReason(), reqVO.getIndicators());
        // 初检仅记录结论;袋仍留在暂存库,待复检及两级审批后再放行或报废。
        // 初检仅记录结论;袋仍留在暂存库,待初检审核、复检及最终审核后再放行或报废。
        return quality.getId();
    }
@@ -310,15 +365,24 @@
    @Override @Transactional(rollbackFor = Exception.class)
    public void retest(Long id, cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRetestReqVO req) {
        MesQcSeedQualityDO quality = requireStatus(id, WAIT_RETEST);
        if (!Objects.equals(quality.getCode(), req.getCode())) throw new IllegalArgumentException("复检袋码与原质检记录不一致");
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
            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, "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim()).retestResult(passed ? 1 : 2).retestedAt(LocalDateTime.now())
        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(passed ? WAIT_PROCESS : WAIT_PROCESS_FAIL).build());
                .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))
@@ -332,12 +396,146 @@
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void retestBatch(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRetestReqVO req) {
        List<Long> ids = approvalIds(req.getIds());
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(ids);
        validateLockedIds(ids, qualities);
        String batchCode = null;
        Integer workflowStatus = null;
        boolean first = true;
        for (MesQcSeedQualityDO quality : qualities) {
            if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            if (StrUtil.isBlank(quality.getBatchCode()))
                throw new IllegalArgumentException("记录缺少生产批次,不能批量质检");
            if (first) {
                batchCode = quality.getBatchCode();
                workflowStatus = quality.getWorkflowStatus();
                first = false;
            } else if (!Objects.equals(batchCode, quality.getBatchCode()) || !Objects.equals(workflowStatus, quality.getWorkflowStatus())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同流程状态的记录");
            }
        }
        applyRetestBatch(qualities, req.getIndicators(), req.getReason());
    }
    private void checkRetestScanType(String scanType) {
        if (!Set.of("PALLET", "BATCH").contains(scanType))
            throw new IllegalArgumentException("批量质检只支持托盘码或生产批次码");
    }
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType) {
        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<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))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> pendingProcessIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_PROCESS))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> pendingInitialFailIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_FAIL_REVIEW))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> pendingCompanyIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> pendingCompanyFailIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY_FAIL))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        Map<String, Object> result = new HashMap<>();
        result.put("code", scope.code()); result.put("scanType", scanType); result.put("batchCode", scope.batchCode());
        result.put("totalCount", codes.size()); result.put("retestIds", retestIds);
        result.put("initialRecheckIds", initialRecheckIds);
        result.put("pendingProcessIds", pendingProcessIds); result.put("pendingInitialFailIds", pendingInitialFailIds);
        result.put("pendingCompanyIds", pendingCompanyIds); result.put("pendingCompanyFailIds", pendingCompanyFailIds);
        result.put("pendingProcessCount", pendingProcessIds.size() + pendingInitialFailIds.size());
        result.put("pendingCompanyCount", pendingCompanyIds.size() + pendingCompanyFailIds.size());
        return result;
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> retestScope(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO req) {
        checkRetestScanType(req.getScanType());
        if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(req.getWorkflowStatus()))
            throw new IllegalArgumentException("批量质检状态无效");
        List<Long> confirmed = approvalIds(req.getQualityIds());
        Scope scope = scope(req.getCode(), req.getScanType(), false);
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<MesProBagCodeDO> effective = scope.bags().stream()
                .filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() != null).toList();
        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()))
                .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))
            throw new IllegalArgumentException("待质检清单已变化,请重新扫描核对");
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, pending);
        if (pending.stream().anyMatch(quality -> !Objects.equals(quality.getWorkflowStatus(), req.getWorkflowStatus())
                || !effectiveCodes.contains(quality.getCode())))
            throw new IllegalArgumentException("待质检记录状态或归属已变化,请重新扫描核对");
        applyRetestBatch(pending, req.getIndicators(), req.getReason());
        return Map.of("processedCount", pending.size());
    }
    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);
        }
        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<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        for (MesQcSeedQualityDO quality : qualities) {
            boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
            if (!initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            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();
            if (!initialRecheck) {
                update.setRetestResult(result);
                update.setRetestedAt(now);
            }
            qualityUpdates.add(update);
            Map<String, MesQcSeedQualityIndicatorDO> existing = existingByQuality.getOrDefault(quality.getId(), Map.of());
            for (MesQcSeedQualitySubmitReqVO.Indicator item : submittedIndicators) {
                MesQcSeedQualityIndicatorDO indicator = existing.get(item.getCode());
                if (indicator == null) throw new IllegalArgumentException("质检指标记录缺失");
                indicatorUpdates.add(MesQcSeedQualityIndicatorDO.builder().id(indicator.getId())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            actions.add(newAction(quality.getId(), initialRecheck ? "INITIAL_RECHECK" : "RETEST", result,
                    reason, submittedIndicators, operatorId, now));
        }
        qualityMapper.updateBatch(qualityUpdates, WRITE_BATCH_SIZE);
        indicatorMapper.updateBatch(indicatorUpdates, WRITE_BATCH_SIZE * REQUIRED.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void processApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_PROCESS, WAIT_PROCESS_FAIL).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行加工部审批");
        if (StrUtil.isBlank(opinion)) throw new IllegalArgumentException("请填写审批意见");
        int next = approved ? (quality.getWorkflowStatus() == WAIT_PROCESS ? WAIT_COMPANY : WAIT_COMPANY_FAIL) : WAIT_RETEST;
        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());
        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);
@@ -347,8 +545,10 @@
    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()))
            throw new IllegalArgumentException("当前状态不能进行公司质检审批");
        if (StrUtil.isBlank(opinion)) throw new IllegalArgumentException("请填写审批意见");
            throw new IllegalArgumentException("当前状态不能进行复检审核");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        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)
@@ -358,20 +558,63 @@
    @Override @Transactional(rollbackFor = Exception.class)
    public void processApproveBatch(List<Long> ids, boolean approved, String opinion) {
        for (Long id : approvalIds(ids)) processApprove(id, approved, opinion);
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        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(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        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(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApproveBatch(List<Long> ids, boolean approved, String opinion) {
        for (Long id : approvalIds(ids)) companyApprove(id, approved, opinion);
        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("所选记录包含不能进行复检审核的状态,请刷新后重试");
        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);
        }
        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(),
                WRITE_BATCH_SIZE);
    }
    private List<Long> approvalIds(List<Long> ids) {
        if (ids == null || ids.isEmpty() || ids.size() > 500 || ids.stream().anyMatch(Objects::isNull))
            throw new IllegalArgumentException("请选择 1 至 500 条质检记录");
        return batchIds(ids, "质检记录");
    }
    private List<Long> batchIds(List<Long> ids, String label) {
        if (ids == null || ids.isEmpty() || ids.size() > MAX_BATCH_SIZE || ids.stream().anyMatch(Objects::isNull))
            throw new IllegalArgumentException("请选择 1 至 " + MAX_BATCH_SIZE + " 条" + label);
        List<Long> sorted = ids.stream().distinct().sorted().toList();
        if (sorted.size() != ids.size()) throw new IllegalArgumentException("质检记录不能重复选择");
        if (sorted.size() != ids.size()) throw new IllegalArgumentException(label + "不能重复选择");
        return sorted;
    }
    private void validateLockedIds(List<Long> expectedIds, List<MesQcSeedQualityDO> qualities) {
        List<Long> actualIds = qualities.stream().map(MesQcSeedQualityDO::getId).sorted().toList();
        if (!expectedIds.equals(actualIds)) throw new IllegalArgumentException("质检记录不存在或已变化,请刷新后重试");
    }
    private MesQcSeedQualityDO requireStatus(Long id, int expected) {
@@ -390,6 +633,9 @@
                    || 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");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
        }
        return passed;
@@ -397,15 +643,21 @@
    private void recordAction(Long id, String action, Integer result, String opinion,
                              List<MesQcSeedQualitySubmitReqVO.Indicator> indicators) {
        actionMapper.insert(newAction(id, action, result, opinion, indicators, getLoginUserId(), LocalDateTime.now()));
    }
    private MesQcSeedQualityActionDO newAction(Long id, String action, Integer result, String opinion,
                                               List<MesQcSeedQualitySubmitReqVO.Indicator> indicators,
                                               Long operatorId, LocalDateTime createTime) {
        MesQcSeedQualityActionDO event = new MesQcSeedQualityActionDO();
        event.setQualityId(id);
        event.setAction(action);
        event.setResult(result);
        event.setOpinion(opinion);
        event.setIndicatorsJson(indicators == null ? null : JSONUtil.toJsonStr(indicators));
        event.setOperatorId(getLoginUserId());
        event.setCreateTime(LocalDateTime.now());
        actionMapper.insert(event);
        event.setOperatorId(operatorId);
        event.setCreateTime(createTime);
        return event;
    }
    /**