zss
3 天以前 ae76dfe2d6f00c13419fea9c7f8d2bd150860b9b
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
package com.ruoyi.production.service.impl;
 
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderPickMapper;
import com.ruoyi.production.mapper.ProductionOrderPickRecordMapper;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOrderPick;
import com.ruoyi.production.pojo.ProductionOrderPickRecord;
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
 
/**
 * 生产订单领料服务实现。
 * 负责领料新增、更新、补料、退料及库存联动。
 */
@Service
@RequiredArgsConstructor
public class ProductionOrderPickServiceImpl extends ServiceImpl<ProductionOrderPickMapper, ProductionOrderPick> implements ProductionOrderPickService {
 
    private static final byte PICK_TYPE_NORMAL = 1;
    private static final byte PICK_TYPE_FEEDING = 2;
    private static final String PICK_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.PICK_STOCK_OUT.getCode();
    private static final String FEED_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.FEED_STOCK_OUT.getCode();
    private static final String PICK_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode();
    private static final String FEED_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.FEED_RETURN_IN.getCode();
 
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
    private final StockOutRecordService stockOutRecordService;
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean savePick(ProductionOrderPickDto dto) {
        // 领料新增总流程:
        // 1) 解析前端行数据并逐行合并参数;
        // 2) 校验参数与批次;
        // 3) 先保存领料主记录;
        // 4) 再走“出库申请 + 审批通过”完成库存扣减;
        // 5) 写入领料流水,记录数量变化轨迹。
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto);
        // 逐行处理领料数据,行号用于拼装精确的报错信息。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            // 每行都做完整校验,异常信息带行号。
            validatePickParam(resolvedDto, rowNo);
 
            // 统一处理批次(支持单批次/多批次)。
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
 
            // 保存领料主记录快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
            orderPick.setProductModelId(resolvedDto.getProductModelId());
            orderPick.setBatchNo(storedBatchNo);
            orderPick.setQuantity(resolvedDto.getPickQuantity());
            orderPick.setRemark(resolvedDto.getRemark());
            orderPick.setOperationName(resolvedDto.getOperationName());
            orderPick.setTechnologyOperationId(resolvedDto.getTechnologyOperationId());
            orderPick.setDemandedQuantity(resolvedDto.getDemandedQuantity());
            orderPick.setBom(resolvedDto.getBom());
            orderPick.setReturned(false);
            // 新增主记录。
            baseMapper.insert(orderPick);
 
