云
3 天以前 d80fa460742ddf052844746482801de355e85528
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
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.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityBatchRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedReturnQualitySubmitReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesTraceCodeUtils;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.seedquality.*;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.batch.MesWmBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmReturnSalesStatusEnum;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseAreaDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO;
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.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.returnsales.MesWmReturnSalesLineService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseAreaService;
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.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
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 int MAX_BATCH_SIZE = 500, WRITE_BATCH_SIZE = 100;
    private static final int WAIT_RETEST = 10, WAIT_PROCESS = 20, WAIT_INITIAL_FAIL_REVIEW = 25, WAIT_COMPANY = 30,
            RELEASED = 40, WAIT_INITIAL_RECHECK = 50, WAIT_COMPANY_FAIL = 60, SCRAPPED = 70;
    /** 质检线:1 加工部;2 销售部 */
    private static final int QC_LINE_PROCESS = 1, QC_LINE_SALES = 2;
    /** 业务类型:1 生产入库质检;2 经销商退货质检 */
    private static final int BIZ_TYPE_PRODUCTION = 1, BIZ_TYPE_RETURN = 2;
    /** 加工部领导审批流程定义标识 */
    private static final String LINE_PROCESS_KEY = "mes_seed_quality_line_approve";
    /** 销售部领导审批流程定义标识 */
    private static final String SALES_LINE_PROCESS_KEY = "mes_seed_quality_sales_line_approve";
    /** 公司质检审批流程定义标识 */
    private static final String COMPANY_PROCESS_KEY = "mes_seed_quality_company_approve";
    /** 经销商退货质检审批流程定义标识 */
    private static final String RETURN_PROCESS_KEY = "mes_seed_return_approve";
    @Resource private MesQcSeedQualityMapper qualityMapper;
    @Resource private BpmProcessInstanceApi processInstanceApi;
    @Resource private MesWmReturnSalesService returnSalesService;
    @Resource private MesWmReturnSalesLineService returnSalesLineService;
    @Resource private MesQcApproverService approverService;
    @Resource private MesQcSeedQualityActionMapper actionMapper;
    @Resource private MesQcSeedQualityIndicatorMapper indicatorMapper;
    @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 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;
    @Resource private MesMdmItemService mdmItemService;
    @Resource private MesQcSeedIndicatorRangeService rangeService;
 
    @Override
    public PageResult<MesQcSeedQualityDO> getQualityPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        return qualityMapper.selectPage(reqVO);
    }
 
    @Override
    public PageResult<MesQcSeedQualityBatchRespVO> getQualityBatchPage(MesQcSeedQualityPageReqVO reqVO) {
        resolveBagScanCode(reqVO);
        int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
        int pageSize = reqVO.getPageSize() == null || reqVO.getPageSize() < 1 ? 10 : Math.min(reqVO.getPageSize(), 100);
        long total = qualityMapper.countBatchGroups(reqVO);
        if (total == 0) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        List<MesQcSeedQualityBatchRespVO> list = qualityMapper.selectBatchGroups(reqVO, (long) (pageNo - 1) * pageSize, pageSize)
                .stream().map(this::toBatchRespVO).collect(Collectors.toList());
        // 回填批次与品种信息
        Set<Long> batchIds = list.stream().map(MesQcSeedQualityBatchRespVO::getBatchId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesProBatchDO> batchMap = batchIds.isEmpty() ? Collections.emptyMap()
                : batchMapper.selectBatchIds(batchIds).stream()
                .collect(Collectors.toMap(MesProBatchDO::getId, Function.identity(), (first, ignored) -> first));
        Map<Long, MesMdmItemDO> itemMap = mdmItemService.getItemMap(list.stream()
                .map(MesQcSeedQualityBatchRespVO::getItemId).filter(Objects::nonNull).collect(Collectors.toSet()));
        for (MesQcSeedQualityBatchRespVO vo : list) {
            vo.setBatchLabel(StrUtil.blankToDefault(vo.getBatchCode(), "未绑定批次"));
            MesProBatchDO batch = batchMap.get(vo.getBatchId());
            if (batch != null) {
                vo.setLineName(batch.getLineName());
                vo.setProduceDate(batch.getProduceDate());
            }
            MesMdmItemDO item = itemMap.get(vo.getItemId());
            if (item != null) {
                vo.setItemCode(item.getCode());
                vo.setItemName(item.getName());
            }
        }
        return new PageResult<>(list, total);
    }
 
    private void resolveBagScanCode(MesQcSeedQualityPageReqVO reqVO) {
        if (StrUtil.isNotBlank(reqVO.getCode())) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(reqVO.getCode());
            if (bag != null) {
                reqVO.setCode(bag.getCode());
            }
        }
    }
 
    private MesQcSeedQualityBatchRespVO toBatchRespVO(Map<String, Object> row) {
        MesQcSeedQualityBatchRespVO vo = new MesQcSeedQualityBatchRespVO();
        vo.setBatchCode((String) row.get("batchCode"));
        vo.setBatchId(toLong(row.get("batchId")));
        vo.setItemId(toLong(row.get("itemId")));
        vo.setTotalCount(toInt(row.get("totalCount")));
        vo.setPassCount(toInt(row.get("passCount")));
        vo.setFailCount(toInt(row.get("failCount")));
        vo.setProcessingCount(toInt(row.get("processingCount")));
        vo.setPendingRetestCount(toInt(row.get("pendingRetestCount")));
        vo.setPendingProcessReviewCount(toInt(row.get("pendingProcessReviewCount")));
        vo.setPendingCompanyReviewCount(toInt(row.get("pendingCompanyReviewCount")));
        vo.setReleasedCount(toInt(row.get("releasedCount")));
        vo.setScrappedCount(toInt(row.get("scrappedCount")));
        vo.setLastInspectedAt(toLocalDateTime(row.get("lastInspectedAt")));
        return vo;
    }
 
    private static Long toLong(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
 
    private static Integer toInt(Object value) {
        return value instanceof Number number ? number.intValue() : 0;
    }
 
    private static LocalDateTime toLocalDateTime(Object value) {
        if (value instanceof LocalDateTime dateTime) {
            return dateTime;
        }
        if (value instanceof Timestamp timestamp) {
            return timestamp.toLocalDateTime();
        }
        return null;
    }
 
    @Override
    public Map<String, Object> resolveScan(String code) {
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(MesTraceCodeUtils.extractCode(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()));
        Long itemId = bag.getItemId();
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(bag.getCode());
        MesQcSeedQualityDO quality = qualities.stream()
                .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        result.put("qualityLines", qualities.stream()
                .filter(q -> !isReturnQuality(q))
                .map(q -> {
                    Map<String, Object> line = new HashMap<>();
                    line.put("id", q.getId());
                    line.put("qcLine", q.getQcLine());
                    line.put("workflowStatus", q.getWorkflowStatus());
                    return line;
                })
                .toList());
        return result;
    }
 
    private record Scope(String type, String code, String businessCode, String batchCode, boolean canceled, List<MesProBagCodeDO> bags) { }
 
    /** 扫码类型:由码值自动识别袋码 / 托盘码 / 生产批次码 */
    private static final String SCAN_TYPE_AUTO = "AUTO";
 
    private Scope scope(String code, String type, boolean lock) {
        // 标签二维码内容可能是整段追溯链接,先拆解为纯码值再匹配
        code = MesTraceCodeUtils.extractCode(code);
        if (StrUtil.isBlank(type) || SCAN_TYPE_AUTO.equalsIgnoreCase(type)) {
            return autoScope(code, lock);
        }
        if ("BAG".equals(type)) {
            MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
            if (bag == null) throw invalidParamException("未找到袋码,请核对扫码类型和码值");
            return bagScope(bag);
        }
        if ("PALLET".equals(type)) {
            var pallet = lock ? palletMapper.selectByScanCodeForUpdate(code) : palletMapper.selectByScanCode(code);
            if (pallet == null) throw invalidParamException("未找到托盘码,请核对扫码类型和码值");
            return palletScope(pallet, lock);
        }
        if ("BATCH".equals(type)) {
            var batch = lock ? batchMapper.selectByScanCodeForUpdate(code) : batchMapper.selectByScanCode(code);
            if (batch == null) throw invalidParamException("未找到生产批次码,请核对扫码类型和码值");
            return batchScope(batch, lock);
        }
        throw invalidParamException("不支持的质检扫码类型");
    }
 
    /** 与扫码绑定托盘一致:按码值自动识别类型,依次尝试袋码、托盘码、生产批次码 */
    private Scope autoScope(String code, boolean lock) {
        MesProBagCodeDO bag = bagCodeMapper.selectByScanCode(code);
        if (bag != null) {
            return bagScope(bag);
        }
        var pallet = lock ? palletMapper.selectByScanCodeForUpdate(code) : palletMapper.selectByScanCode(code);
        if (pallet != null) {
            return palletScope(pallet, lock);
        }
        var batch = lock ? batchMapper.selectByScanCodeForUpdate(code) : batchMapper.selectByScanCode(code);
        if (batch != null) {
            return batchScope(batch, lock);
        }
        throw invalidParamException("未找到对应的袋码、托盘码或生产批次码,请核对码值");
    }
 
    private Scope bagScope(MesProBagCodeDO bag) {
        return new Scope("BAG", bag.getCode(), bag.getBusinessCode(), bag.getBatchCode(),
                Integer.valueOf(30).equals(bag.getStatus()), List.of(bag));
    }
 
    private Scope palletScope(MesProPalletDO pallet, boolean lock) {
        if (pallet.getBatchId() == null) throw invalidParamException("托盘尚未绑定生产批次,无法质检");
        return new Scope("PALLET", 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()));
    }
 
    private Scope batchScope(MesProBatchDO batch, boolean lock) {
        return new Scope("BATCH", 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()));
    }
 
    @Override
    public Map<String, Object> resolveScan(String code, String scanType) {
        return resolveScan(code, scanType, QC_LINE_PROCESS);
    }
 
    @Override
    public Map<String, Object> resolveScan(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        Scope scope = scope(code, scanType, false);
        List<MesProBagCodeDO> effective = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus())).toList();
        // 一次提交生成两条质检线记录,任一已存在即视为该袋已完成初检。
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .filter(q -> !isReturnQuality(q))
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        Map<String, Object> result = new HashMap<>();
        // 自动识别时回传实际码类型,前端据此展示对象类型并用于提交
        result.put("scanType", scope.type()); 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());
        Long itemId = resolveItemId(scope);
        MesMdmItemDO item = itemId == null ? null : mdmItemService.getItem(itemId);
        result.put("itemId", itemId);
        result.put("itemCode", item == null ? null : item.getCode());
        result.put("itemName", item == null ? null : item.getName());
        result.put("indicators", toRangePayload(itemId));
        if ("BAG".equals(scope.type())) {
            List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByCode(scope.code());
            MesQcSeedQualityDO quality = qualities.stream()
                    .filter(q -> !isReturnQuality(q)).findFirst().orElse(null);
            result.put("qualityId", quality == null ? null : quality.getId());
            result.put("result", quality == null ? null : quality.getResult());
            result.put("qualityIds", qualities.stream().map(MesQcSeedQualityDO::getId).toList());
        }
        return result;
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> submitGroup(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        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("没有有效袋码");
        // 一次提交同时生成两条质检线记录,任一已存在即视为该袋已质检。
        Set<String> inspected = qualityMapper.selectListByCodes(effective.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .filter(q -> !isReturnQuality(q))
                .map(MesQcSeedQualityDO::getCode).collect(java.util.stream.Collectors.toSet());
        List<MesProBagCodeDO> pendingPreview = effective.stream().filter(b -> b.getInboundEventId() != null && !inspected.contains(b.getCode()))
                .sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        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.containsAll(confirmed))
            throw new IllegalArgumentException("袋码归属已变化,请重新扫描核对");
        if (qualityMapper.selectListByCodesForUpdate(locked.stream().map(MesProBagCodeDO::getCode).toList()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("待质检清单已变化或已全部质检,请重新扫描,禁止重复处理");
        List<MesProBagCodeDO> pending = locked.stream().sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList();
        submitInitialBatch(pending, req);
        return Map.of("processedCount", pending.size());
    }
 
    private void submitInitialBatch(List<MesProBagCodeDO> bags,
                                    cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityGroupSubmitReqVO req) {
        Long batchId = bags.get(0).getBatchId();
        if (batchId == null || bags.stream().anyMatch(bag -> !Objects.equals(batchId, bag.getBatchId())))
            throw new IllegalArgumentException("批量初检只允许同一生产批次的袋码");
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        if (batch == null || batch.getItemId() == null) throw new IllegalArgumentException("未根据袋码解析到物料信息");
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(req.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        if (Integer.valueOf(40).equals(batch.getStatus())) throw new IllegalArgumentException("生产批次已作废,无法质检");
        MesWmBatchDO wmBatch = mesWmBatchMapper.selectByCode(batch.getCode());
        if (wmBatch == null || materialStockMapper.selectTempStockCountByBatchId(wmBatch.getId()) == 0)
            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_RETEST;
        // 一次提交同时生成加工部与销售部两条记录,指标与结论完全一致。
        List<MesQcSeedQualityDO> qualities = new ArrayList<>(bags.size() * 2);
        for (MesProBagCodeDO bag : bags) {
            for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
                qualities.add(MesQcSeedQualityDO.builder()
                        .code(bag.getCode()).qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                        .itemId(batch.getItemId()).batchId(batch.getId()).batchCode(batch.getCode())
                        .result(result).reason(passed ? "" : req.getReason().trim()).workflowStatus(status)
                        .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId).inspectedAt(now).build());
            }
        }
        qualityMapper.insertBatch(qualities, WRITE_BATCH_SIZE);
        if (qualities.stream().anyMatch(quality -> quality.getId() == null))
            throw new IllegalStateException("批量初检记录编号生成失败");
 
        List<MesQcSeedQualityIndicatorDO> indicators = new ArrayList<>(qualities.size() * expected.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        for (MesQcSeedQualityDO quality : qualities) {
            for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicators.add(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            actions.add(newAction(quality.getId(), "INITIAL", result, req.getReason(), req.getIndicators(), operatorId, now));
        }
        indicatorMapper.insertBatch(indicators, WRITE_BATCH_SIZE * expected.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public Long submit(MesQcSeedQualitySubmitReqVO reqVO) {
        // 种子质检必须以袋码(单袋)提交,物料信息由袋码自动解析,前端不再单独选择物料
        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.selectListByCodeForUpdate(bag.getCode()).stream()
                .anyMatch(q -> !isReturnQuality(q)))
            throw new IllegalArgumentException("该袋码已质检,禁止重复提交");
        MesProBatchDO batch = bag.getBatchId() == null ? null : proBatchService.getBatch(bag.getBatchId());
        if (batch == null || batch.getItemId() == null) {
            throw new IllegalArgumentException("未根据袋码解析到物料信息,请确认扫码内容或批次号正确");
        }
        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);
        }
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(batch.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        int status = passed ? WAIT_PROCESS : WAIT_RETEST;
        // 一次提交同时生成加工部与销售部两条记录,指标与结论完全一致。
        Long firstQualityId = null;
        for (int qcLine : new int[]{QC_LINE_PROCESS, QC_LINE_SALES}) {
            MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder().code(bag.getCode())
                    .qcLine(qcLine).bizType(BIZ_TYPE_PRODUCTION)
                    .itemId(batch.getItemId()).batchCode(batch.getCode()).batchId(batch.getId())
                    .reason(passed ? "" : reqVO.getReason().trim()).result(result).workflowStatus(status)
                    .qualifiedWarehouseId(qualifiedWarehouse.getId()).inspectorId(operatorId)
                    .inspectedAt(now).build();
            qualityMapper.insert(quality);
            for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
                IndicatorMeta meta = expected.get(item.getCode());
                indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId()).indicatorCode(item.getCode())
                        .indicatorName(meta.name()).unit(meta.unit())
                        .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
            }
            recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
            if (firstQualityId == null) firstQualityId = quality.getId();
        }
        // 初检仅记录结论;袋仍留在暂存库,待初检审核、复检及最终审核后再放行或报废。
        String batchCode = batch.getCode();
        if (passed) {
            afterCommit(() -> startLineProcess(QC_LINE_PROCESS, batchCode, operatorId));
            afterCommit(() -> startLineProcess(QC_LINE_SALES, batchCode, operatorId));
        }
        return firstQualityId;
    }
 
    private void finalizeStock(MesQcSeedQualityDO quality, boolean passed) {
        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) {
            // 库存事务必须落到库区、库位,合格库缺少配置时提前给出可操作的提示
            Long[] target = resolveQualifiedLocation(qualifiedWarehouse);
            qualifiedLocationId = target[0];
            qualifiedAreaId = target[1];
        }
        // 按袋码粒度流转库存:锁定该袋在暂存库中的活动明细,禁止按批次任意扣减
        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 = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能提交复检,请刷新后重试");
        boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
        if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
            throw new IllegalArgumentException("经销商退货质检当前状态不能复检,请刷新后重试");
        boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
        if (!Objects.equals(quality.getCode(), req.getCode())) throw new IllegalArgumentException("检验袋码与原质检记录不一致");
        // 复检沿用原记录的指标集合,避免配置变更导致历史记录无法复检
        Map<String, MesQcSeedQualityIndicatorDO> existing = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityId(id))
            existing.put(indicator.getIndicatorCode(), indicator);
        boolean passed = validateIndicators(req.getIndicators(), toMetaMap(existing.values(), quality.getItemId()));
        if (!passed && StrUtil.isBlank(req.getReason())) throw new IllegalArgumentException("检验不合格必须填写原因");
        recordAction(id, initialRecheck ? "INITIAL_RECHECK" : "RETEST", passed ? 1 : 2, req.getReason(), req.getIndicators());
        Long operatorId = getLoginUserId();
        LocalDateTime now = LocalDateTime.now();
        int nextStatus;
        if (returnQuality) {
            // 复检结论统一交由销售部领导审批,不合格时审批后不予退货。
            nextStatus = WAIT_PROCESS;
        } else if (initialRecheck) {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        } else {
            nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
        }
        MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(id).result(passed ? 1 : 2)
                .reason(passed ? "" : req.getReason().trim())
                .inspectorId(operatorId).inspectedAt(now)
                .workflowStatus(nextStatus).build();
        if (!initialRecheck) {
            update.setRetestResult(passed ? 1 : 2);
            update.setRetestedAt(now);
        }
        qualityMapper.updateById(update);
        if (returnQuality) {
            afterCommit(() -> startReturnProcess(quality, operatorId));
        } else if (initialRecheck) {
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        } else if (!initialRecheck) {
            // 初检不合格的复检完成后,先交本线部门审批。
            afterCommit(() -> startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId));
        }
        // 页面、公开追溯读取主指标表;同步为最新一次测值,历次测值在操作留痕表保留。
        for (MesQcSeedQualitySubmitReqVO.Indicator item : req.getIndicators()) {
            MesQcSeedQualityIndicatorDO indicator = existing.get(item.getCode());
            if (indicator == null) throw new IllegalArgumentException("质检指标记录缺失");
            indicatorMapper.updateById(MesQcSeedQualityIndicatorDO.builder().id(indicator.getId())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
    }
 
    @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;
        Integer batchQcLine = 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();
                batchQcLine = quality.getQcLine();
                first = false;
            } else if (!Objects.equals(batchCode, quality.getBatchCode())
                    || !Objects.equals(workflowStatus, quality.getWorkflowStatus())
                    || !Objects.equals(batchQcLine, quality.getQcLine())) {
                throw new IllegalArgumentException("批量质检只允许同生产批次、同质检线和同流程状态的记录");
            }
        }
        applyRetestBatch(qualities, req.getIndicators(), req.getReason());
    }
 
    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) {
        return resolveRetestScope(code, scanType, QC_LINE_PROCESS);
    }
 
    @Override
    public Map<String, Object> resolveRetestScope(String code, String scanType, Integer qcLine) {
        requireQcLine(qcLine);
        checkRetestScanType(scanType);
        Scope scope = scope(code, scanType, false);
        if (scope.canceled()) throw invalidParamException("质检对象已作废");
        List<String> codes = scope.bags().stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()) && b.getInboundEventId() != null)
                .map(MesProBagCodeDO::getCode).toList();
        List<MesQcSeedQualityDO> qualities = codes.isEmpty() ? List.of() : qualityMapper.selectListByCodes(codes).stream()
                .filter(q -> Objects.equals(q.getQcLine(), qcLine) && !isReturnQuality(q)).toList();
        List<Long> retestIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_RETEST))
                .map(MesQcSeedQualityDO::getId).sorted().toList();
        List<Long> initialRecheckIds = qualities.stream().filter(q -> Objects.equals(q.getWorkflowStatus(), WAIT_INITIAL_RECHECK))
                .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());
        Long itemId = qualities.stream().map(MesQcSeedQualityDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        result.put("itemId", itemId);
        MesQcSeedQualityDO actionable = qualities.stream()
                .filter(q -> Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(q.getWorkflowStatus()))
                .findFirst().orElse(null);
        result.put("indicators", actionable == null ? List.of()
                : toIndicatorPayload(indicatorMapper.selectListByQualityId(actionable.getId()), itemId));
        return result;
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> retestScope(cn.iocoder.yudao.module.mes.controller.admin.qc.seedquality.vo.MesQcSeedQualityScopeRetestReqVO req) {
        requireQcLine(req.getQcLine());
        checkRetestScanType(req.getScanType());
        if (!Set.of(WAIT_RETEST, WAIT_INITIAL_RECHECK).contains(req.getWorkflowStatus()))
            throw new IllegalArgumentException("批量质检状态无效");
        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.getQcLine(), req.getQcLine())
                        && !isReturnQuality(q) && Objects.equals(q.getWorkflowStatus(), req.getWorkflowStatus()))
                .sorted(Comparator.comparing(MesQcSeedQualityDO::getId)).toList();
        List<Long> expectedChunk = pendingPreview.stream().limit(MAX_BATCH_SIZE).map(MesQcSeedQualityDO::getId).toList();
        if (expectedChunk.isEmpty() || !confirmed.equals(expectedChunk))
            throw new IllegalArgumentException("待质检清单已变化,请重新扫描核对");
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, pending);
        if (pending.stream().anyMatch(quality -> !Objects.equals(quality.getWorkflowStatus(), req.getWorkflowStatus())
                || !Objects.equals(quality.getQcLine(), req.getQcLine()) || isReturnQuality(quality)
                || !effectiveCodes.contains(quality.getCode())))
            throw new IllegalArgumentException("待质检记录状态或归属已变化,请重新扫描核对");
        applyRetestBatch(pending, req.getIndicators(), req.getReason());
        return Map.of("processedCount", pending.size());
    }
 
    private void applyRetestBatch(List<MesQcSeedQualityDO> qualities,
                                  List<MesQcSeedQualitySubmitReqVO.Indicator> submittedIndicators, String reason) {
        List<Long> ids = qualities.stream().map(MesQcSeedQualityDO::getId).toList();
        Map<Long, Map<String, MesQcSeedQualityIndicatorDO>> existingByQuality = new HashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicatorMapper.selectListByQualityIds(ids)) {
            existingByQuality.computeIfAbsent(indicator.getQualityId(), ignored -> new HashMap<>())
                    .put(indicator.getIndicatorCode(), indicator);
        }
        Map<String, MesQcSeedQualityIndicatorDO> firstIndicators = existingByQuality.get(qualities.get(0).getId());
        Map<String, IndicatorMeta> expected = toMetaMap(
                firstIndicators == null ? Collections.<MesQcSeedQualityIndicatorDO>emptyList() : firstIndicators.values(),
                qualities.get(0).getItemId());
        boolean passed = validateIndicators(submittedIndicators, expected);
        if (!passed && StrUtil.isBlank(reason)) throw new IllegalArgumentException("检验不合格必须填写原因");
 
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        List<MesQcSeedQualityDO> qualityUpdates = new ArrayList<>(qualities.size());
        List<MesQcSeedQualityIndicatorDO> indicatorUpdates = new ArrayList<>(qualities.size() * submittedIndicators.size());
        List<MesQcSeedQualityActionDO> actions = new ArrayList<>(qualities.size());
        Set<String> lineProcessTargets = new HashSet<>();
        List<MesQcSeedQualityDO> returnReady = new ArrayList<>();
        for (MesQcSeedQualityDO quality : qualities) {
            boolean returnQuality = Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
            boolean initialRecheck = Objects.equals(quality.getWorkflowStatus(), WAIT_INITIAL_RECHECK);
            if (returnQuality && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            if (!returnQuality && !initialRecheck && !Objects.equals(quality.getWorkflowStatus(), WAIT_RETEST))
                throw new IllegalArgumentException("所选记录包含不可检验的状态,请刷新后重试");
            int nextStatus;
            if (returnQuality) {
                nextStatus = WAIT_PROCESS;
                returnReady.add(quality);
            } else if (initialRecheck) {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            } else {
                nextStatus = passed ? WAIT_PROCESS : WAIT_INITIAL_FAIL_REVIEW;
                lineProcessTargets.add(quality.getQcLine() + "#" + quality.getBatchCode());
            }
            MesQcSeedQualityDO update = MesQcSeedQualityDO.builder().id(quality.getId()).result(result)
                    .reason(passed ? "" : reason.trim()).inspectorId(operatorId).inspectedAt(now)
                    .workflowStatus(nextStatus).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 * submittedIndicators.size());
        actionMapper.insertBatch(actions, WRITE_BATCH_SIZE);
        // 复检按记录所属质检线分别发起本线审批,避免提前触发另一条线。
        lineProcessTargets.forEach(target -> {
            String[] parts = target.split("#", 2);
            afterCommit(() -> startLineProcess(Integer.valueOf(parts[0]), parts[1], operatorId));
        });
        returnReady.forEach(quality -> afterCommit(() -> startReturnProcess(quality, operatorId)));
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public void processApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行初检审核");
        requireLegacyApproval(quality);
        applyProcessApproval(quality, approved, opinion, true);
    }
 
    /**
     * 本线领导审批:生产质检通过后进入公司质检审批,驳回后重新初检;经销商退货质检则直接终审。
     */
    private void applyProcessApproval(MesQcSeedQualityDO quality, boolean approved, String opinion, boolean validateApprover) {
        if (validateApprover) {
            approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                    MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        }
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw new IllegalArgumentException("经销商退货质检当前状态不能进行销售部审批");
            applyReturnApproval(quality, approved, opinion, operatorId, now);
            return;
        }
        int next = approved ? (Objects.equals(quality.getQcLine(), 0) ? WAIT_RETEST
                : Objects.equals(quality.getResult(), 1) ? WAIT_COMPANY : WAIT_COMPANY_FAIL)
                : WAIT_INITIAL_RECHECK;
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId()).workflowStatus(next)
                .processApprovedAt(now).build());
        if (!approved) qualityMapper.clearLineProcessInstance(quality.getId());
        actionMapper.insert(newAction(quality.getId(), approved ? "PROCESS_APPROVE" : "PROCESS_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
        if (approved && !Objects.equals(quality.getQcLine(), 0))
            afterCommit(() -> maybeStartCompanyProcess(quality.getBatchCode(), operatorId));
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null) throw new IllegalArgumentException("质检记录不存在");
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        if (!Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
            throw new IllegalArgumentException("当前状态不能进行复检审核");
        requireLegacyApproval(quality);
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        applyCompanyApproval(quality.getCode(), approved, opinion, getLoginUserId());
    }
 
    /**
     * 公司质检审批:以「袋」为单位成对审批,两条质检线都走完才允许审批。
     * 两条线均合格才放行入库,任一条线不合格则整袋报废;驳回则两条线全部退回复检。
     */
    private void applyCompanyApproval(String code, boolean approved, String opinion, Long operatorId) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        List<MesQcSeedQualityDO> pair = qualityMapper.selectListByCodeForUpdate(code).stream()
                .filter(q -> !isReturnQuality(q)).toList();
        if (pair.size() != 2 || pair.stream().anyMatch(q -> !Objects.equals(q.getQcLine(), QC_LINE_PROCESS)
                && !Objects.equals(q.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("袋码 " + code + " 的双线质检记录不完整,无法公司审批");
        if (pair.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(q.getWorkflowStatus())))
            throw new IllegalArgumentException("袋码 " + code + " 的双线质检尚未全部完成,不能进行公司质检审批");
        boolean passed = pair.stream().allMatch(q -> Objects.equals(q.getWorkflowStatus(), WAIT_COMPANY));
        MesQcSeedQualityDO representative = pair.stream()
                .filter(q -> Objects.equals(q.getQcLine(), QC_LINE_PROCESS)).findFirst().orElse(pair.get(0));
        LocalDateTime now = LocalDateTime.now();
        if (approved) {
            finalizeStock(representative, passed);
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(passed ? RELEASED : SCRAPPED)
                    .companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        } else {
            qualityMapper.clearCompanyProcessInstance(code);
            pair.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
            qualityMapper.updateBatch(pair.stream().map(quality -> MesQcSeedQualityDO.builder()
                    .id(quality.getId()).workflowStatus(WAIT_RETEST).companyApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        }
        actionMapper.insertBatch(pair.stream().map(quality -> newAction(quality.getId(),
                approved ? "COMPANY_APPROVE" : "COMPANY_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public void processApproveBatch(List<Long> ids, boolean approved, String opinion) {
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> !Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())))
            throw new IllegalArgumentException("所选记录包含不能进行初检审核的状态,请刷新后重试");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        List<MesQcSeedQualityDO> production = qualities.stream()
                .filter(quality -> !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)).toList();
        qualities.stream().filter(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
                .forEach(quality -> applyProcessApproval(quality, approved, opinion, false));
        if (production.isEmpty()) return;
        int next = approved ? WAIT_RETEST : WAIT_INITIAL_RECHECK;
        qualityMapper.updateBatch(production.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) production.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(production.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), opinion, null, operatorId, now)).toList(),
                WRITE_BATCH_SIZE);
    }
 
    @Override @Transactional(rollbackFor = Exception.class)
    public void companyApproveBatch(List<Long> ids, boolean approved, String opinion) {
        List<Long> confirmed = approvalIds(ids);
        List<MesQcSeedQualityDO> qualities = qualityMapper.selectListByIdsForUpdate(confirmed);
        validateLockedIds(confirmed, qualities);
        qualities.forEach(MesQcSeedQualityServiceImpl::requireLegacyApproval);
        if (qualities.stream().anyMatch(quality -> Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)))
            throw new IllegalArgumentException("经销商退货质检请使用销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.COMPANY.getNode(), getLoginUserId());
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        Long operatorId = getLoginUserId();
        // 公司审批以袋为单位成对处理,同一袋的两条线记录一次审批
        qualities.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, opinion, operatorId));
    }
 
    // ==================== 经销商退货质检 ====================
 
    @Override
    public Map<String, Object> resolveReturnQuality(Long returnSalesId) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(returnSalesId);
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSalesId);
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst().orElse(null);
        Long itemId = first == null ? null : first.getItemId();
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesId(returnSalesId);
        Map<String, Object> result = new HashMap<>();
        result.put("returnSalesId", returnSales.getId());
        result.put("returnSalesCode", returnSales.getCode());
        result.put("returnSalesStatus", returnSales.getStatus());
        result.put("itemId", itemId);
        result.put("batchId", first == null ? null : first.getBatchId());
        result.put("batchCode", first == null ? null : first.getBatchCode());
        result.put("indicators", toRangePayload(itemId));
        result.put("qualityId", quality == null ? null : quality.getId());
        result.put("result", quality == null ? null : quality.getResult());
        result.put("workflowStatus", quality == null ? null : quality.getWorkflowStatus());
        return result;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long submitReturnQuality(MesQcSeedReturnQualitySubmitReqVO reqVO) {
        MesWmReturnSalesDO returnSales = returnSalesService.getReturnSales(reqVO.getReturnSalesId());
        if (returnSales == null) throw new IllegalArgumentException("销售退货单不存在");
        if (qualityMapper.selectByReturnSalesId(returnSales.getId()) != null)
            throw new IllegalArgumentException("该销售退货单已完成退货质检,禁止重复提交");
        if (!Set.of(MesWmReturnSalesStatusEnum.PREPARE.getStatus(),
                MesWmReturnSalesStatusEnum.CONFIRMED.getStatus()).contains(returnSales.getStatus()))
            throw new IllegalArgumentException("当前销售退货单状态不能进行退货质检");
        List<MesWmReturnSalesLineDO> lines = returnSalesLineService.getReturnSalesLineListByReturnId(returnSales.getId());
        if (CollUtil.isEmpty(lines)) throw new IllegalArgumentException("销售退货单没有明细,无法质检");
        MesWmReturnSalesLineDO first = lines.stream().filter(line -> line.getItemId() != null).findFirst()
                .orElseThrow(() -> new IllegalArgumentException("销售退货单明细缺少物料,无法质检"));
        Map<String, IndicatorMeta> expected = requireEffectiveIndicators(first.getItemId());
        boolean passed = validateIndicators(reqVO.getIndicators(), expected);
        if (!passed && StrUtil.isBlank(reqVO.getReason())) throw new IllegalArgumentException("质检不合格必须填写原因");
        LocalDateTime now = LocalDateTime.now();
        Long operatorId = getLoginUserId();
        int result = passed ? 1 : 2;
        MesQcSeedQualityDO quality = MesQcSeedQualityDO.builder()
                .code(returnSales.getCode()).qcLine(QC_LINE_SALES).bizType(BIZ_TYPE_RETURN)
                .itemId(first.getItemId()).batchId(first.getBatchId()).batchCode(first.getBatchCode())
                .result(result).reason(passed ? "" : reqVO.getReason().trim())
                // 初检合格进入销售部审批;不合格先复检。
                .workflowStatus(passed ? WAIT_PROCESS : WAIT_RETEST)
                .returnSalesId(returnSales.getId()).returnSalesCode(returnSales.getCode())
                .inspectorId(operatorId).inspectedAt(now).build();
        qualityMapper.insert(quality);
        for (MesQcSeedQualitySubmitReqVO.Indicator item : reqVO.getIndicators()) {
            IndicatorMeta meta = expected.get(item.getCode());
            indicatorMapper.insert(MesQcSeedQualityIndicatorDO.builder().qualityId(quality.getId())
                    .indicatorCode(item.getCode()).indicatorName(meta.name()).unit(meta.unit())
                    .value(item.getValue()).minValue(item.getMinValue()).maxValue(item.getMaxValue()).build());
        }
        recordAction(quality.getId(), "INITIAL", result, reqVO.getReason(), reqVO.getIndicators());
        returnSalesService.updateReturnSalesStatus(returnSales.getId(), MesWmReturnSalesStatusEnum.CONFIRMED.getStatus());
        if (passed) afterCommit(() -> startReturnProcess(quality, operatorId));
        return quality.getId();
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void returnApprove(Long id, boolean approved, String opinion) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN))
            throw new IllegalArgumentException("质检记录不存在或不是经销商退货质检");
        requireLegacyApproval(quality);
        if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
            throw new IllegalArgumentException("当前状态不能进行销售部审批");
        approverService.validateApprover(MesQcApprovalTypeEnum.SEED_QUALITY.getType(),
                MesQcApproveNodeEnum.PROCESS.getNode(), getLoginUserId());
        applyReturnApproval(quality, approved, opinion, getLoginUserId(), LocalDateTime.now());
    }
 
    /**
     * 销售部领导审批:通过则退货单进入执行;不通过则不予退货并取消退货单,质检记录留存。
     */
    private void applyReturnApproval(MesQcSeedQualityDO quality, boolean approved, String opinion,
                                     Long operatorId, LocalDateTime now) {
        if (!approved && StrUtil.isBlank(opinion)) throw new IllegalArgumentException("驳回时请填写审批意见");
        if (approved && Objects.equals(quality.getResult(), 1)) {
            returnSalesService.updateReturnSalesStatus(quality.getReturnSalesId(),
                    MesWmReturnSalesStatusEnum.APPROVING.getStatus());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(RELEASED).processApprovedAt(now).build());
        } else {
            cancelReturnSalesQuietly(quality.getReturnSalesId());
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .workflowStatus(SCRAPPED).processApprovedAt(now).build());
        }
        actionMapper.insert(newAction(quality.getId(), approved ? "RETURN_APPROVE" : "RETURN_REJECT",
                quality.getResult(), opinion, null, operatorId, now));
    }
 
    // ==================== BPM 流程回调 ====================
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleLineProcessResult(String processInstanceId, Integer qcLine, String batchCode, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || qcLine == null || StrUtil.isBlank(batchCode)) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByProcessInstanceId(processInstanceId).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && Objects.equals(quality.getBatchCode(), batchCode)
                        && !isReturnQuality(quality)
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleLineProcessResult][批次 {} 质检线 {} 无待审批记录,忽略 BPM 事件]", batchCode, qcLine);
            return;
        }
        List<Long> ids = pending.stream().map(MesQcSeedQualityDO::getId).sorted().toList();
        List<MesQcSeedQualityDO> locked = qualityMapper.selectListByIdsForUpdate(ids);
        validateLockedIds(ids, locked);
        LocalDateTime now = LocalDateTime.now();
        int next = approved ? WAIT_COMPANY : WAIT_INITIAL_RECHECK;
        String finalReason = StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回");
        qualityMapper.updateBatch(locked.stream().map(quality -> MesQcSeedQualityDO.builder().id(quality.getId())
                .workflowStatus(approved && Objects.equals(quality.getResult(), 2) ? WAIT_COMPANY_FAIL : next)
                .processApprovedAt(now).build()).toList(), WRITE_BATCH_SIZE);
        if (!approved) locked.forEach(quality -> qualityMapper.clearLineProcessInstance(quality.getId()));
        actionMapper.insertBatch(locked.stream().map(quality -> newAction(quality.getId(),
                approved ? "PROCESS_APPROVE" : "PROCESS_REJECT", quality.getResult(), finalReason, null, null, now)).toList(),
                WRITE_BATCH_SIZE);
        if (approved) {
            Long inspectorId = locked.stream().map(MesQcSeedQualityDO::getInspectorId)
                    .filter(Objects::nonNull).findFirst().orElse(null);
            afterCommit(() -> maybeStartCompanyProcess(batchCode, inspectorId));
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleCompanyProcessResult(String processInstanceId, String businessKey, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId)) return;
        // 公司审批按生产批次发起,一个流程实例覆盖该批次的所有袋;
        // 按流程实例编号回查可同时兼容历史按袋码发起的审批。
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByCompanyProcessInstanceId(processInstanceId).stream()
                .filter(quality -> !isReturnQuality(quality)
                        && Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) {
            log.info("[handleCompanyProcessResult][流程 {} 无待公司审批记录,忽略 BPM 事件]", processInstanceId);
            return;
        }
        String finalReason = StrUtil.blankToDefault(reason, approved ? "公司质检审批通过" : "公司质检审批驳回");
        // 放行/报废以袋为单位落库,同一批次的多个袋依次处理
        pending.stream().map(MesQcSeedQualityDO::getCode).distinct().sorted()
                .forEach(code -> applyCompanyApproval(code, approved, finalReason, null));
        log.info("[handleCompanyProcessResult][公司质检审批 {} 完成,批次/袋码 {} 共处理 {} 条记录]",
                processInstanceId, businessKey, pending.size());
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleReturnProcessResult(String processInstanceId, Long returnSalesId, boolean approved, String reason) {
        if (StrUtil.isBlank(processInstanceId) || returnSalesId == null) return;
        MesQcSeedQualityDO quality = qualityMapper.selectByReturnSalesIdForUpdate(returnSalesId);
        if (quality == null || !Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)
                || !Objects.equals(quality.getProcessInstanceId(), processInstanceId)
                || !Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS)) {
            log.info("[handleReturnProcessResult][销售退货单 {} 无待审批退货质检,忽略 BPM 事件]", returnSalesId);
            return;
        }
        applyReturnApproval(quality, approved, StrUtil.blankToDefault(reason, approved ? "审批通过" : "审批驳回"),
                null, LocalDateTime.now());
    }
 
    private void startLineProcess(Integer qcLine, String batchCode, Long operatorId) {
        if (qcLine == null || StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> pending = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> Objects.equals(quality.getQcLine(), qcLine)
                        && !isReturnQuality(quality)
                        && StrUtil.isBlank(quality.getProcessInstanceId())
                        && Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus()))
                .toList();
        if (pending.isEmpty()) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("qcLine", qcLine);
        variables.put("batchCode", batchCode);
        String processDefinitionKey = Objects.equals(qcLine, QC_LINE_SALES)
                ? SALES_LINE_PROCESS_KEY : LINE_PROCESS_KEY;
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(qcLine + "#" + batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : pending) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .processInstanceId(processInstanceId).build());
        }
        log.info("[startLineProcess][批次 {} 质检线 {} 已发起本线领导审批 {}", batchCode, qcLine, processInstanceId);
    }
 
    /**
     * 双线部门审批全部完成后,按生产批次统一发起一次公司质检审批。
     * 同一批次的多袋共用同一个流程实例,避免每袋各发一条审批。
     */
    private void maybeStartCompanyProcess(String batchCode, Long operatorId) {
        if (StrUtil.isBlank(batchCode) || operatorId == null) return;
        List<MesQcSeedQualityDO> records = qualityMapper.selectListByBatchCode(batchCode).stream()
                .filter(quality -> !isReturnQuality(quality))
                // 已放行/已报废的记录属于已终结的批次,不再参与新的公司审批
                .filter(quality -> !Set.of(RELEASED, SCRAPPED).contains(quality.getWorkflowStatus()))
                .toList();
        if (records.isEmpty()) return;
        if (records.stream().anyMatch(quality -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                .contains(quality.getWorkflowStatus()))) return;
        if (records.stream().anyMatch(quality -> StrUtil.isNotBlank(quality.getCompanyProcessInstanceId()))) return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("batchCode", batchCode);
        // 兼容流程条件中引用的 code 变量
        variables.put("code", batchCode);
        variables.put("passed", records.stream().allMatch(quality -> Objects.equals(quality.getWorkflowStatus(), WAIT_COMPANY)));
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(COMPANY_PROCESS_KEY)
                        .setBusinessKey(batchCode)
                        .setVariables(variables));
        for (MesQcSeedQualityDO quality : records) {
            qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                    .companyProcessInstanceId(processInstanceId).build());
        }
        log.info("[maybeStartCompanyProcess][批次 {} 双线质检完成,已发起公司质检审批 {},覆盖 {} 条记录]",
                batchCode, processInstanceId, records.size());
    }
 
    private void startReturnProcess(MesQcSeedQualityDO quality, Long operatorId) {
        if (quality == null || quality.getReturnSalesId() == null || StrUtil.isNotBlank(quality.getProcessInstanceId()))
            return;
        Map<String, Object> variables = new HashMap<>();
        variables.put("returnSalesId", quality.getReturnSalesId());
        variables.put("returnSalesCode", quality.getReturnSalesCode());
        variables.put("code", quality.getCode());
        String processInstanceId = processInstanceApi.createProcessInstance(operatorId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(RETURN_PROCESS_KEY)
                        .setBusinessKey(String.valueOf(quality.getReturnSalesId()))
                        .setVariables(variables));
        qualityMapper.updateById(MesQcSeedQualityDO.builder().id(quality.getId())
                .processInstanceId(processInstanceId).build());
        log.info("[startReturnProcess][销售退货单 {} 已发起销售部领导审批 {}]", quality.getReturnSalesId(), processInstanceId);
    }
 
    @Override
    public void retryBpmProcess(Long id) {
        MesQcSeedQualityDO quality = qualityMapper.selectById(id);
        if (quality == null) throw invalidParamException("质检记录不存在");
        Long operatorId = getLoginUserId();
        if (Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN)) {
            if (!Objects.equals(quality.getWorkflowStatus(), WAIT_PROCESS))
                throw invalidParamException("当前退货质检不需要发起审批");
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("退货质检审批流程已经发起");
            startReturnProcess(quality, operatorId);
        } else if (Set.of(WAIT_PROCESS, WAIT_INITIAL_FAIL_REVIEW).contains(quality.getWorkflowStatus())) {
            if (StrUtil.isNotBlank(quality.getProcessInstanceId()))
                throw invalidParamException("部门审批流程已经发起");
            startLineProcess(quality.getQcLine(), quality.getBatchCode(), operatorId);
        } else if (Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL).contains(quality.getWorkflowStatus())) {
            String batchCode = quality.getBatchCode();
            if (StrUtil.isBlank(batchCode))
                throw invalidParamException("质检记录未绑定生产批次,不能发起公司审批");
            List<MesQcSeedQualityDO> batchRecords = qualityMapper.selectListByBatchCode(batchCode).stream()
                    .filter(q -> !isReturnQuality(q))
                    .filter(q -> !Set.of(RELEASED, SCRAPPED).contains(q.getWorkflowStatus()))
                    .toList();
            if (batchRecords.isEmpty() || batchRecords.stream().anyMatch(q -> !Set.of(WAIT_COMPANY, WAIT_COMPANY_FAIL)
                    .contains(q.getWorkflowStatus())))
                throw invalidParamException("同批次双线部门审批尚未全部完成,不能发起公司审批");
            if (batchRecords.stream().anyMatch(q -> StrUtil.isNotBlank(q.getCompanyProcessInstanceId())))
                throw invalidParamException("公司质检审批流程已经发起");
            maybeStartCompanyProcess(batchCode, operatorId);
        } else {
            throw invalidParamException("当前质检状态不需要发起审批");
        }
    }
 
    private void cancelReturnSalesQuietly(Long returnSalesId) {
        if (returnSalesId == null) return;
        try {
            returnSalesService.cancelReturnSales(returnSalesId);
        } catch (Exception ex) {
            log.warn("[cancelReturnSalesQuietly][销售退货单 {} 取消失败: {}]", returnSalesId, ex.getMessage());
        }
    }
 
    private void afterCommit(Runnable action) {
        TransactionSynchronization synchronization = new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    action.run();
                } catch (Exception ex) {
                    log.error("[afterCommit][BPM 流程启动失败,请修复流程配置并在质检记录中重新发起审批]", ex);
                }
            }
        };
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(synchronization);
        } else {
            synchronization.afterCommit();
        }
    }
 
    /** 历史数据 bizType 为空,按生产入库质检处理 */
    private static void requireQcLine(Integer qcLine) {
        if (!Objects.equals(qcLine, QC_LINE_PROCESS) && !Objects.equals(qcLine, QC_LINE_SALES))
            throw new IllegalArgumentException("质检线必须为加工部或销售部");
    }
 
    /** 生产双线记录只能走 BPM 审批;经销商退货质检不拆分质检线,仍可使用人工审批接口。 */
    private static void requireLegacyApproval(MesQcSeedQualityDO quality) {
        if (!isReturnQuality(quality) && (Objects.equals(quality.getQcLine(), QC_LINE_PROCESS)
                || Objects.equals(quality.getQcLine(), QC_LINE_SALES)))
            throw new IllegalArgumentException("该质检记录由 BPM 审批,请在流程待办中处理");
    }
 
    private static boolean isReturnQuality(MesQcSeedQualityDO quality) {
        return Objects.equals(quality.getBizType(), BIZ_TYPE_RETURN);
    }
 
    private List<Long> approvalIds(List<Long> ids) {
        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(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) {
        MesQcSeedQualityDO quality = qualityMapper.selectByIdForUpdate(id);
        if (quality == null || !Objects.equals(quality.getWorkflowStatus(), expected))
            throw new IllegalArgumentException("质检记录状态已变化,请刷新后重试");
        return quality;
    }
 
    private record IndicatorMeta(String code, String name, String unit) { }
 
    private Map<String, IndicatorMeta> requireEffectiveIndicators(Long itemId) {
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            result.put(range.getIndicatorCode(), new IndicatorMeta(range.getIndicatorCode(),
                    StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()),
                    StrUtil.blankToDefault(range.getUnit(), "%")));
        }
        if (result.isEmpty())
            throw new IllegalArgumentException("该产品尚未配置质检项,请先在「质检项合格范围配置」中维护");
        return result;
    }
 
    private Map<String, IndicatorMeta> toMetaMap(Collection<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        Map<String, IndicatorMeta> result = new LinkedHashMap<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            String name = StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName());
            String unit = StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit());
            result.put(indicator.getIndicatorCode(), new IndicatorMeta(indicator.getIndicatorCode(), name, unit));
        }
        return result;
    }
 
    private boolean validateIndicators(List<MesQcSeedQualitySubmitReqVO.Indicator> indicators,
                                       Map<String, IndicatorMeta> expected) {
        if (indicators == null || indicators.isEmpty()) throw new IllegalArgumentException("请至少提交一项质检指标");
        if (expected.isEmpty()) throw new IllegalArgumentException("该产品尚未配置质检项");
        if (indicators.size() != expected.size())
            throw new IllegalArgumentException("质检项数量与产品配置不一致,请刷新后重新录入");
        Set<String> codes = new HashSet<>();
        boolean passed = true;
        for (MesQcSeedQualitySubmitReqVO.Indicator item : indicators) {
            if (item == null || StrUtil.isBlank(item.getCode()) || !codes.add(item.getCode())
                    || item.getValue() == null || item.getMinValue() == null || item.getMaxValue() == null)
                throw new IllegalArgumentException("质检项的实测值和合格范围必须完整且不可重复");
            if (!expected.containsKey(item.getCode()))
                throw new IllegalArgumentException("质检项未在产品配置中启用:" + item.getCode());
            if (item.getMinValue().compareTo(item.getMaxValue()) > 0) throw new IllegalArgumentException("质检项下限不能大于上限");
            if (item.getValue().compareTo(BigDecimal.ZERO) < 0 || item.getMinValue().compareTo(BigDecimal.ZERO) < 0
                    || item.getMaxValue().compareTo(BigDecimal.ZERO) < 0)
                throw new IllegalArgumentException("质检项数值不能为负数");
            if (item.getValue().compareTo(item.getMinValue()) < 0 || item.getValue().compareTo(item.getMaxValue()) > 0) passed = false;
        }
        if (!codes.equals(expected.keySet())) throw new IllegalArgumentException("产品质检项不完整,请刷新后重新录入");
        return passed;
    }
 
    private Long resolveItemId(Scope scope) {
        Long itemId = scope.bags().stream().map(MesProBagCodeDO::getItemId).filter(Objects::nonNull).findFirst().orElse(null);
        if (itemId != null || scope.bags().isEmpty()) return itemId;
        Long batchId = scope.bags().get(0).getBatchId();
        if (batchId == null) return null;
        MesProBatchDO batch = proBatchService.getBatch(batchId);
        return batch == null ? null : batch.getItemId();
    }
 
    private List<Map<String, Object>> toRangePayload(Long itemId) {
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedIndicatorRangeDO range : rangeService.getEffectiveRanges(itemId)) {
            Map<String, Object> item = new HashMap<>();
            item.put("code", range.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(range.getIndicatorName(), range.getIndicatorCode()));
            item.put("unit", StrUtil.blankToDefault(range.getUnit(), "%"));
            item.put("minValue", range.getMinValue());
            item.put("maxValue", range.getMaxValue());
            result.add(item);
        }
        return result;
    }
 
    private List<Map<String, Object>> toIndicatorPayload(List<MesQcSeedQualityIndicatorDO> indicators, Long itemId) {
        Map<String, MesQcSeedIndicatorRangeDO> config = rangeService.getEffectiveMap(itemId);
        List<Map<String, Object>> result = new ArrayList<>();
        for (MesQcSeedQualityIndicatorDO indicator : indicators) {
            MesQcSeedIndicatorRangeDO range = config.get(indicator.getIndicatorCode());
            Map<String, Object> item = new HashMap<>();
            item.put("code", indicator.getIndicatorCode());
            item.put("name", StrUtil.blankToDefault(indicator.getIndicatorName(),
                    range == null ? indicator.getIndicatorCode() : range.getIndicatorName()));
            item.put("unit", StrUtil.blankToDefault(indicator.getUnit(), range == null ? "%" : range.getUnit()));
            item.put("minValue", indicator.getMinValue());
            item.put("maxValue", indicator.getMaxValue());
            result.add(item);
        }
        return result;
    }
 
    private void recordAction(Long id, String action, Integer result, String opinion,
                              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(operatorId);
        event.setCreateTime(createTime);
        return 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  目标合格库库区编号
     * @param qualifiedAreaId      目标合格库库位编号
     */
    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;
    }
 
    /**
     * 解析合格库的默认入库库区与库位:取第一个同时配置了库位的非冻结库区。
     *
     * 库区/库位为可选项,仓库未配置时按仓库层级直接入库,返回 null。
     */
    private Long[] resolveQualifiedLocation(MesWmWarehouseDO qualifiedWarehouse) {
        List<MesWmWarehouseLocationDO> locations = warehouseLocationService.getWarehouseLocationList(qualifiedWarehouse.getId())
                .stream().filter(location -> !Boolean.TRUE.equals(location.getFrozen())).toList();
        if (CollUtil.isEmpty(locations)) {
            return new Long[]{null, null};
        }
        for (MesWmWarehouseLocationDO location : locations) {
            List<MesWmWarehouseAreaDO> areas = warehouseAreaService.getWarehouseAreaList(location.getId())
                    .stream().filter(area -> !Boolean.TRUE.equals(area.getFrozen())).toList();
            if (CollUtil.isNotEmpty(areas)) return new Long[]{location.getId(), areas.get(0).getId()};
        }
        return new Long[]{null, null};
    }
 
    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;
                }
            }
        }
        if (StrUtil.isNotBlank(batchCode)) {
            return proBatchService.getBatchByCode(batchCode);
        }
        return null;
    }
 
    @Override
    public List<MesQcSeedQualityDO> getQualityByBatchCode(String batchCode) {
        try {
            return qualityMapper.selectListByBatchCode(batchCode);
        } catch (BadSqlGrammarException ex) {
            // 种子质检表未初始化时容错,不阻塞公开溯源
            log.warn("[getQualityByBatchCode][种子质检表未初始化,批次 {} 查询失败: {}]", batchCode, ex.getMessage());
            return List.of();
        }
    }
 
    @Override
    public List<MesQcSeedQualityIndicatorDO> getIndicatorsByQualityId(Long qualityId) {
        try {
            return indicatorMapper.selectListByQualityId(qualityId);
        } catch (BadSqlGrammarException ex) {
            // 种子质检表未初始化时容错
            log.warn("[getIndicatorsByQualityId][种子质检指标表未初始化,质检 {} 查询失败: {}]", qualityId, ex.getMessage());
            return List.of();
        }
    }
 
    @Override
    public List<MesQcSeedQualityActionDO> getActions(Long qualityId) {
        return actionMapper.selectByQualityId(qualityId);
    }
}