云
9 小时以前 c215f6c20445b973ff730dcdf4206426992da81d
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -1,66 +1,497 @@
package cn.iocoder.yudao.module.mes.service.qc.seedquality;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
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.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.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.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseLocationDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.*;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.batch.MesWmBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
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.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.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.materialstock.MesWmMaterialStockService;
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;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseLocationService;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
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;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_WAREHOUSE_TYPE_NOT_QUALIFIED;
@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;
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private MesQcSeedQualityActionMapper actionMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
    @Resource private MesProBagCodeMapper bagCodeMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper palletMapper;
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper batchMapper;
    @Resource private MesProBatchService proBatchService;
    @Resource private MesWmBatchService mesWmBatchService;
    @Resource private MesWmMaterialStockService materialStockService;
    @Resource private MesWmBatchMapper mesWmBatchMapper;
    @Resource private MesWmMaterialStockMapper materialStockMapper;
    @Resource private MesWmMaterialStockBagMapper materialStockBagMapper;
    @Resource private MesWmMaterialStockBagService materialStockBagService;
    @Resource private MesWmTransactionService wmTransactionService;
    @Resource private MesWmWarehouseService warehouseService;
    @Resource private MesWmWarehouseLocationService warehouseLocationService;
    @Resource private MesWmWarehouseAreaService warehouseAreaService;
    @Override
    public PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO) {
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(reqVO.getCode());
            if (bag != null) reqVO.setCode(bag.getCode());
        }
        return qualityMapper.selectPage(reqVO);
    }
    @Override
    public Map<String, Object> resolveScan(String code) {
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
        if (bag == null) throw new IllegalArgumentException("未解析到有效袋码,请扫描袋码");
        Map<String, Object> result = new HashMap<>();
        result.put("code", bag.getCode());
        result.put("businessCode", bag.getBusinessCode());
        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());
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        return result;
    }
    private record Scope(String code, String businessCode, String batchCode, boolean canceled, List<MesProBagCodeDO> bags) { }
    private Scope scope(String code, String type, boolean lock) {
        if ("BAG".equals(type)) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
            if (bag == null) throw new IllegalArgumentException("未找到袋码,请核对扫码类型");
            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("托盘尚未绑定生产批次,无法质检");
            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("未找到生产批次码,请核对扫码类型");
            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("不支持的质检扫码类型");
    }
    @Override
    public Map<String, Object> resolveScan(String code, String scanType) {
        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()
                .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());
        result.put("batchCode", scope.batchCode()); result.put("canceled", scope.canceled());
        result.put("totalCount", effective.size());
        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());
        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());
        }
        return result;
    }
    @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<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))
            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())))
            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()))
            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);
        }
        return Map.of("processedCount", pending.size());
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public Long submit(MesQcSeedQualitySubmitReqVO reqVO) {
        if (reqVO.getIndicators().size() != 4) throw new IllegalArgumentException("必须提交四项指标");
        if (qualityMapper.selectByCode(reqVO.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        // 种子质检必须以袋码(单袋)提交,物料信息由袋码自动解析,前端不再单独选择物料
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(reqVO.getCode());
        if (bag == null) {
            throw new IllegalArgumentException("未解析到有效袋码,种子质检必须以袋码提交");
        }
        if (Integer.valueOf(30).equals(bag.getStatus()) || bag.getInboundEventId() == null)
            throw new IllegalArgumentException("袋码已作废或尚未入库,无法质检");
        // 双码锁定同一袋,并以追溯码记录结果,防止换码或并发重复扣减库存。
        if (qualityMapper.selectByCodeForUpdate(bag.getCode()) != null) throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        MesProBatchDO batch = bag.getBatchId() == null ? null : proBatchService.getBatch(bag.getBatchId());
        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("该袋码/批次对应的库存不在暂存库中,无法进行质检");
        }
        // 校验目标合格库:必须存在且仓库类型为合格库(20)
        if (warehouseService.getFirstQualifiedWarehouse() == null) {
            throw exception(WM_QUALIFIED_WAREHOUSE_NOT_CONFIGURED);
        }
        MesWmWarehouseDO qualifiedWarehouse = warehouseService.getWarehouse(reqVO.getQualifiedWarehouseId());
        if (qualifiedWarehouse == null) {
            throw exception(WM_WAREHOUSE_NOT_EXISTS);
        }
        if (!MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType())) {
            throw exception(WM_WAREHOUSE_TYPE_NOT_QUALIFIED);
        }
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(reqVO.getCode()).itemId(reqVO.getItemId())
                .batchCode(reqVO.getBatchCode()).reason(reqVO.getReason()).result(0)
        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 (!REQUIRED.contains(item.getCode()) || !codes.add(item.getCode())) throw new IllegalArgumentException("指标必须为纯度、净度、发芽率、水分且不可重复");
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0 || item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("指标下限不能大于上限");
            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("四项指标不完整");
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        quality.setResult(passed ? 1 : 2);
        quality.setWorkflowStatus(passed ? WAIT_PROCESS : WAIT_RETEST);
        qualityMapper.updateById(quality);
        // 质检合格 → 库存整批由暂存库(待质检)转为合格库(正式库存)
        recordAction(quality.getId(), "INITIAL", quality.getResult(), reqVO.getReason(), reqVO.getIndicators());
        // 初检仅记录结论;袋仍留在暂存库,待复检及两级审批后再放行或报废。
        return quality.getId();
    }
    private void finalizeStock(MesQcSeedQualityDO quality, boolean passed) {
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCodeForUpdate(quality.getCode());
        if (bag == null || bag.getInboundEventId() == null || !Objects.equals(bag.getQcStatus(), MesProBagCodeQcStatusEnum.UNCHECKED.getStatus()))
            throw new IllegalArgumentException("袋码状态已变化,禁止重复库存流转");
        MesProBatchDO batch = proBatchService.getBatch(bag.getBatchId());
        if (batch == null) throw new IllegalArgumentException("袋码所属生产批次不存在");
        MesWmBatchDO wmBatch = mesWmBatchMapper.selectByCode(batch.getCode());
        if (wmBatch == null) throw new IllegalArgumentException("暂存库存所属仓库批次不存在");
        MesWmWarehouseDO qualifiedWarehouse = warehouseService.getWarehouse(quality.getQualifiedWarehouseId());
        if (passed && (qualifiedWarehouse == null || Boolean.TRUE.equals(qualifiedWarehouse.getFrozen())
                || !MesWmWarehouseTypeEnum.QUALIFIED.getType().equals(qualifiedWarehouse.getType())))
            throw new IllegalArgumentException("目标合格库不存在或类型已变化");
        Long qualifiedLocationId = null;
        Long qualifiedAreaId = null;
        if (passed) {
            MesProBatchDO proBatch = proBatchService.getBatchByCode(reqVO.getBatchCode());
            if (proBatch != null) {
                MesWmBatchDO wmBatch = mesWmBatchService.getOrCreateByProBatch(proBatch);
                if (wmBatch != null) {
                    int updated = materialStockService.updateStockStateByBatchId(wmBatch.getId(),
                            MesWmStockStateEnum.QUALIFIED.getState(), MesWmStockStateEnum.TEMP.getState());
                    log.info("[submit][质检合格,批次 {} 库存由暂存库转合格库,影响 {} 条]", reqVO.getBatchCode(), updated);
            List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId());
            if (CollUtil.isNotEmpty(locations)) {
                qualifiedLocationId = locations.get(0).getId();
                List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(qualifiedLocationId);
                if (CollUtil.isNotEmpty(areas)) qualifiedAreaId = areas.get(0).getId();
            }
        }
        // 按袋码粒度流转库存:锁定该袋在暂存库中的活动明细,禁止按批次任意扣减
        MesWmMaterialStockBagDO stockBag = materialStockBagMapper.selectActiveByBagCode(bag.getCode());
        MesWmMaterialStockDO tempStock = resolveTempStock(bag, wmBatch, stockBag);
        // A. 暂存出库(-1):该袋离开暂存库,写入质检暂存出库事务(跳过袋码自动维护,由质检统一控制袋码最终落点)
        MesWmTransactionSaveReqDTO tempOutReq = new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.OUT.getType())
                .setBizType(MesBizTypeConstants.QC_SEED_QUALITY)
                .setBizId(quality.getId())
                .setBizCode(quality.getCode())
                .setBizLineId(quality.getId())
                .setItemId(batch.getItemId())
                .setQuantity(BigDecimal.ONE.negate())
                .setBatchId(tempStock.getBatchId())
                .setBatchCode(tempStock.getBatchCode())
                .setBagCodeId(bag.getId())
                .setBagCode(bag.getCode())
                .setWarehouseId(tempStock.getWarehouseId())
                .setLocationId(tempStock.getLocationId())
                .setAreaId(tempStock.getAreaId())
                .setVendorId(tempStock.getVendorId())
                .setMaterialStockId(tempStock.getId())
                .setCheckFlag(true)
                .setSkipBagManage(true);
        Long tempOutTxId = wmTransactionService.createTransaction(tempOutReq);
        if (passed) {
            // B1. 质检合格 → 该袋转入操作员选择的合格库,写入合格入库事务(跳过袋码自动维护)
            MesWmMaterialStockDO qualifiedStock = getOrCreateQualifiedStock(tempStock, qualifiedWarehouse.getId(), qualifiedLocationId, qualifiedAreaId);
            MesWmTransactionSaveReqDTO qualInReq = new MesWmTransactionSaveReqDTO()
                    .setType(MesWmTransactionTypeEnum.IN.getType())
                    .setBizType(MesBizTypeConstants.QC_SEED_QUALITY)
                    .setBizId(quality.getId())
                    .setBizCode(quality.getCode())
                    .setBizLineId(quality.getId())
                    .setItemId(batch.getItemId())
                    .setQuantity(BigDecimal.ONE)
                    .setBatchId(qualifiedStock.getBatchId())
                    .setBatchCode(qualifiedStock.getBatchCode())
                    .setBagCodeId(bag.getId())
                    .setBagCode(bag.getCode())
                    .setWarehouseId(qualifiedStock.getWarehouseId())
                    .setLocationId(qualifiedStock.getLocationId())
                    .setAreaId(qualifiedStock.getAreaId())
                    .setVendorId(qualifiedStock.getVendorId())
                    .setMaterialStockId(qualifiedStock.getId())
                    .setCheckFlag(false)
                    .setStockState(MesWmStockStateEnum.QUALIFIED.getState())
                    .setSkipBagManage(true);
            Long qualInTxId = wmTransactionService.createTransaction(qualInReq);
            // B2. 同步迁移袋码明细到合格库存落点
            materialStockBagService.moveIn(qualInReq, qualifiedStock, qualInTxId);
            bagCodeMapper.updateQcStatus(bag.getId(), MesProBagCodeQcStatusEnum.QUALIFIED.getStatus());
            log.info("[finalizeStock][审批放行,袋码 {} 由暂存库转合格库 {}]", bag.getCode(), qualifiedWarehouse.getName());
        } else {
            // C. 质检不合格 → 该袋报废;暂存出库事务已扣减暂存库存,标记袋码报废
            materialStockBagService.scrap(tempOutReq, tempOutTxId);
            bagCodeMapper.updateQcStatus(bag.getId(), MesProBagCodeQcStatusEnum.UNQUALIFIED.getStatus());
            log.info("[finalizeStock][审批报废,袋码 {}]", bag.getCode());
        }
    }
    @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("复检袋码与原质检记录不一致");
        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())
                .inspectorId(getLoginUserId()).inspectedAt(LocalDateTime.now())
                .workflowStatus(passed ? WAIT_PROCESS : WAIT_PROCESS_FAIL).build());
        // 页面、公开追溯读取主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        Map<String, MesQcSeedQualityIndicatorDO> existing = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityId(id))
            existing.put(indicator.getIndicatorCode(), indicator);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
            MesQcSeedQualityIndicatorDO indicator = existing.get(item.getCode());
            if (indicator == null) throw new IllegalArgumentException("质检指标记录缺失");
            indicatorMapper.updateById(MesQcSeedQualityIndicatorDO.builder().id(indicator.getId())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
    }
    @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;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(id).workflowStatus(next)
                .processApprovedAt(LocalDateTime.now()).build());
        recordAction(id, approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null);
    }
    @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()))
            throw new IllegalArgumentException("当前状态不能进行公司质检审批");
        if (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);
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void processApproveBatch(List<Long> ids, boolean approved, String opinion) {
        for (Long id : approvalIds(ids)) processApprove(id, approved, opinion);
    }
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApproveBatch(List<Long> ids, boolean approved, String opinion) {
        for (Long id : approvalIds(ids)) companyApprove(id, approved, opinion);
    }
    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 条质检记录");
        List<Long> sorted = ids.stream().distinct().sorted().toList();
        if (sorted.size() != ids.size()) throw new IllegalArgumentException("质检记录不能重复选择");
        return sorted;
    }
    private MesQcSeedQualityDO requireStatus(Long id, int expected) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Objects.equals(quality.getWorkflowStatus(), expected))
            throw new IllegalArgumentException("质检记录状态已变化,请刷新后重试");
        return quality;
    }
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators) {
        if (indicators == null || indicators.size() != REQUIRED.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())
                    || 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(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
        }
        return passed;
    }
    private void recordAction(Long id, String action, Integer result, String opinion,
                              List<MesQcSeedQualitySubmitReqVO.Indicator> indicators) {
        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);
    }
    /**
     * 定位该袋的暂存库存汇总记录(只定位,不扣减;扣减由质检暂存出库事务完成)。
     * <p>
     * 优先严格按袋码锁定其活动明细对应的暂存记录;历史无明细时回退按批次定位并补建明细。
     */
    private MesWmMaterialStockDO resolveTempStock(MesProBagCodeDO bag, MesWmBatchDO wmBatch, MesWmMaterialStockBagDO stockBag) {
        if (stockBag != null && stockBag.getMaterialStockId() != null) {
            MesWmMaterialStockDO stock = materialStockMapper.selectById(stockBag.getMaterialStockId());
            if (stock != null && MesWmStockStateEnum.TEMP.getState().equals(stock.getStockState())) {
                return stock;
            }
        }
        MesWmMaterialStockDO tempStock = materialStockMapper.selectStockListByBatchId(wmBatch.getId()).stream()
                .filter(s -> MesWmStockStateEnum.TEMP.getState().equals(s.getStockState()))
                .max(Comparator.comparing(MesWmMaterialStockDO::getQuantity))
                .orElse(null);
        if (tempStock == null) {
            throw new IllegalArgumentException("该批次暂存库存不足,无法质检");
        }
        getOrCreateStockBag(bag, tempStock);
        return tempStock;
    }
    /**
     * 质检合格:将质检通过的那袋转入操作员选择的合格库(同组合键合格库记录 +1,不存在则新建)。
     *
     * @param tempStock            从暂存库扣减后返回的暂存记录(携带 item/batch/vendor 维度)
     * @param qualifiedWarehouseId 目标合格库仓库编号
     * @param qualifiedLocationId  目标合格库库区编号(未配置则为 null)
     * @param qualifiedAreaId      目标合格库库位编号(未配置则为 null)
     */
    private MesWmMaterialStockBagDO getOrCreateStockBag(MesProBagCodeDO bag, MesWmMaterialStockDO tempStock) {
        MesWmMaterialStockBagDO stockBag = materialStockBagMapper.selectByBagCode(bag.getCode());
        if (stockBag != null) {
            return stockBag;
        }
        stockBag = MesWmMaterialStockBagDO.builder().materialStockId(tempStock.getId())
                .bagCodeId(bag.getId()).bagCode(bag.getCode()).itemId(tempStock.getItemId())
                .batchId(tempStock.getBatchId()).batchCode(tempStock.getBatchCode()).vendorId(tempStock.getVendorId())
                .warehouseId(tempStock.getWarehouseId()).locationId(tempStock.getLocationId()).areaId(tempStock.getAreaId())
                .stockState(MesWmStockStateEnum.TEMP.getState()).status(1).quantity(BigDecimal.ONE)
                .frozen(false).inboundTime(tempStock.getReceiptTime()).build();
        materialStockBagMapper.insert(stockBag);
        return stockBag;
    }
    private MesWmMaterialStockDO getOrCreateQualifiedStock(MesWmMaterialStockDO tempStock, Long qualifiedWarehouseId,
                                         Long qualifiedLocationId, Long qualifiedAreaId) {
        MesWmMaterialStockDO qualified = materialStockMapper.selectQualifiedByCompositeKey(
                tempStock.getItemId(), qualifiedWarehouseId, qualifiedLocationId,
                qualifiedAreaId, tempStock.getBatchId(), tempStock.getVendorId());
        if (qualified != null) {
            return qualified;
        }
        // 不存在则新建合格库记录(仓库/库区/库位采用所选合格库的维度,数量由质检入库事务 +1)
        MesWmMaterialStockDO newStock = MesWmMaterialStockDO.builder()
                .itemId(tempStock.getItemId()).itemTypeId(tempStock.getItemTypeId())
                .warehouseId(qualifiedWarehouseId).locationId(qualifiedLocationId).areaId(qualifiedAreaId)
                .batchId(tempStock.getBatchId()).batchCode(tempStock.getBatchCode()).vendorId(tempStock.getVendorId())
                .quantity(BigDecimal.ZERO).receiptTime(LocalDateTime.now())
                .stockState(MesWmStockStateEnum.QUALIFIED.getState())
                .frozen(false)
                .build();
        materialStockMapper.insert(newStock);
        return newStock;
    }
    /**
     * 由袋码或生产批次解析所属生产批次(袋码优先,批次码兼容临时码)
     */
    private MesProBatchDO resolveBatch(String code, String batchCode) {
        if (StrUtil.isNotBlank(code)) {
            MesProBagCodeDO bag = bagCodeMapper.selectByCode(code);
            if (bag != null && bag.getBatchId() != null) {
                MesProBatchDO batch = proBatchService.getBatch(bag.getBatchId());
                if (batch != null) {
                    return batch;
                }
            }
        }
        // 库位由后台配置服务接管,当前版本保留审计闭环,禁止前端传入库位。
        return quality.getId();
        if (StrUtil.isNotBlank(batchCode)) {
            return proBatchService.getBatchByCode(batchCode);
        }
        return null;
    }
    @Override
@@ -84,4 +515,9 @@
            return List.of();
        }
    }
    @Override
    public List<MesQcSeedQualityActionDO> getActions(Long qualityId) {
        return actionMapper.selectByQualityId(qualityId);
    }
}