云
3 小时以前 724a3d9d2cdb001bd9fae4eaea0ffabfa5ffaf6d
feat(mes): 完善种子质检功能实现

- 新增质检流程留痕功能,支持查询质检操作历史记录
- 实现批量质检提交功能,优化单个质检提交性能
- 添加质检指标范围配置管理功能
- 集成用户信息服务,支持质检操作人员信息展示
- 扩展权限控制,支持多种质检场景权限配置
- 优化袋码、托盘码、批次码解析逻辑
- 实现质检记录锁定机制,防止并发操作冲突
- 添加质检数据验证和异常处理机制
- 完善单元测试覆盖,确保代码质量稳定性
已添加2个文件
已修改6个文件
537 ■■■■■ 文件已修改
docs/seed_quality_action_operator_integration.md 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/mobile-quality-sales-smoke.cjs 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityActionRespVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityIndicatorMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/seed_quality_action_operator_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
# ç§å­è´¨æ£€æµç¨‹è®°å½•操作人昵称 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- è´¨é‡ç®¡ç† > ç§å­è´¨æ£€ > è´¨æ£€è®°å½•(流程记录弹窗)
## èƒŒæ™¯
流程记录此前直接展示 `operatorId`(如“操作人 1”),需要展示用户昵称。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/qc/seed-quality/actions?id={qualityId}` | èŽ·å¾—ç§å­è´¨æ£€æµç¨‹ç•™ç—• |
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| operatorName | String | æ“ä½œäººæ˜µç§°ï¼Œå–系统用户昵称;用户不存在时为空 |
**响应示例:**
```json
{
  "code": 0,
  "data": [
    {
      "id": 1,
      "qualityId": 192,
      "action": "INITIAL",
      "result": 1,
      "opinion": "",
      "indicatorsJson": "[...]",
      "operatorId": 1,
      "operatorName": "超级管理员",
      "createTime": "2026-10-20 10:29:30"
    }
  ]
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| operatorName | æµç¨‹è®°å½•弹窗每条记录头部 | ä¸Žæ“ä½œæ—¶é—´åŒè¡Œå±•示为“操作人 xxx” |
| operatorId | å…œåº• | `operatorName` ä¸ºç©ºæ—¶å±•示为“操作人 {id}”,再为空显示“—” |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| æ“ä½œäººå·²ç¦»èŒ/删除 | `operatorName` ä¸ºç©ºï¼Œå‰ç«¯å›žé€€å±•示 `operatorId` |
| ä¸€æ¬¡æŸ¥è¯¢å¤šæ¡ç•™ç—• | åŽç«¯ç»Ÿä¸€æ‰¹é‡æŸ¥è¯¢ç”¨æˆ·ï¼Œä»…增加一次用户查询调用 |
## æ³¨æ„äº‹é¡¹
- è¯¥æŽ¥å£ä»…新增响应字段,不改变请求参数,旧前端可继续使用。
- éƒ¨ç½²éœ€é‡å¯åŽç«¯ï¼Œæ˜µç§°æ‰ä¼šè¿”回。
scripts/mobile-quality-sales-smoke.cjs
@@ -74,7 +74,9 @@
    await page.getByRole('heading', { name: '质检结果待确认' }).waitFor(); assert.equal(writes.length, 1); assert.equal(writes[0].quality.code, 'TRACE-1');
    await page.reload(); await page.getByRole('button', { name: '核实质检结果' }).click();
    await page.getByText(/已核实:本次袋码均已有质检记录/).waitFor(); assert.equal(writes.length, 1, 'Verification must not resubmit quality');
    await page.goto(base + '/mobile/quality-records'); await page.getByText('合格', { exact: true }).last().waitFor();
    await page.goto(base + '/mobile/quality-records');
    await page.getByRole('heading', { name: '批量检验与审核', exact: true }).waitFor();
    await page.getByText('合格', { exact: true }).last().waitFor();
    await capture('quality-records-480.png');
    for (const type of ['PALLET', 'BATCH']) {
      quality = null;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/MesQcSeedQualityController.java
@@ -12,7 +12,11 @@
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRetestReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedIndicatorRangeSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityActionRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityActionDO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedQualityService;
import cn.iocoder.yudao.module.mes.service.qc.seedquality.MesQcSeedIndicatorRangeService;
@@ -36,6 +40,7 @@
    @Resource private MesQcSeedQualityService service;
    @Resource private MesQcSeedIndicatorRangeService rangeService;
    @Resource private MesProBagCodeMapper bagCodeMapper;
    @Resource private AdminUserApi adminUserApi;
    @GetMapping("/ranges") @Operation(summary = "获得种子质检合格范围")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-range:query')")
    public CommonResult<java.util.List<cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedIndicatorRangeDO>> ranges() {
@@ -50,7 +55,7 @@
    }
    @GetMapping("/resolve")
    @Operation(summary = "解析待质检袋码及确认该袋质检结果")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:submit')")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<java.util.Map<String, Object>> resolve(@RequestParam String code, @RequestParam(defaultValue = "BAG") String scanType) {
        return success(service.resolveScan(code, scanType));
    }
@@ -80,8 +85,15 @@
    }
    @GetMapping("/actions") @Operation(summary = "获得种子质检流程留痕")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:query')")
    public CommonResult<java.util.List<cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityActionDO>> actions(@RequestParam Long id) {
        return success(service.getActions(id));
    public CommonResult<List<MesQcSeedQualityActionRespVO>> actions(@RequestParam Long id) {
        List<MesQcSeedQualityActionDO> actions = service.getActions(id);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(actions.stream()
                .map(MesQcSeedQualityActionDO::getOperatorId).filter(java.util.Objects::nonNull)
                .collect(Collectors.toSet()));
        return success(BeanUtils.toBean(actions, MesQcSeedQualityActionRespVO.class, vo -> {
            AdminUserRespDTO user = userMap.get(vo.getOperatorId());
            if (user != null) vo.setOperatorName(user.getNickname());
        }));
    }
    @PostMapping("/submit") @Operation(summary = "提交种子质检")
@@ -109,7 +121,7 @@
    }
    @GetMapping("/retest-scope/resolve") @Operation(summary = "按托盘码或生产批次码解析待批量质检记录")
    @PreAuthorize("@ss.hasPermission('mes:qc-seed-quality:retest')")
    @PreAuthorize("@ss.hasAnyPermissions('mes:qc-seed-quality:submit', 'mes:qc-seed-quality:retest', 'mes:qc-seed-quality:process-approve', 'mes:qc-seed-quality:company-approve')")
    public CommonResult<java.util.Map<String, Object>> resolveRetestScope(@RequestParam String code, @RequestParam String scanType) {
        return success(service.resolveRetestScope(code, scanType));
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/qc/seedquality/vo/MesQcSeedQualityActionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES ç§å­è´¨æ£€æµç¨‹è®°å½• Response VO")
@Data
public class MesQcSeedQualityActionRespVO {
    @Schema(description = "编号")
    private Long id;
    @Schema(description = "质检记录编号")
    private Long qualityId;
    @Schema(description = "操作类型")
    private String action;
    @Schema(description = "检验结果")
    private Integer result;
    @Schema(description = "审批意见 / å¤‡æ³¨")
    private String opinion;
    @Schema(description = "指标快照")
    private String indicatorsJson;
    @Schema(description = "操作人编号")
    private Long operatorId;
    @Schema(description = "操作人昵称")
    private String operatorName;
    @Schema(description = "操作时间")
    private LocalDateTime createTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityIndicatorMapper.java
@@ -11,4 +11,11 @@
                .eq(MesQcSeedQualityIndicatorDO::getQualityId, qualityId)
                .orderByAsc(MesQcSeedQualityIndicatorDO::getId));
    }
    default List<MesQcSeedQualityIndicatorDO> selectListByQualityIds(java.util.Collection<Long> qualityIds) {
        if (qualityIds.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityIndicatorDO>()
                .in(MesQcSeedQualityIndicatorDO::getQualityId, qualityIds)
                .orderByAsc(MesQcSeedQualityIndicatorDO::getQualityId)
                .orderByAsc(MesQcSeedQualityIndicatorDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/qc/seedquality/MesQcSeedQualityMapper.java
@@ -38,6 +38,13 @@
    default MesQcSeedQualityDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<MesQcSeedQualityDO>().eq(MesQcSeedQualityDO::getId, id).last("FOR UPDATE"));
    }
    default List<MesQcSeedQualityDO> selectListByIdsForUpdate(java.util.Collection<Long> ids) {
        if (ids.isEmpty()) return List.of();
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
                .in(MesQcSeedQualityDO::getId, ids)
                .orderByAsc(MesQcSeedQualityDO::getId)
                .last("FOR UPDATE"));
    }
    default List<MesQcSeedQualityDO> selectListByBatchCode(String batchCode) {
        return selectList(new LambdaQueryWrapperX<MesQcSeedQualityDO>()
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/MesQcSeedQualityServiceImpl.java
@@ -47,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;
@@ -55,6 +56,7 @@
@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;
    @Resource private MesQcSeedQualityMapper qualityMapper;
@@ -104,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
@@ -145,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)
@@ -352,13 +398,13 @@
    @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;
        List<MesQcSeedQualityDO> qualities = new ArrayList<>();
        for (Long id : ids) {
            MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
            if (quality == null || !Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
        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("记录缺少生产批次,不能批量质检");
@@ -369,16 +415,8 @@
            } else if (!Objects.equals(batchCode, quality.getBatchCode()) || !Objects.equals(workflowStatus, quality.getWorkflowStatus())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同流程状态的记录");
            }
            qualities.add(quality);
        }
        for (MesQcSeedQualityDO quality : qualities) {
            cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRetestReqVO single =
                    new cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRetestReqVO();
            single.setCode(quality.getCode());
            single.setIndicators(req.getIndicators());
            single.setReason(req.getReason());
            retest(quality.getId(), single);
        }
        applyRetestBatch(qualities, req.getIndicators(), req.getReason());
    }
    private void checkRetestScanType(String scanType) {
@@ -390,7 +428,7 @@
    public Map<String, Object> resolveRetestScope(String code, String scanType) {
        checkRetestScanType(scanType);
        Scope scope = scope(code, scanType, false);
        if (scope.canceled()) throw new IllegalArgumentException("质检对象已作废");
        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);
@@ -398,16 +436,22 @@
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> initialRecheckIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_RECHECK))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        long pendingProcessCount = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_PROCESS)
                || Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_FAIL_REVIEW)).count();
        long pendingCompanyCount = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY)
                || Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY_FAIL)).count();
        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("pendingProcessCount", pendingProcessCount);
        result.put("pendingCompanyCount", pendingCompanyCount);
        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;
    }