            // 先新增出库申请,再审批通过,完成库存扣减。
            subtractInventory(orderPick.getId(), resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
 
            // 记录本次领料流水(before=0,after=本次领料量)。
            insertPickRecord(orderPick.getId(),
                    resolvedDto.getProductionOrderId(),
                    resolvedDto.getProductionOperationTaskId(),
                    resolvedDto.getProductModelId(),
                    inventoryBatchNo,
                    resolvedDto.getPickQuantity(),
                    BigDecimal.ZERO,
                    resolvedDto.getPickQuantity(),
                    resolvedDto.getPickType(),
                    resolvedDto.getRemark(),
                    resolvedDto.getFeedingReason());
        }
        return true;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean updatePick(ProductionOrderPickDto dto) {
        // 领料更新入口(同接口兼容三类业务):
        // 1) 普通领料改量/增删;
        // 2) 补料(pickType=2);
        // 3) 退料(returned=true)。
        if (dto == null) {
            throw new ServiceException("参数不能为空");
        }
        Long productionOrderId = resolveProductionOrderId(dto);
        if (productionOrderId == null) {
            throw new ServiceException("生产订单ID不能为空");
        }
        ProductionOrder productionOrder = productionOrderMapper.selectById(productionOrderId);
        if (productionOrder == null) {
            throw new ServiceException("生产订单不存在");
        }
 
        // 查询订单下现有领料记录并构建ID索引。
        List<ProductionOrderPick> existingPickList = baseMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOrderId, productionOrderId));
        // 转成Map便于后续按ID快速校验与更新。
        Map<Long, ProductionOrderPick> existingPickMap = existingPickList.stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(ProductionOrderPick::getId, Function.identity(), (a, b) -> a));
 
        // 补料请求单独走补料分支。
        if (isFeedingRequest(dto)) {
            processFeedingPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
        // 退料请求单独走退料分支。
        if (isReturnRequest(dto)) {
            processReturnPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
 
        // 普通更新场景先处理显式删除。
        processDeletePickIds(dto, existingPickMap, productionOrderId);
 
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(dto);
        Set<Long> keepPickIdSet = new HashSet<>();
        // keepPickIdSet 用于标记本次前端仍然保留的旧记录,后续用于识别“未回传即删除”的行。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            if (isEmptyUpdateItem(resolvedDto)) {
                continue;
            }
            if (resolvedDto.getProductionOrderId() == null) {
                resolvedDto.setProductionOrderId(productionOrderId);
            }
            validatePickParam(resolvedDto, rowNo);
 
            if (resolvedDto.getId() == null) {
                addNewPickInUpdate(resolvedDto, rowNo);
                continue;
            }
            keepPickIdSet.add(resolvedDto.getId());
            updateExistingPick(resolvedDto, rowNo, existingPickMap);
        }
        // 清理前端未回传旧行并回补库存。
        processMissingPickItems(dto, existingPickMap, productionOrderId, keepPickIdSet);
        return true;
    }
 
    @Override
    public List<ProductionOrderPickVo> listPickedDetail(Long productionOrderId) {
        // 查询订单领料明细,并补齐批次展示字段。
        if (productionOrderId == null) {
            return Collections.emptyList();
        }
        List<ProductionOrderPickVo> detailList = baseMapper.listPickedDetailByOrderId(productionOrderId);
        fillBatchNoList(detailList);
        fillSelectableBatchNoList(detailList);
        return detailList;
    }
 
    private void processDeletePickIds(ProductionOrderPickDto rootDto,
                                      Map<Long, ProductionOrderPick> existingPickMap,
                                      Long productionOrderId) {
        // 处理前端显式删除ID:
        // 1) 校验删除目标是否属于当前订单;
        // 2) 回补库存;
        // 3) 删除主记录;
        // 4) 记录删除流水。
        if (rootDto.getDeletePickIds() == null || rootDto.getDeletePickIds().isEmpty()) {
            return;
        }
        Set<Long> deleteIdSet = new LinkedHashSet<>(rootDto.getDeletePickIds());
        for (Long deleteId : deleteIdSet) {
            if (deleteId == null) {
                continue;
            }
            ProductionOrderPick existingPick = existingPickMap.get(deleteId);
            if (existingPick == null || !Objects.equals(existingPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("删除失败:领料记录不存在或不属于当前订单,ID=" + deleteId);
            }
            String oldBatchNo = resolveInventoryBatchNoFromStored(existingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(existingPick.getQuantity());
            addInventory(existingPick.getId(), existingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // 删除关联领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, existingPick.getId())
            );
            int affected = baseMapper.deleteById(deleteId);
            if (affected <= 0) {
                throw new ServiceException("删除领料记录失败,ID=" + deleteId);
            }
            insertPickRecord(existingPick.getId(),
                    existingPick.getProductionOrderId(),
                    rootDto.getProductionOperationTaskId(),
                    existingPick.getProductModelId(),
                    oldBatchNo,
                    oldQuantity,
                    oldQuantity,
                    BigDecimal.ZERO,
                    rootDto.getPickType(),
                    rootDto.getRemark(),
                    rootDto.getFeedingReason());
            existingPickMap.remove(deleteId);
        }
    }
 
    private void processMissingPickItems(ProductionOrderPickDto rootDto,
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId,
                                         Set<Long> keepPickIdSet) {
        // 处理“前端未回传”的旧行:
        // 对应场景是用户在前端删除行但未放入 deletePickIds。
        // 这里兜底识别并执行回补库存 + 删除主记录 + 写流水。
        if (rootDto.getPickList() == null) {
            return;
        }
        List<ProductionOrderPick> missingPickList = existingPickMap.values().stream()
                .filter(Objects::nonNull)
                .filter(item -> item.getId() != null)
                .filter(item -> Objects.equals(item.getProductionOrderId(), productionOrderId))
                .filter(item -> !keepPickIdSet.contains(item.getId()))
                .toList();
        for (ProductionOrderPick missingPick : missingPickList) {
            String oldBatchNo = resolveInventoryBatchNoFromStored(missingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(missingPick.getQuantity());
            addInventory(missingPick.getId(), missingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // 删除关联领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, missingPick.getId())
            );
            int affected = baseMapper.deleteById(missingPick.getId());
            if (affected <= 0) {
                throw new ServiceException("删除未回传领料记录失败,ID=" + missingPick.getId());
            }
            insertPickRecord(missingPick.getId(),
                    missingPick.getProductionOrderId(),
                    rootDto.getProductionOperationTaskId(),
                    missingPick.getProductModelId(),
                    oldBatchNo,
                    oldQuantity,
                    oldQuantity,
                    BigDecimal.ZERO,
                    rootDto.getPickType(),
                    rootDto.getRemark(),
                    rootDto.getFeedingReason());
            existingPickMap.remove(missingPick.getId());
        }
    }
 
    private void addNewPickInUpdate(ProductionOrderPickDto dto, int rowNo) {
        // 更新场景下新增一条领料:
        // 新增主记录 -> 出库申请并审批 -> 写流水。
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
        String storedBatchNo = formatBatchNoStorage(batchNoList);
 
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
        orderPick.setProductModelId(dto.getProductModelId());
        orderPick.setBatchNo(storedBatchNo);
        orderPick.setQuantity(dto.getPickQuantity());
        orderPick.setRemark(dto.getRemark());
        orderPick.setOperationName(dto.getOperationName());
        orderPick.setTechnologyOperationId(dto.getTechnologyOperationId());
        orderPick.setDemandedQuantity(dto.getDemandedQuantity());
        orderPick.setBom(dto.getBom());
        orderPick.setReturned(false);
        baseMapper.insert(orderPick);
 
        // 先新增出库申请,再审批通过,完成库存扣减。
        subtractInventory(orderPick.getId(), dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
 
        insertPickRecord(orderPick.getId(),
                dto.getProductionOrderId(),
                dto.getProductionOperationTaskId(),
                dto.getProductModelId(),
                inventoryBatchNo,
                dto.getPickQuantity(),
                BigDecimal.ZERO,
                dto.getPickQuantity(),
                dto.getPickType(),
                dto.getRemark(),
                dto.getFeedingReason());
    }
 
    private void processFeedingPickItems(ProductionOrderPickDto rootDto,
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId) {
        // 补料流程入口:
        // 逐行校验补料参数,校验原领料归属,再执行补料库存扣减和主记录回写。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(rootDto, pickItems.get(i));
            if (isEmptyUpdateItem(resolvedDto)) {
                continue;
            }
            if (!isFeedingPick(resolvedDto)) {
                throw new ServiceException("补料请求中存在非补料类型数据");
            }
            if (resolvedDto.getProductionOrderId() == null) {
                resolvedDto.setProductionOrderId(productionOrderId);
            }
            validateFeedingParam(resolvedDto, rowNo);
 
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "行补料失败:未找到对应的领料记录");
            }
            addFeedingPick(resolvedDto, oldPick, rowNo);
        }
    }
 
    private void addFeedingPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // 补料核心:
        // 1) 校验规格一致;
        // 2) 扣减补料库存;
        // 3) 写补料流水;
        // 4) 回写主单累计补料量和实际量。
        if (dto.getProductModelId() != null && !Objects.equals(dto.getProductModelId(), oldPick.getProductModelId())) {
            throw new ServiceException("第" + rowNo + "行补料失败:产品规格与原领料记录不一致");
        }
        Long productModelId = oldPick.getProductModelId();
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = batchNoList.isEmpty()
                ? resolveInventoryBatchNoFromStored(oldPick.getBatchNo())
                : formatBatchNoStorage(batchNoList);
        BigDecimal feedingQuantity = dto.getFeedingQuantity();
 
        subtractInventory(oldPick.getId(), productModelId, inventoryBatchNo, feedingQuantity, rowNo, FEED_STOCK_OUT_RECORD_TYPE);
 
        // 计算补料前后数量并写补料流水。
        BigDecimal beforeFeedingQty = sumFeedingQuantity(dto.getProductionOrderId(), oldPick.getId());
        BigDecimal afterFeedingQty = beforeFeedingQty.add(feedingQuantity);
        insertPickRecord(oldPick.getId(),
                dto.getProductionOrderId(),
                dto.getProductionOperationTaskId(),
                productModelId,
                inventoryBatchNo,
                feedingQuantity,
                beforeFeedingQty,
                afterFeedingQty,
                PICK_TYPE_FEEDING,
                dto.getRemark(),
                dto.getFeedingReason());
 
        ProductionOrderPick updatePick = new ProductionOrderPick();
        updatePick.setId(oldPick.getId());
        updatePick.setFeedingQty(afterFeedingQty);
        updatePick.setActualQty(calculateActualQty(oldPick, afterFeedingQty));
        // 回写主记录的补料累计值与实际用量。
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "行补料失败:更新领料主记录失败");
        }
        oldPick.setFeedingQty(afterFeedingQty);
        oldPick.setActualQty(updatePick.getActualQty());
    }
 
    private void processReturnPickItems(ProductionOrderPickDto rootDto,
                                        Map<Long, ProductionOrderPick> existingPickMap,
                                        Long productionOrderId) {
        // 退料流程入口:
        // 逐行校验退料参数与领料归属,再更新退料量与实际量字段。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(rootDto, pickItems.get(i));
            if (isEmptyUpdateItem(resolvedDto)) {
                continue;
            }
            if (resolvedDto.getProductionOrderId() == null) {
                resolvedDto.setProductionOrderId(productionOrderId);
            }
            validateReturnParam(resolvedDto, rowNo);
 
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "行退料失败:未找到对应的领料记录");
            }
            updateReturnPick(resolvedDto, oldPick, rowNo);
        }
    }
 
    private void updateReturnPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // 退料更新:
        // 1) returnQty 按“本次退料量”处理;
        // 2) 本次退料量回补到“生产退料入库”;
        // 3) 累加主记录退料总量并重算实际量。
        BigDecimal oldReturnQty = defaultDecimal(oldPick.getReturnQty());
        BigDecimal currentReturnQty = defaultDecimal(dto.getReturnQty());
        BigDecimal totalReturnQty = oldReturnQty.add(currentReturnQty);
        if (currentReturnQty.compareTo(BigDecimal.ZERO) > 0) {
            String returnBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
            addInventoryRecordOnly(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
        }
 
        BigDecimal actualQty = defaultDecimal(oldPick.getQuantity())
                .add(defaultDecimal(oldPick.getFeedingQty()))
                .subtract(totalReturnQty);
        if (actualQty.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:累计退料数量不能大于可用数量");
        }
        ProductionOrderPick updatePick = new ProductionOrderPick();
        updatePick.setId(oldPick.getId());
        updatePick.setReturnQty(totalReturnQty);
        updatePick.setActualQty(actualQty);
        updatePick.setReturned(totalReturnQty.compareTo(BigDecimal.ZERO) > 0);
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:更新领料主记录失败");
        }
        oldPick.setReturnQty(updatePick.getReturnQty());
        oldPick.setActualQty(updatePick.getActualQty());
        oldPick.setReturned(updatePick.getReturned());
    }
 
    private void updateExistingPick(ProductionOrderPickDto dto,
                                    int rowNo,
                                    Map<Long, ProductionOrderPick> existingPickMap) {
        // 普通更新单行核心流程:
        // 1) 校验旧记录存在且属于当前订单;
        // 2) 比较新旧“规格+批次”,决定库存处理策略;
        // 3) 更新主记录;
        // 4) 写变更流水(记录前后数量变化)。
        ProductionOrderPick oldPick = existingPickMap.get(dto.getId());
        if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), dto.getProductionOrderId())) {
            throw new ServiceException("第" + rowNo + "行更新失败:未找到对应的领料记录");
        }
 
        Long oldProductModelId = oldPick.getProductModelId();
        String oldBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
        BigDecimal oldQuantity = defaultDecimal(oldPick.getQuantity());
 
        Long newProductModelId = dto.getProductModelId();
        List<String> newBatchNoList = resolveBatchNoList(dto);
        String newBatchNo = pickInventoryBatchNo(newBatchNoList);
        String newStoredBatchNo = formatBatchNoStorage(newBatchNoList);
        BigDecimal newQuantity = dto.getPickQuantity();
 
        // 判断规格+批次或数量是否变化,并按场景处理库存:
        // 1) 同规格同批次:按差值处理(增量扣减 / 减量回退);
        // 2) 规格或批次变化:回退旧领料后再重提新领料。
        boolean sameStockKey = Objects.equals(oldProductModelId, newProductModelId)
                && Objects.equals(oldBatchNo, newBatchNo);
        boolean quantityChanged = oldQuantity.compareTo(newQuantity) != 0;
        boolean needReissuePickRecord = !sameStockKey || quantityChanged;
        if (sameStockKey) {
            BigDecimal deltaQuantity = newQuantity.subtract(oldQuantity);
            if (deltaQuantity.compareTo(BigDecimal.ZERO) > 0) {
                // 数量增加,只扣减新增部分。
                subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, deltaQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            } else if (deltaQuantity.compareTo(BigDecimal.ZERO) < 0) {
                // 数量减少,只回退差值部分。
                addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, deltaQuantity.abs(), PICK_RETURN_IN_RECORD_TYPE);
            }
        } else {
            // 规格或批次变化:先全量回退旧领料,再全量扣减新领料。
            addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, newQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        }
        if (needReissuePickRecord) {
            // 正常领料流水按“最新领料量”重建,避免保留历史旧值。
            deleteNormalPickRecord(oldPick.getId());
        }
 
        oldPick.setProductModelId(newProductModelId);
        oldPick.setBatchNo(newStoredBatchNo);
        oldPick.setQuantity(newQuantity);
        oldPick.setRemark(dto.getRemark());
        oldPick.setOperationName(dto.getOperationName());
        oldPick.setTechnologyOperationId(dto.getTechnologyOperationId());
        // 普通更新也要同步重算实际用量,避免沿用旧值。
        // 规则:实际用量 = 领料数量 + 补料数量 - 退料数量。
        oldPick.setActualQty(calculateActualQty(oldPick, oldPick.getFeedingQty()));
        if (dto.getDemandedQuantity() != null) {
            oldPick.setDemandedQuantity(dto.getDemandedQuantity());
        }
        if (dto.getBom() != null) {
            oldPick.setBom(dto.getBom());
        }
        int affected = baseMapper.updateById(oldPick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "行更新失败:更新领料记录失败");
        }
 
        // 如果发生领料重提,补写一条新的正常领料流水。
        if (needReissuePickRecord) {
            insertPickRecord(oldPick.getId(),
                    dto.getProductionOrderId(),
                    dto.getProductionOperationTaskId(),
                    newProductModelId,
                    newBatchNo,
                    newQuantity,
                    BigDecimal.ZERO,
                    newQuantity,
                    dto.getPickType(),
                    dto.getRemark(),
                    dto.getFeedingReason());
        }
    }
 
    private void insertPickRecord(Long pickId,
                                  Long productionOrderId,
                                  Long productionOperationTaskId,
                                  Long productModelId,
                                  String batchNo,
                                  BigDecimal pickQuantity,
                                  BigDecimal beforeQuantity,
                                  BigDecimal afterQuantity,
                                  Byte pickType,
                                  String remark,
                                  String feedingReason) {
        // 写领料流水记录:统一记录领料/补料/退料数量变化轨迹。
        ProductionOrderPickRecord pickRecord = new ProductionOrderPickRecord();
        pickRecord.setPickId(pickId);
        pickRecord.setProductionOrderId(productionOrderId);
        pickRecord.setProductionOperationTaskId(productionOperationTaskId);
        pickRecord.setProductModelId(productModelId);
        pickRecord.setBatchNo(batchNo);
        pickRecord.setPickQuantity(defaultDecimal(pickQuantity));
        pickRecord.setBeforeQuantity(defaultDecimal(beforeQuantity));
        pickRecord.setAfterQuantity(defaultDecimal(afterQuantity));
        pickRecord.setPickType(pickType == null ? PICK_TYPE_NORMAL : pickType);
        pickRecord.setRemark(remark);
        pickRecord.setFeedingReason(feedingReason);
        productionOrderPickRecordMapper.insert(pickRecord);
    }
 
    private void deleteNormalPickRecord(Long pickId) {
        // 删除该领料单历史上的“正常领料”流水,保留补料/退料流水。
        if (pickId == null) {
            return;
        }
        productionOrderPickRecordMapper.delete(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getPickId, pickId)
                        .eq(ProductionOrderPickRecord::getPickType, PICK_TYPE_NORMAL)
        );
    }
 
    private void subtractInventory(Long recordId,
                                   Long productModelId,
                                   String batchNo,
                                   BigDecimal quantity,
                                   int rowNo,
                                   String stockOutRecordType) {
        // 扣减库存总流程:
        // 1) 解析批次列表;
        // 2) 计算每个批次可用量与总可用量;
        // 3) 按批次顺序逐笔“新增出库记录并审批通过”,直到扣完目标数量;
        // 4) 任一步失败即抛错并回滚事务。
        BigDecimal deductQuantity = defaultDecimal(quantity);
        // 领料数量小于等于0时,不需要执行库存扣减。
        if (deductQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
 
        List<String> batchNoList = parseBatchNoValue(batchNo);
        if (batchNoList.isEmpty()) {
            batchNoList = Collections.singletonList(null);
        }
 
        // 先计算各批次可用量,避免边扣边算导致判断不一致。
        Map<String, BigDecimal> availableQuantityMap = new LinkedHashMap<>();
        BigDecimal totalAvailableQuantity = BigDecimal.ZERO;
        // 遍历批次,计算每个批次可用库存。
        for (String currentBatchNo : batchNoList) {
            // 查询当前规格+批次的库存记录。
            StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, currentBatchNo));
            BigDecimal availableQuantity = BigDecimal.ZERO;
            if (stockInventory != null) {
                availableQuantity = defaultDecimal(stockInventory.getQualitity())
                        .subtract(defaultDecimal(stockInventory.getLockedQuantity()));
                if (availableQuantity.compareTo(BigDecimal.ZERO) < 0) {
                    availableQuantity = BigDecimal.ZERO;
                }
            }
            availableQuantityMap.put(currentBatchNo, availableQuantity);
            totalAvailableQuantity = totalAvailableQuantity.add(availableQuantity);
        }
 
        if (deductQuantity.compareTo(totalAvailableQuantity) > 0) {
            BigDecimal shortQuantity = deductQuantity.subtract(totalAvailableQuantity);
            throw new ServiceException("第" + rowNo + "行扣减库存失败:可用库存不足,当前可用"
                    + formatQuantity(totalAvailableQuantity) + ",仍缺少" + formatQuantity(shortQuantity));
        }
 
        // 按批次顺序逐笔扣减库存。
        BigDecimal remainingQuantity = deductQuantity;
        for (Map.Entry<String, BigDecimal> entry : availableQuantityMap.entrySet()) {
            if (remainingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
                break;
            }
            BigDecimal availableQuantity = defaultDecimal(entry.getValue());
            if (availableQuantity.compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            BigDecimal currentDeductQuantity = remainingQuantity.min(availableQuantity);
            createAndApproveStockOutRecord(recordId, productModelId, entry.getKey(), currentDeductQuantity, rowNo, stockOutRecordType);
            remainingQuantity = remainingQuantity.subtract(currentDeductQuantity);
        }
 
        if (remainingQuantity.compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("第" + rowNo + "行扣减库存失败:仍有未扣减数量" + formatQuantity(remainingQuantity));
        }
    }
 
    private void createAndApproveStockOutRecord(Long recordId,
                                                Long productModelId,
                                                String batchNo,
                                                BigDecimal quantity,
                                                int rowNo,
                                                String stockOutRecordType) {
        // 库存扣减改为两步:
        // 1) 先调用 addStockOutRecordOnly 新增待审批出库记录;
        // 2) 再调用出库审批,审批状态固定传 1(通过)。
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryDto.setRecordType(stockOutRecordType);
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(quantity);
            stockInventoryService.addStockOutRecordOnly(stockInventoryDto);
 
            LambdaQueryWrapper<StockOutRecord> recordWrapper = Wrappers.<StockOutRecord>lambdaQuery()
                    .eq(StockOutRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockOutRecord::getRecordType, stockOutRecordType)
                    .eq(StockOutRecord::getProductModelId, productModelId)
                    .eq(StockOutRecord::getType, "0")
                    .orderByDesc(StockOutRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockOutRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockOutRecord::getBatchNo, batchNo);
            }
            StockOutRecord stockOutRecord = stockOutRecordService.getOne(recordWrapper, false);
            if (stockOutRecord == null || stockOutRecord.getId() == null) {
                throw new ServiceException("第" + rowNo + "行扣减库存失败:未找到对应出库申请记录");
            }
            stockOutRecordService.batchApprove(
                    Collections.singletonList(stockOutRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("第" + rowNo + "行扣减库存失败:" + ex.getMessage());
        }
    }
 
    private void addInventory(Long recordId,
                              Long productModelId,
                              String batchNo,
                              BigDecimal quantity,
                              String stockInRecordType) {
        // 回补库存改为两步:
        // 1) 先新增入库申请;
        // 2) 再审批通过,确保库存立刻回补生效。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(addQuantity);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
 
            LambdaQueryWrapper<StockInRecord> recordWrapper = Wrappers.<StockInRecord>lambdaQuery()
                    .eq(StockInRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockInRecord::getRecordType, stockInventoryDto.getRecordType())
                    .eq(StockInRecord::getProductModelId, productModelId)
                    .eq(StockInRecord::getType, "0")
                    .orderByDesc(StockInRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockInRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockInRecord::getBatchNo, batchNo);
            }
            StockInRecord stockInRecord = stockInRecordService.getOne(recordWrapper, false);
            if (stockInRecord == null || stockInRecord.getId() == null) {
                throw new ServiceException("回补库存失败:未找到对应入库申请记录");
            }
            stockInRecordService.batchApprove(
                    Collections.singletonList(stockInRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("回补库存失败:" + ex.getMessage());
        }
    }
 
    private void addInventoryRecordOnly(Long recordId,
                                        Long productModelId,
                                        String batchNo,
                                        BigDecimal quantity,
                                        String stockInRecordType) {
        // 仅记录入库申请,不做审核通过。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(addQuantity);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("退料入库记录保存失败:" + ex.getMessage());
        }
    }
 
    private List<ProductionOrderPickDto> resolvePickItems(ProductionOrderPickDto dto) {
        // 解析新增场景的领料明细集合。
        if (dto == null) {
            throw new ServiceException("参数不能为空");
        }
        if (dto.getPickList() != null && !dto.getPickList().isEmpty()) {
            return dto.getPickList();
        }
        return Collections.singletonList(dto);
    }
 
    private List<ProductionOrderPickDto> resolveUpdateItems(ProductionOrderPickDto dto) {
        // 解析更新场景的领料明细集合。
        if (dto.getPickList() != null) {
            return dto.getPickList();
        }
        if (isEmptyUpdateItem(dto)) {
            return Collections.emptyList();
        }
        return Collections.singletonList(dto);
    }
 
    private boolean isEmptyUpdateItem(ProductionOrderPickDto dto) {
        // 判断更新行是否为空白占位行。
        return dto.getId() == null
                && dto.getProductModelId() == null
                && dto.getPickQuantity() == null
                && StringUtils.isEmpty(dto.getBatchNo())
                && (dto.getBatchNoList() == null || dto.getBatchNoList().isEmpty())
                && dto.getPickType() == null
                && dto.getFeedingQuantity() == null
                && StringUtils.isEmpty(dto.getFeedingReason())
                && dto.getReturnQty() == null
                && dto.getActualQty() == null
                && dto.getReturned() == null
                && dto.getProductionOperationTaskId() == null
                && dto.getTechnologyOperationId() == null
                && StringUtils.isEmpty(dto.getOperationName())
                && dto.getDemandedQuantity() == null
                && dto.getBom() == null
                && StringUtils.isEmpty(dto.getRemark());
    }
 
    private Long resolveProductionOrderId(ProductionOrderPickDto dto) {
        // 优先从主DTO解析订单ID,不存在时再从子项中回退查找。
        if (dto.getProductionOrderId() != null) {
            return dto.getProductionOrderId();
        }
        if (dto.getPickList() == null || dto.getPickList().isEmpty()) {
            return null;
        }
        return dto.getPickList().stream()
                .filter(Objects::nonNull)
                .map(ProductionOrderPickDto::getProductionOrderId)
                .filter(Objects::nonNull)
                .findFirst()
                .orElse(null);
    }
 
    private ProductionOrderPickDto mergeDto(ProductionOrderPickDto rootDto, ProductionOrderPickDto itemDto) {
        // 合并规则:
        // - itemDto 优先承载行级输入;
        // - itemDto 缺失字段从 rootDto 兜底继承;
        // - 输出 merged 作为统一业务入参。
        ProductionOrderPickDto merged = new ProductionOrderPickDto();
        // 先拷贝行级字段。
        if (itemDto != null) {
            merged.setId(itemDto.getId());
            merged.setProductionOrderId(itemDto.getProductionOrderId());
            merged.setProductionOperationTaskId(itemDto.getProductionOperationTaskId());
            merged.setProductModelId(itemDto.getProductModelId());
            merged.setBatchNo(itemDto.getBatchNo());
            merged.setBatchNoList(itemDto.getBatchNoList());
            merged.setPickQuantity(itemDto.getPickQuantity());
            merged.setPickType(itemDto.getPickType());
            merged.setRemark(itemDto.getRemark());
            merged.setFeedingReason(itemDto.getFeedingReason());
            merged.setFeedingQuantity(itemDto.getFeedingQuantity());
            merged.setTechnologyOperationId(itemDto.getTechnologyOperationId());
            merged.setOperationName(itemDto.getOperationName());
            merged.setDemandedQuantity(itemDto.getDemandedQuantity());
            merged.setBom(itemDto.getBom());
            merged.setReturnQty(itemDto.getReturnQty());
            merged.setActualQty(itemDto.getActualQty());
            merged.setReturned(itemDto.getReturned());
        }
        if (merged.getId() == null) {
            merged.setId(rootDto.getId());
        }
        if (merged.getProductionOrderId() == null) {
            merged.setProductionOrderId(rootDto.getProductionOrderId());
        }
        if (merged.getProductionOperationTaskId() == null) {
            merged.setProductionOperationTaskId(rootDto.getProductionOperationTaskId());
        }
        if (merged.getProductModelId() == null) {
            merged.setProductModelId(rootDto.getProductModelId());
        }
        if (merged.getBatchNo() == null) {
            merged.setBatchNo(rootDto.getBatchNo());
        }
        if (merged.getBatchNoList() == null || merged.getBatchNoList().isEmpty()) {
            merged.setBatchNoList(rootDto.getBatchNoList());
        }
        if (merged.getPickQuantity() == null) {
            merged.setPickQuantity(rootDto.getPickQuantity());
        }
        if (merged.getPickType() == null) {
            merged.setPickType(rootDto.getPickType());
        }
        if (merged.getRemark() == null) {
            merged.setRemark(rootDto.getRemark());
        }
        if (merged.getFeedingReason() == null) {
            merged.setFeedingReason(rootDto.getFeedingReason());
        }
        if (merged.getFeedingQuantity() == null) {
            merged.setFeedingQuantity(rootDto.getFeedingQuantity());
        }
        if (merged.getTechnologyOperationId() == null) {
            merged.setTechnologyOperationId(rootDto.getTechnologyOperationId());
        }
        if (merged.getOperationName() == null) {
            merged.setOperationName(rootDto.getOperationName());
        }
        if (merged.getDemandedQuantity() == null) {
            merged.setDemandedQuantity(rootDto.getDemandedQuantity());
        }
        if (merged.getBom() == null) {
            merged.setBom(rootDto.getBom());
        }
        if (merged.getReturnQty() == null) {
            merged.setReturnQty(rootDto.getReturnQty());
        }
        if (merged.getActualQty() == null) {
            merged.setActualQty(rootDto.getActualQty());
        }
        if (merged.getReturned() == null) {
            merged.setReturned(rootDto.getReturned());
        }
        return merged;
    }
 
    private void validatePickParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验普通领料参数(订单、规格、数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getProductModelId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:产品规格不能为空");
        }
        if (dto.getPickQuantity() == null || dto.getPickQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行参数错误:领料数量不能小于0");
        }
        if (dto.getPickType() != null && dto.getPickType() != PICK_TYPE_NORMAL && dto.getPickType() != PICK_TYPE_FEEDING) {
            throw new ServiceException("第" + rowNo + "行参数错误:领料类型仅支持1(领料)或2(补料)");
        }
    }
 
    private void validateFeedingParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验补料参数(订单、领料ID、补料数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getFeedingQuantity() == null || dto.getFeedingQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行参数错误:补料数量不能小于0");
        }
        if (!isFeedingPick(dto)) {
            throw new ServiceException("第" + rowNo + "行参数错误:补料场景下领料类型必须为2");
        }
    }
 
    private void validateReturnParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验退料参数(订单、领料ID、退料量)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getReturnQty() == null || dto.getReturnQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行参数错误:退料数量不能小于0");
        }
    }
 
    private boolean isFeedingRequest(ProductionOrderPickDto dto) {
        // 判断当前请求是否属于补料流程。
        if (isFeedingPick(dto)) {
            return true;
        }
        if (dto.getPickList() == null || dto.getPickList().isEmpty()) {
            return false;
        }
        return dto.getPickList().stream()
                .filter(Objects::nonNull)
                .anyMatch(this::isFeedingPick);
    }
 
    private boolean isFeedingPick(ProductionOrderPickDto dto) {
        // 判断当前行是否为补料类型。
        return dto != null && Objects.equals(dto.getPickType(), PICK_TYPE_FEEDING);
    }
 
    private boolean isReturnRequest(ProductionOrderPickDto dto) {
        // 判断当前请求是否属于退料流程。
        if (isReturnPick(dto)) {
            return true;
        }
        if (dto.getPickList() == null || dto.getPickList().isEmpty()) {
            return false;
        }
        return dto.getPickList().stream()
                .filter(Objects::nonNull)
                .anyMatch(this::isReturnPick);
    }
 
    private boolean isReturnPick(ProductionOrderPickDto dto) {
        // 判断当前行是否为退料类型。
        return dto != null && Boolean.TRUE.equals(dto.getReturned());
    }
 
    private BigDecimal sumFeedingQuantity(Long productionOrderId, Long pickId) {
        // 汇总指定领料单的历史补料总量。
        List<ProductionOrderPickRecord> feedingRecords = productionOrderPickRecordMapper.selectList(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getProductionOrderId, productionOrderId)
                        .eq(ProductionOrderPickRecord::getPickId, pickId)
                        .eq(ProductionOrderPickRecord::getPickType, PICK_TYPE_FEEDING));
        return feedingRecords.stream()
                .map(ProductionOrderPickRecord::getPickQuantity)
                .map(this::defaultDecimal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
 
    private BigDecimal calculateActualQty(ProductionOrderPick pick, BigDecimal feedingQty) {
        // 按“领料+补料-退料”计算实际用量。
        return defaultDecimal(pick.getQuantity())
                .add(defaultDecimal(feedingQty))
                .subtract(defaultDecimal(pick.getReturnQty()));
    }
 
    private String normalizeBatchNo(String batchNo) {
        // 标准化批次号(去空白、空串转null)。
        if (StringUtils.isEmpty(batchNo)) {
            return null;
        }
        String trimBatchNo = batchNo.trim();
        return trimBatchNo.isEmpty() ? null : trimBatchNo;
    }
    private List<String> resolveBatchNoList(ProductionOrderPickDto dto) {
        // 优先解析 batchNoList,空则回退解析 batchNo 字符串。
        List<String> normalizedBatchNoList = normalizeBatchNoList(dto.getBatchNoList());
        if (!normalizedBatchNoList.isEmpty()) {
            return normalizedBatchNoList;
        }
        return parseBatchNoValue(dto.getBatchNo());
    }
 
    private String pickInventoryBatchNo(List<String> batchNoList) {
        // 从批次集合中取库存扣减使用的批次。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
        return batchNoList.get(0);
    }
 
    private String resolveInventoryBatchNoFromStored(String storedBatchNo) {
        // 从数据库存储批次字段中反解可用批次。
        return pickInventoryBatchNo(parseBatchNoValue(storedBatchNo));
    }
 
    private String formatBatchNoStorage(List<String> batchNoList) {
        // 将批次集合格式化为数据库存储值。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
        if (batchNoList.size() == 1) {
            return batchNoList.get(0);
        }
        return String.join(",", batchNoList);
    }
 
    private List<String> normalizeBatchNoList(List<String> batchNoList) {
        // 批量标准化批次号并去重。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return Collections.emptyList();
        }
        LinkedHashSet<String> normalizedSet = new LinkedHashSet<>();
        for (String batchNo : batchNoList) {
            String normalizedBatchNo = normalizeBatchNo(batchNo);
            if (!StringUtils.isEmpty(normalizedBatchNo)) {
                normalizedSet.add(normalizedBatchNo);
            }
        }
        return new ArrayList<>(normalizedSet);
    }
 
    private void fillBatchNoList(List<ProductionOrderPickVo> detailList) {
        // 将同订单+同规格+同工序的数据按组聚合批次,便于前端统一展示。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        Map<String, LinkedHashSet<String>> batchNoGroupMap = new HashMap<>();
        for (ProductionOrderPickVo detail : detailList) {
            String key = buildBatchNoGroupKey(detail);
            LinkedHashSet<String> batchSet = batchNoGroupMap.computeIfAbsent(key, k -> new LinkedHashSet<>());
            batchSet.addAll(parseBatchNoValue(detail.getBatchNo()));
            if (detail.getBatchNoList() != null && !detail.getBatchNoList().isEmpty()) {
                batchSet.addAll(normalizeBatchNoList(detail.getBatchNoList()));
            }
        }
        for (ProductionOrderPickVo detail : detailList) {
            String key = buildBatchNoGroupKey(detail);
            LinkedHashSet<String> batchSet = batchNoGroupMap.get(key);
            detail.setBatchNoList(batchSet == null ? Collections.emptyList() : new ArrayList<>(batchSet));
        }
    }
 
    private void fillSelectableBatchNoList(List<ProductionOrderPickVo> detailList) {
        // 合并“已选批次”和“库存可选批次”,用于前端下拉。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        // 先收集明细中涉及的规格ID,批量查询库存批次。
        Set<Long> productModelIdSet = detailList.stream()
                .map(ProductionOrderPickVo::getProductModelId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (productModelIdSet.isEmpty()) {
            return;
        }
        List<StockInventory> stockBatchList = stockInventoryMapper.listSelectableBatchNoByProductModelIds(
                new ArrayList<>(productModelIdSet));
        Map<Long, LinkedHashSet<String>> stockBatchMap = new HashMap<>();
        for (StockInventory stockInventory : stockBatchList) {
            if (stockInventory == null || stockInventory.getProductModelId() == null) {
                continue;
            }
            String normalizedBatchNo = normalizeBatchNo(stockInventory.getBatchNo());
            if (StringUtils.isEmpty(normalizedBatchNo)) {
                continue;
            }
            stockBatchMap.computeIfAbsent(stockInventory.getProductModelId(), k -> new LinkedHashSet<>())
                    .add(normalizedBatchNo);
        }
        for (ProductionOrderPickVo detail : detailList) {
            LinkedHashSet<String> mergedBatchSet = new LinkedHashSet<>();
            mergedBatchSet.addAll(normalizeBatchNoList(detail.getBatchNoList()));
            LinkedHashSet<String> selectableBatchSet = stockBatchMap.get(detail.getProductModelId());
            if (selectableBatchSet != null) {
                mergedBatchSet.addAll(selectableBatchSet);
            }
            detail.setBatchNoList(new ArrayList<>(mergedBatchSet));
        }
    }
 
    private String buildBatchNoGroupKey(ProductionOrderPickVo detail) {
        // 构建批次聚合分组键。
        return detail.getProductionOrderId() + "|"
                + detail.getProductModelId() + "|"
                + detail.getTechnologyOperationId() + "|"
                + detail.getOperationName();
    }
 
    private List<String> parseBatchNoValue(String rawBatchNoValue) {
        // 批次解析兼容三种格式:
        // 1) 单值:A001
        // 2) 逗号分隔:A001,A002
        // 3) 类JSON数组字符串:["A001","A002"]
        String normalizedValue = normalizeBatchNo(rawBatchNoValue);
        if (StringUtils.isEmpty(normalizedValue)) {
            return Collections.emptyList();
        }
        if (normalizedValue != null && normalizedValue.startsWith("[") && normalizedValue.endsWith("]")) {
            String value = normalizedValue.substring(1, normalizedValue.length() - 1);
            if (StringUtils.isEmpty(value)) {
                return Collections.emptyList();
            }
            List<String> parsed = Arrays.stream(value.split(","))
                    .map(item -> item.trim().replace("\"", "").replace("'", ""))
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
        if (normalizedValue != null && normalizedValue.contains(",")) {
            List<String> parsed = Arrays.stream(normalizedValue.split(","))
                    .map(item -> item.trim())
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
        return Collections.singletonList(normalizedValue);
    }
 
    private LambdaQueryWrapper<StockInventory> buildStockWrapper(Long productModelId, String batchNo) {
        // 构建库存查询条件(规格 + 批次)。
        LambdaQueryWrapper<StockInventory> wrapper = Wrappers.<StockInventory>lambdaQuery()
                .eq(StockInventory::getProductModelId, productModelId);
        if (StringUtils.isEmpty(batchNo)) {
            wrapper.isNull(StockInventory::getBatchNo);
        } else {
            wrapper.eq(StockInventory::getBatchNo, batchNo);
        }
        return wrapper;
    }
 
    private BigDecimal defaultDecimal(BigDecimal value) {
        // BigDecimal 空值兜底,统一按0处理。
        return value == null ? BigDecimal.ZERO : value;
    }
 
    private String formatQuantity(BigDecimal value) {
        // 数量格式化输出(去除末尾无效0)。
        return defaultDecimal(value).stripTrailingZeros().toPlainString();
    }
}