@@ -417,30 +461,70 @@
        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(), true);
        if (scope.canceled()) throw new IllegalArgumentException("质检对象已作废");
        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("当前范围没有已入库的有效袋码");
        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()) && b.getInboundEventId() != null)
                .map(MesProBagCodeDO::getId).collect(java.util.stream.Collectors.toSet());
        if (!currentIds.equals(new HashSet<>(locked.stream().map(MesProBagCodeDO::getId).toList())))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描");
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList());
        List<MesQcSeedQualityDO> pending = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
        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();
        if (pending.isEmpty() || !confirmed.equals(pending.stream().map(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("待质检清单已变化,请重新扫描核对");
        for (MesQcSeedQualityDO quality : pending) {
            var single = new cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityRetestReqVO();
            single.setCode(quality.getCode()); single.setIndicators(req.getIndicators()); single.setReason(req.getReason());
            retest(quality.getId(), single);
        }
        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)
@@ -474,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) {
@@ -516,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;
    }
    /**
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/seedquality/SeedQualityGroupTest.java
@@ -5,10 +5,22 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.MesQcSeedQualityIndicatorDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedQualityActionMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedQualityIndicatorMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.seedquality.MesQcSeedQualityMapper;
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.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.warehouse.MesWmWarehouseService;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@@ -21,58 +33,104 @@
    private final MesProBatchMapper batches=mock(MesProBatchMapper.class);
    private final MesProPalletMapper pallets=mock(MesProPalletMapper.class);
    private final MesQcSeedQualityMapper qualities=mock(MesQcSeedQualityMapper.class);
    private final MesQcSeedQualityIndicatorMapper indicators=mock(MesQcSeedQualityIndicatorMapper.class);
    private final MesQcSeedQualityActionMapper actions=mock(MesQcSeedQualityActionMapper.class);
    private final MesProBatchService batchService=mock(MesProBatchService.class);
    private final MesWmBatchMapper wmBatches=mock(MesWmBatchMapper.class);
    private final MesWmMaterialStockMapper stocks=mock(MesWmMaterialStockMapper.class);
    private final MesWmWarehouseService warehouses=mock(MesWmWarehouseService.class);
    private final MesQcApproverService approvers=mock(MesQcApproverService.class);
    private final MesProBagCodeDO first=bag(1L),second=bag(2L),canceled=bag(3L).setStatus(30);
    private MesQcSeedQualityGroupSubmitReqVO req;
    private MesProBagCodeDO bag(Long id) {return new MesProBagCodeDO().setId(id).setCode("B"+id).setBatchId(10L).setPalletId(20L).setStatus(20).setInboundEventId(9L);}
    private List<MesQcSeedQualitySubmitReqVO.Indicator> validIndicators() {
        return List.of(indicator("purity"), indicator("cleanliness"), indicator("germination_rate"), indicator("moisture"));
    }
    private MesQcSeedQualitySubmitReqVO.Indicator indicator(String code) {
        var item = new MesQcSeedQualitySubmitReqVO.Indicator(); item.setCode(code);
        item.setValue(java.math.BigDecimal.ONE); item.setMinValue(java.math.BigDecimal.ZERO); item.setMaxValue(java.math.BigDecimal.TEN);
        return item;
    }
    private List<MesQcSeedQualityIndicatorDO> storedIndicators(Long... qualityIds) {
        var rows = new java.util.ArrayList<MesQcSeedQualityIndicatorDO>(); long id = 100;
        for (Long qualityId : qualityIds) for (var item : validIndicators()) rows.add(MesQcSeedQualityIndicatorDO.builder()
                .id(id++).qualityId(qualityId).indicatorCode(item.getCode()).value(item.getValue())
                .minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        return rows;
    }
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(service,"bagCodeMapper",bags);ReflectionTestUtils.setField(service,"batchMapper",batches);
        ReflectionTestUtils.setField(service,"palletMapper",pallets);ReflectionTestUtils.setField(service,"qualityMapper",qualities);
        var batch=new MesProBatchDO().setId(10L).setCode("BATCH").setBusinessCode("BATCH-BUS").setStatus(30);
        ReflectionTestUtils.setField(service,"indicatorMapper",indicators);ReflectionTestUtils.setField(service,"actionMapper",actions);
        ReflectionTestUtils.setField(service,"proBatchService",batchService);ReflectionTestUtils.setField(service,"mesWmBatchMapper",wmBatches);
        ReflectionTestUtils.setField(service,"materialStockMapper",stocks);ReflectionTestUtils.setField(service,"warehouseService",warehouses);
        ReflectionTestUtils.setField(service,"approverService",approvers);
        var batch=new MesProBatchDO().setId(10L).setCode("BATCH").setBusinessCode("BATCH-BUS").setItemId(99L).setStatus(30);
        when(batches.selectByScanCode(anyString())).thenReturn(batch);when(batches.selectByScanCodeForUpdate(anyString())).thenReturn(batch);
        when(batchService.getBatch(10L)).thenReturn(batch);
        when(wmBatches.selectByCode("BATCH")).thenReturn(new MesWmBatchDO().setId(30L).setCode("BATCH"));
        when(stocks.selectTempStockCountByBatchId(30L)).thenReturn(2L);
        var warehouse = new MesWmWarehouseDO().setId(8L).setType(MesWmWarehouseTypeEnum.QUALIFIED.getType());
        when(warehouses.getFirstQualifiedWarehouse()).thenReturn(warehouse); when(warehouses.getWarehouse(8L)).thenReturn(warehouse);
        doAnswer(invocation -> { long id = 1000; for (var quality : (java.util.Collection<MesQcSeedQualityDO>) invocation.getArgument(0)) quality.setId(id++); return true; })
                .when(qualities).insertBatch(anyCollection(), anyInt());
        var pallet=new MesProPalletDO().setId(20L).setCode("PALLET").setBusinessCode("PALLET-BUS").setBatchCode("BATCH").setBatchId(10L).setStatus(10);
        when(pallets.selectByScanCode(anyString())).thenReturn(pallet);when(pallets.selectByScanCodeForUpdate(anyString())).thenReturn(pallet);
        when(bags.selectListByBatchId(10L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByBatchIdForUpdate(10L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByPalletId(20L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByPalletIdForUpdate(20L)).thenReturn(List.of(first,second,canceled));
        when(bags.selectListByIdsForUpdate(any())).thenReturn(List.of(first,second));
        when(bags.selectListByIdsForUpdate(any())).thenAnswer(invocation -> {
            java.util.Collection<Long> ids = invocation.getArgument(0);
            return List.of(first, second).stream().filter(bag -> ids.contains(bag.getId())).toList();
        });
        req=new MesQcSeedQualityGroupSubmitReqVO();req.setCode("BATCH-BUS");req.setScanType("BATCH");req.setBagIds(List.of(1L,2L));
        req.setQualifiedWarehouseId(8L);req.setIndicators(List.of());
        doReturn(101L).when(service).submit(any(MesQcSeedQualitySubmitReqVO.class));
        req.setQualifiedWarehouseId(8L);req.setIndicators(validIndicators());
    }
    @Test void batchPreviewCountsEffectiveInspectedAndPendingBags() {
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        var result=service.resolveScan("BATCH-BUS","BATCH");assertEquals(2,result.get("totalCount"));
        assertEquals(List.of(1L),result.get("completedBagIds"));assertEquals(List.of(2L),result.get("pendingBagIds"));
        verify(service,never()).submit(any());
        verify(qualities,never()).insertBatch(anyCollection(),anyInt());
    }
    @Test void palletProcessesOnlyConfirmedUninspectedBags() {
        req.setScanType("PALLET");req.setCode("PALLET-BUS");req.setBagIds(List.of(2L));
        when(qualities.selectListByCodesForUpdate(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(MesQcSeedQualityDO.builder().code("B1").build()));
        when(qualities.selectListByCodesForUpdate(List.of("B2"))).thenReturn(List.of());
        assertEquals(1,service.submitGroup(req).get("processedCount"));
        var capture=org.mockito.ArgumentCaptor.forClass(MesQcSeedQualitySubmitReqVO.class);verify(service).submit(capture.capture());
        assertEquals("B2",capture.getValue().getCode());assertEquals(8L,capture.getValue().getQualifiedWarehouseId());
        verify(qualities).insertBatch(argThat(rows -> rows.size() == 1 && rows.iterator().next().getCode().equals("B2")
                && rows.iterator().next().getQualifiedWarehouseId().equals(8L)),eq(100));
        verify(indicators).insertBatch(argThat(rows -> rows.size() == 4),eq(400));
        verify(actions).insertBatch(argThat(rows -> rows.size() == 1),eq(100));
    }
    @Test void changedConfirmedListDoesNotSubmitAnyBag() {
        req.setBagIds(List.of(1L));assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
        req.setBagIds(List.of(1L));assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(qualities,never()).insertBatch(anyCollection(),anyInt());
    }
    @Test void unenteredBagBlocksWholeGroup() {
        second.setInboundEventId(null);assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
        second.setInboundEventId(null);assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(qualities,never()).insertBatch(anyCollection(),anyInt());
    }
    @Test void wrongTypeAndCanceledBatchCannotSubmit() {
        req.setScanType("OTHER");assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));
        req.setScanType("BATCH");when(batches.selectByScanCodeForUpdate(anyString())).thenReturn(new MesProBatchDO().setId(10L).setCode("BATCH").setStatus(40));
        assertThrows(IllegalArgumentException.class,()->service.submitGroup(req));verify(service,never()).submit(any());
        req.setScanType("OTHER");assertThrows(ServiceException.class,()->service.submitGroup(req));
        req.setScanType("BATCH");when(batches.selectByScanCode(anyString())).thenReturn(new MesProBatchDO().setId(10L).setCode("BATCH").setStatus(40));
        assertThrows(ServiceException.class,()->service.submitGroup(req));verify(qualities,never()).insertBatch(anyCollection(),anyInt());
    }
    @Test void unknownPalletReturnsActionableBadRequest() {
        when(pallets.selectByScanCode("UNKNOWN")).thenReturn(null);
        var error = assertThrows(ServiceException.class, () -> service.resolveRetestScope("UNKNOWN", "PALLET"));
        assertEquals(400, error.getCode());
        assertTrue(error.getMessage().contains("托盘码"));
    }
    @Test void singleBagUsesSameConfirmedGroupProtocol() {
        req.setScanType("BAG");req.setCode("B1");req.setBagIds(List.of(1L));
        when(bags.selectByScanCode("B1")).thenReturn(first);when(bags.selectListByIdsForUpdate(any())).thenReturn(List.of(first));
        assertEquals(1,service.submitGroup(req).get("processedCount"));verify(service).submit(any());
        when(bags.selectByScanCode("B1")).thenReturn(first);
        assertEquals(1,service.submitGroup(req).get("processedCount"));verify(qualities).insertBatch(anyCollection(),eq(100));
    }
    @Test void failingSecondBagRollsBackWholeTransaction() {
        var source=new org.h2.jdbcx.JdbcDataSource();source.setURL("jdbc:h2:mem:qcgroup"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1");
        var jdbc=new org.springframework.jdbc.core.JdbcTemplate(source);jdbc.execute("CREATE TABLE qc_result(id BIGINT)");
        doAnswer(invocation->{var single=(MesQcSeedQualitySubmitReqVO)invocation.getArgument(0);jdbc.update("INSERT INTO qc_result(id) VALUES(?)",single.getCode().equals("B1")?1:2);if(single.getCode().equals("B2"))throw new IllegalStateException("库存流转失败");return 101L;}).when(service).submit(any());
        doAnswer(invocation->{long id=1;for(var quality:(java.util.Collection<MesQcSeedQualityDO>)invocation.getArgument(0)){jdbc.update("INSERT INTO qc_result(id) VALUES(?)",id);quality.setId(id++);}return true;})
                .when(qualities).insertBatch(anyCollection(),anyInt());
        doThrow(new IllegalStateException("日志批量写入失败")).when(actions).insertBatch(anyCollection(),anyInt());
        var factory=new org.springframework.aop.framework.ProxyFactory(service);
        factory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(new org.springframework.jdbc.datasource.DataSourceTransactionManager(source),new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
        var proxy=(MesQcSeedQualityService)factory.getProxy();
@@ -86,25 +144,57 @@
        assertEquals(List.of(11L), result.get("retestIds"));
        assertEquals(List.of(12L), result.get("initialRecheckIds"));
    }
    @Test void retestScopePreviewExposesPendingReviewGroups() {
        var third = bag(3L); var fourth = bag(4L);
        when(bags.selectListByPalletId(20L)).thenReturn(List.of(first, second, third, fourth));
        when(qualities.selectListByCodes(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(20).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(25).build(),
                MesQcSeedQualityDO.builder().id(13L).code("B3").workflowStatus(30).build(),
                MesQcSeedQualityDO.builder().id(14L).code("B4").workflowStatus(60).build()));
        var result = service.resolveRetestScope("PALLET-BUS", "PALLET");
        assertEquals(List.of(11L), result.get("pendingProcessIds"));
        assertEquals(List.of(12L), result.get("pendingInitialFailIds"));
        assertEquals(List.of(13L), result.get("pendingCompanyIds"));
        assertEquals(List.of(14L), result.get("pendingCompanyFailIds"));
        assertEquals(2, result.get("pendingProcessCount"));
        assertEquals(2, result.get("pendingCompanyCount"));
    }
    @Test void retestScopeRejectsStaleConfirmedList() {
        when(qualities.selectListByCodesForUpdate(any())).thenReturn(List.of(
        when(qualities.selectListByCodes(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(10).build()));
        var request = new MesQcSeedQualityScopeRetestReqVO();
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setWorkflowStatus(10);
        request.setQualityIds(List.of(11L)); request.setIndicators(List.of());
        request.setQualityIds(List.of(11L)); request.setIndicators(validIndicators());
        assertThrows(IllegalArgumentException.class, () -> service.retestScope(request));
        verify(service, never()).retest(anyLong(), any());
        verify(indicators, never()).updateBatch(anyCollection(), anyInt());
    }
    @Test void retestScopeProcessesAllConfirmedBags() {
        when(qualities.selectListByCodesForUpdate(any())).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(10).build()));
        doNothing().when(service).retest(anyLong(), any());
        var pending = List.of(MesQcSeedQualityDO.builder().id(11L).code("B1").workflowStatus(10).build(),
                MesQcSeedQualityDO.builder().id(12L).code("B2").workflowStatus(10).build());
        when(qualities.selectListByCodes(any())).thenReturn(pending);
        when(qualities.selectListByIdsForUpdate(List.of(11L, 12L))).thenReturn(pending);
        when(indicators.selectListByQualityIds(any())).thenReturn(storedIndicators(11L, 12L));
        var request = new MesQcSeedQualityScopeRetestReqVO();
        request.setCode("BATCH-BUS"); request.setScanType("BATCH"); request.setWorkflowStatus(10);
        request.setQualityIds(List.of(11L, 12L)); request.setIndicators(List.of());
        request.setQualityIds(List.of(11L, 12L)); request.setIndicators(validIndicators());
        assertEquals(2, service.retestScope(request).get("processedCount"));
        verify(service, times(2)).retest(anyLong(), any());
        verify(indicators).selectListByQualityIds(List.of(11L, 12L));
        verify(qualities).updateBatch(argThat(rows -> rows.size() == 2), eq(100));
        verify(indicators).updateBatch(argThat(rows -> rows.size() == 8), eq(400));
        verify(actions).insertBatch(argThat(rows -> rows.size() == 2), eq(100));
    }
    @Test void processApprovalLocksAndWritesWholeBatchOnce() {
        when(qualities.selectListByIdsForUpdate(List.of(11L, 12L))).thenReturn(List.of(
                MesQcSeedQualityDO.builder().id(11L).result(1).workflowStatus(20).build(),
                MesQcSeedQualityDO.builder().id(12L).result(2).workflowStatus(25).build()));
        service.processApproveBatch(List.of(12L, 11L), true, "批量通过");
        verify(qualities).selectListByIdsForUpdate(List.of(11L, 12L));
        verify(approvers, times(1)).validateApprover(anyInt(), anyInt(), nullable(Long.class));
        verify(qualities).updateBatch(argThat(rows -> rows.size() == 2 && rows.stream().allMatch(row -> row.getWorkflowStatus() == 10)), eq(100));
        verify(actions).insertBatch(argThat(rows -> rows.size() == 2), eq(100));
        verify(qualities, never()).selectByIdForUpdate(anyLong());
    }
}