7 小时以前 7bf754a7835d06f26240c4ca15bc5f83650de377
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
package com.ruoyi.stock.service.impl;
 
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.mapper.sales.AccountInvoiceApplicationMapper;
import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
import com.ruoyi.basic.pojo.Vehicle;
import com.ruoyi.basic.service.IVehicleService;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.EnumUtil;
import com.ruoyi.common.utils.OrderUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.outsourcing.enums.OutsourcingOrderStatusEnum;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderProductMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.mapper.SalesLedgerArrivalMapper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.StockOutRecordSalesLedgerMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.StockOutRecordSalesLedger;
 
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.dto.StockUninventoryDto;
import com.ruoyi.stock.execl.StockOutRecordExportData;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.mapper.StockOutRecordMapper;
import com.ruoyi.stock.mapper.StockUninventoryMapper;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.pojo.StockUninventory;
import com.ruoyi.stock.service.StockOutRecordService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
 
/**
 * <p>
 * 出库记录表 服务实现类
 * </p>
 *
 * @author 芯导软件(江苏)有限公司
 * @since 2026-01-21 05:27:04
 */
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> implements StockOutRecordService {
 
    /**
     * 出库类别:销售。唯一的区别是销售类出库必须选车辆、按车辆算上次剩余油量;
     * 扣库存、进发货台账这些对所有出库类别一视同仁
     */
    private static final String OUT_CATEGORY_SALE = "销售";
 
    /**
     * 出库类别:代储。客户把自己的油寄存在我们的罐里。
     * 出库要求与销售一致(选批次、审批扣库存、删除归还、进发货台账),只是不要求选车辆
     */
    private static final String OUT_CATEGORY_DEPOSIT = "代储";
 
    /**
     * 销售台账的销售类型:代储(卖存储位置)。这类台账没有产品明细,只有油库+储罐,见 SalesLedger.ledgerType
     */
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
 
    /**
     * 销售台账产品明细的行类型:销售。见 sales_ledger_product.type
     */
    private static final Integer LEDGER_PRODUCT_TYPE_SALE = 1;
 
    private final StockOutRecordMapper stockOutRecordMapper;
    private final IVehicleService vehicleService;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockUninventoryMapper stockUninventoryMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final AccountInvoiceApplicationMapper accountInvoiceApplicationMapper;
    private final OutsourcingOrderProductMapper outsourcingOrderProductMapper;
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final StockOutRecordSalesLedgerMapper stockOutRecordSalesLedgerMapper;
    private final SalesLedgerArrivalMapper salesLedgerArrivalMapper;
 
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
        return stockOutRecordMapper.listPage(page, stockOutRecordDto);
    }
 
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        LocalDateTime createTime = stockOutRecordDto.getCreateTime();
        if (createTime == null) {
            createTime = LocalDateTime.now();
        }
        // 出库单号先于校验生成,好让校验失败时的报错带上出库批次
        String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", createTime);
        stockOutRecordDto.setOutboundBatches(no);
        stockOutRecordDto.setCreateTime(createTime);
        // 油品出库(type 为空)审批通过时一律按批次扣减库存,批次不能为空。
        // type=0/1 的合格、不合格出库走各自分支,不受这里约束
        if (StringUtils.isEmpty(stockOutRecordDto.getType())) {
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("油品出库必须选择出库批次");
            }
            if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
                // 只有销售类按车辆算残油,代储/客存不要求选车辆
                resolveVehicle(stockOutRecordDto);
            }
            checkOilOutAvailable(stockOutRecordDto);
        }
        if (StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode().equals(stockOutRecordDto.getRecordType())){
            stockOutRecordDto.setApprovalStatus(3);
        }
        int rows = stockOutRecordMapper.insert(stockOutRecordDto);
        // 出库单 id 由自增主键回填,拿到后才能落绑定行
        saveLedgerBindings(stockOutRecordDto.getId(), ledgerBindings);
        return rows;
    }
 
    @Override
    public int update(Long id, StockOutRecordDto stockOutRecordDto) {
        // 判断对象是否存在
        StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
        if (stockOutRecord == null){
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
 
        List<StockOutRecordSalesLedger> ledgerBindings = resolveSalesLedgerBinding(stockOutRecordDto);
        // 排除 id 属性;recordType/recordId 由后端维护(油品出库恒为 '1' / 0,手工发货单出库由发货流程写入),
        // 前端编辑表单不回传会把它们冲成 null,破坏出库记录的来源标识
        String[] ignoreProperties = {"id", "outbound_batches", "recordType", "recordId"};
        BeanUtils.copyProperties(stockOutRecordDto, stockOutRecord, ignoreProperties);
        int rows = stockOutRecordMapper.updateById(stockOutRecord);
        rebindLedger(id, ledgerBindings);
        return rows;
    }
 
    /**
     * 绑定销售台账:一次出库可以分送多个客户,所以台账是个列表,每个台账手工填本次分摊到的数量,
     * 各台账数量之和必须等于本次出库总量。客户一律以台账为准覆盖,出库类别按台账的销售类型自动定,
     * 免得出现「绑了代储台账却按销售要求选车辆」这类矛盾数据。
     * <ul>
     *     <li>卖油(含 ledgerType 为空):出库类别置「销售」,逐个校验所选规格确实在该台账的销售明细里,
     *         并把命中的明细 id 落进绑定行(同一台账同规格多行时固定取 id 最小的那条)</li>
     *     <li>代储:出库类别置「代储」,带入台账的油库/储罐,跳过「规格是否在台账明细里」的校验 ——
     *         代储台账本身就是卖存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选。
     *         代储不能与其他台账混绑,也不产绑定行(它的油是客户自己的,不走到货)</li>
     * </ul>
     *
     * @return 待落库的绑定行;null 表示本次请求没带台账信息,调用方应保持已有绑定不变
     */
    private List<StockOutRecordSalesLedger> resolveSalesLedgerBinding(StockOutRecordDto stockOutRecordDto) {
        List<StockOutRecordSalesLedger> ledgerList = stockOutRecordDto.getLedgerList();
        if (ledgerList == null && stockOutRecordDto.getSalesLedgerId() != null) {
            // 兼容只传 salesLedgerId 的旧入参:视作「绑一个台账、数量就是本次出库总量」
            StockOutRecordSalesLedger single = new StockOutRecordSalesLedger();
            single.setSalesLedgerId(stockOutRecordDto.getSalesLedgerId());
            single.setQuantity(stockOutRecordDto.getStockOutNum());
            ledgerList = new ArrayList<>();
            ledgerList.add(single);
        } else if (ledgerList == null) {
            return null;
        }
        if (ledgerList.isEmpty()) {
            // 明确的「不绑台账」
            stockOutRecordDto.setSalesLedgerId(null);
            return ledgerList;
        }
        List<SalesLedger> ledgers = new ArrayList<>(ledgerList.size());
        for (StockOutRecordSalesLedger item : ledgerList) {
            if (item == null || item.getSalesLedgerId() == null) {
                throw new BaseException("绑定的销售台账不能为空");
            }
            SalesLedger salesLedger = salesLedgerMapper.selectById(item.getSalesLedgerId());
            if (salesLedger == null) {
                throw new BaseException("所选销售台账不存在,销售台账id:" + item.getSalesLedgerId());
            }
            ledgers.add(salesLedger);
        }
        // 首项台账继续写进出库记录的单值列,出库管理列表、库存报表都按它展示
        SalesLedger first = ledgers.get(0);
        stockOutRecordDto.setSalesLedgerId(first.getId());
        stockOutRecordDto.setCustomerId(first.getCustomerId());
        stockOutRecordDto.setCustomerName(first.getCustomerName());
 
        boolean hasDeposit = ledgers.stream().anyMatch(item -> LEDGER_TYPE_DEPOSIT.equals(item.getLedgerType()));
        if (hasDeposit) {
            if (ledgers.size() > 1) {
                throw new BaseException("代储台账不能与其他销售台账一起绑定,一次出库只能绑一个代储台账");
            }
            applyDepositLedger(stockOutRecordDto, first);
            return new ArrayList<>();
        }
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_SALE);
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("绑定销售台账时必须选择产品规格,销售合同号:" + first.getSalesContractNo());
        }
        BigDecimal stockOutNum = stockOutRecordDto.getStockOutNum();
        if (stockOutNum == null || stockOutNum.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("请填写本次出库数量(吨)");
        }
        BigDecimal total = BigDecimal.ZERO;
        for (int index = 0; index < ledgerList.size(); index++) {
            StockOutRecordSalesLedger item = ledgerList.get(index);
            SalesLedger salesLedger = ledgers.get(index);
            BigDecimal quantity = item.getQuantity();
            if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
                throw new BaseException("每个销售台账的绑定数量必须大于0,销售合同号:" + salesLedger.getSalesContractNo());
            }
            SalesLedgerProduct detail = findLedgerSaleDetail(salesLedger.getId(), stockOutRecordDto.getProductModelId());
            if (detail == null) {
                throw new BaseException("所选产品不在该销售台账明细中,销售合同号:" + salesLedger.getSalesContractNo());
            }
            item.setSalesLedgerProductId(detail.getId());
            item.setCustomerId(salesLedger.getCustomerId());
            item.setCustomerName(salesLedger.getCustomerName());
            total = total.add(quantity);
        }
        if (total.compareTo(stockOutNum) != 0) {
            throw new BaseException("各销售台账绑定数量之和必须等于本次出库数量,出库数量:" + toPlain(stockOutNum)
                    + ",绑定数量之和:" + toPlain(total));
        }
        return ledgerList;
    }
 
    /**
     * 同一台账可能有多行同规格的销售明细,固定取 id 最小的那条,保证每次绑定的明细 id 稳定
     */
    private SalesLedgerProduct findLedgerSaleDetail(Long salesLedgerId, Long productModelId) {
        List<SalesLedgerProduct> details = salesLedgerProductMapper.selectList(
                Wrappers.<SalesLedgerProduct>lambdaQuery()
                        .eq(SalesLedgerProduct::getSalesLedgerId, salesLedgerId)
                        .eq(SalesLedgerProduct::getType, LEDGER_PRODUCT_TYPE_SALE)
                        .eq(SalesLedgerProduct::getProductModelId, productModelId)
                        .orderByAsc(SalesLedgerProduct::getId));
        return details.isEmpty() ? null : details.get(0);
    }
 
    /**
     * 代储台账:卖的是存储位置、没有产品明细,但扣库存要按规格定位,规格仍必须选
     */
    private void applyDepositLedger(StockOutRecordDto stockOutRecordDto, SalesLedger salesLedger) {
        stockOutRecordDto.setOutCategory(OUT_CATEGORY_DEPOSIT);
        stockOutRecordDto.setOilDepotId(salesLedger.getOilDepotId());
        stockOutRecordDto.setOilDepotName(salesLedger.getOilDepotName());
        stockOutRecordDto.setTankId(salesLedger.getTankId());
        stockOutRecordDto.setTankNo(salesLedger.getTankNo());
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("代储出库需要扣减库存,必须选择产品规格,销售合同号:" + salesLedger.getSalesContractNo());
        }
    }
 
    /**
     * 落库绑定行。明细 id / 客户 / 出库单 id 一律用后端校验时算出的值,前端传的被覆盖。
     * 逐条 insert:本表一次出库只有几行,且 MyBaseMapper 的 insertBatchSomeColumn
     * 项目里没配自定义 SqlInjector,方法根本没注册(调用会报 Invalid bound statement)
     */
    private void saveLedgerBindings(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (stockOutRecordId == null || CollectionUtils.isEmpty(bindings)) {
            return;
        }
        for (StockOutRecordSalesLedger binding : bindings) {
            binding.setId(null);
            binding.setStockOutRecordId(stockOutRecordId);
            stockOutRecordSalesLedgerMapper.insert(binding);
        }
    }
 
    /**
     * 重新绑定台账:绑定没变就原样不动(保住绑定行 id);变了则先按出库单物理删旧再重插。
     * 已登记到货记录时不允许改 —— 到货记录挂在「台账×出库单」上,改绑会让它指向不存在的分摊数量
     */
    private void rebindLedger(Long stockOutRecordId, List<StockOutRecordSalesLedger> bindings) {
        if (bindings == null) {
            return;
        }
        List<StockOutRecordSalesLedger> existing = stockOutRecordSalesLedgerMapper.selectList(
                Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                        .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        if (sameBinding(existing, bindings)) {
            return;
        }
        if (salesLedgerArrivalMapper.countByStockOutRecordId(stockOutRecordId) > 0) {
            throw new BaseException("该出库单已登记到货记录,无法修改绑定的销售台账,出库单id:" + stockOutRecordId);
        }
        stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                .eq(StockOutRecordSalesLedger::getStockOutRecordId, stockOutRecordId));
        saveLedgerBindings(stockOutRecordId, bindings);
    }
 
    /**
     * 台账与分摊数量是否完全一致(顺序无关)
     */
    private boolean sameBinding(List<StockOutRecordSalesLedger> existing, List<StockOutRecordSalesLedger> incoming) {
        if (existing.size() != incoming.size()) {
            return false;
        }
        Map<Long, BigDecimal> existingQuantity = new HashMap<>(existing.size());
        for (StockOutRecordSalesLedger item : existing) {
            existingQuantity.put(item.getSalesLedgerId(), item.getQuantity());
        }
        for (StockOutRecordSalesLedger item : incoming) {
            BigDecimal quantity = existingQuantity.get(item.getSalesLedgerId());
            if (quantity == null || quantity.compareTo(item.getQuantity()) != 0) {
                return false;
            }
        }
        return true;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchDelete(List<Long> ids) {
        //todo 如果出库与开票收款有关联则无法删除
        if (accountSalesCollectionMapper.existsByStockOutRecordId(ids) ||
                accountInvoiceApplicationMapper.existsByStockOutRecordId(ids)) {
            throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
        }
        // 油品出库的开票/收款关联存的是绑定行 id(与出库单 id 是两个独立 id 空间),必须单独判,
        // 否则绑了台账的油品出库被开票后还能删掉,留下指向不存在绑定行的关联数据
        if (!CollectionUtils.isEmpty(ids)) {
            List<Long> bindingIds = stockOutRecordSalesLedgerMapper.selectList(
                            Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                                    .in(StockOutRecordSalesLedger::getStockOutRecordId, ids))
                    .stream().map(StockOutRecordSalesLedger::getId).toList();
            if (!CollectionUtils.isEmpty(bindingIds)
                    && (accountSalesCollectionMapper.existsByStockOutBindingId(bindingIds)
                    || accountInvoiceApplicationMapper.existsByStockOutBindingId(bindingIds))) {
                throw new BaseException("出库记录存在开票收款关联数据,无法删除!!!");
            }
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            // 到货记录挂在出库单下,删了会变成孤儿,销售台账的已发货数量也就说不清了
            if (salesLedgerArrivalMapper.countByStockOutRecordId(id) > 0) {
                throw new BaseException("出库单已登记到货记录,无法删除,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            // 绑定行是出库单的附属数据,一并清掉,不留指向已删出库单的孤儿行
            stockOutRecordSalesLedgerMapper.delete(Wrappers.<StockOutRecordSalesLedger>lambdaQuery()
                    .eq(StockOutRecordSalesLedger::getStockOutRecordId, id));
            // type 对油品出库是 null,常量写在前面避免 NPE
            if ("0".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                    .eq(StockInventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
                    wrapper.isNull(StockInventory::getBatchNo);
                } else {
                    wrapper.eq(StockInventory::getBatchNo, stockOutRecord.getBatchNo());
                }
                StockInventory stockInventory = stockInventoryMapper.selectOne(wrapper);
                if (stockInventory == null) {
                    throw new BaseException("库存记录中没有对应的产品,无法删除!!!");
                }else {
                    StockInventoryDto stockInRecordDto = new StockInventoryDto();
                    stockInRecordDto.setProductModelId(stockInventory.getProductModelId());
                    stockInRecordDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockInRecordDto.setBatchNo(stockInventory.getBatchNo());
                    stockInventoryMapper.updateAddStockInventory(stockInRecordDto);
                }
            }
            else if ("1".equals(stockOutRecord.getType())) {
                LambdaQueryWrapper<StockUninventory> wrapper = new LambdaQueryWrapper<StockUninventory>()
                        .eq(StockUninventory::getProductModelId, stockOutRecord.getProductModelId());
                if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
                    wrapper.isNull(StockUninventory::getBatchNo);
                } else {
                    wrapper.eq(StockUninventory::getBatchNo, stockOutRecord.getBatchNo());
                }
                StockUninventory stockUninventory = stockUninventoryMapper.selectOne(wrapper);
                if (stockUninventory == null) {
                    throw new BaseException("库存记录中没有对应的产品,无法删除!!!");
                }else {
                    StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
                    stockUninventoryDto.setProductModelId(stockUninventory.getProductModelId());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryDto.setBatchNo(stockUninventory.getBatchNo());
                    stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
                }
            }
            else {
                // 油品出库只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnOilOutStock(stockOutRecord);
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
 
    /**
     * 油品出库归还库存,仅对审批通过(即扣过库存)的记录生效。
     * 归还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
     */
    private void returnOilOutStock(StockOutRecord stockOutRecord) {
        if (!ReviewStatusEnum.APPROVED.getCode().equals(stockOutRecord.getApprovalStatus())
                || StringUtils.isEmpty(stockOutRecord.getBatchNo())
                || stockOutRecord.getStockOutNum() == null) {
            return;
        }
        BigDecimal returnQuantity = stockOutRecord.getDeductQuantity() != null
                ? stockOutRecord.getDeductQuantity()
                : stockOutRecord.getStockOutNum();
        if (returnQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
        stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
        stockInventoryDto.setQualitity(returnQuantity);
        stockInventoryMapper.updateAddStockInventory(stockInventoryDto);
    }
 
    @Override
    public void exportStockOutRecord(HttpServletResponse response, StockOutRecordDto stockOutRecordDto) {
        List<StockOutRecordExportData> list = stockOutRecordMapper.listStockOutRecordExportData(stockOutRecordDto);
        for (StockOutRecordExportData stockInRecordExportData : list) {
            if (stockInRecordExportData.getType().equals("0")) {
                stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockOutQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
            }else {
                stockInRecordExportData.setRecordType(EnumUtil.fromCode(StockInQualifiedRecordTypeEnum.class, Integer.parseInt(stockInRecordExportData.getRecordType())).getValue());
            }
        }
        ExcelUtil<StockOutRecordExportData> util = new ExcelUtil<>(StockOutRecordExportData.class);
        util.exportExcel(response,list, "出库记录信息");
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchDeletePending(List<Long> ids) {
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord == null) {
                throw new BaseException("出库记录不存在,无法删除!!!");
            }
            if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有待审批状态的记录才能删除,出库批次:" + stockOutRecord.getOutboundBatches());
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchApprove(List<Long> ids, Integer approvalStatus) {
        if (CollectionUtils.isEmpty(ids)) {
            throw new BaseException("请选择至少一条数据");
        }
        if (approvalStatus == null || (!ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus) && !ReviewStatusEnum.REJECTED.getCode().equals(approvalStatus))) {
            throw new BaseException("审批状态值无效");
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord == null) {
                throw new BaseException("出库记录不存在,无法审批!!!");
            }
            if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有待审批状态的记录才能审批,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            // 审批通过时先扣减库存,扣减量(deduct_quantity)要随审批状态一起落库,所以放在更新之前
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)) {
                if ("0".equals(stockOutRecord.getType())) {
                    // 合格出库 -> 先查库存是否存在,存在才扣减
                    StockInventory stockInventory = getStockInventory(stockOutRecord.getProductModelId(), stockOutRecord.getBatchNo());
                    if (stockInventory == null) {
                        throw new BaseException("合格库存记录不存在,出库批次:" + stockOutRecord.getOutboundBatches());
                    }
                    StockInventoryDto stockInventoryDto = new StockInventoryDto();
                    stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
                    stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockInventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
                } else if ("1".equals(stockOutRecord.getType())) {
                    // 不合格出库 -> 先查库存是否存在,存在才扣减
                    StockUninventory stockUninventory = getStockUninventory(stockOutRecord.getProductModelId(), stockOutRecord.getBatchNo());
                    if (stockUninventory == null) {
                        throw new BaseException("不合格库存记录不存在,出库批次:" + stockOutRecord.getOutboundBatches());
                    }
                    StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
                    stockUninventoryDto.setProductModelId(stockOutRecord.getProductModelId());
                    stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
                } else {
                    // 油品出库(type 为空)审批通过时按批次扣减库存。
                    // 不再生成发货单,也不改 record_type:油品出库的 record_type 一直是「1」,
                    // 改成「13」会让出库管理列表按出库类型筛选时看不到它。判断「是不是销售类油品出库」
                    // 一律以 binding 表(stock_out_record_sales_ledger)为准
                    deductOilOutStock(stockOutRecord);
                }
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
            stockOutRecordMapper.updateById(stockOutRecord);
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)
                    && StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode()
                            .equals(stockOutRecord.getRecordType())) {
                tryCompleteOutsourcingOrder(stockOutRecord);
            }
        }
        return ids.size();
    }
 
    /**
     * 委外报废品出库审批通过后,若该委外订单的报废品选料行已<b>全部</b>出库审批通过,
     * 自动把订单从「已确认」推到「已完成」,省掉人工再点一次完成。
     *
     * <p>直接用 mapper 而不是 {@code IOutsourcingOrderService#complete}:后者依赖本服务,
     * 两边都走构造器注入会形成循环依赖。驳回/反审不回退订单状态(处理错误审批走人工重新审批)。</p>
     */
    private void tryCompleteOutsourcingOrder(StockOutRecord record) {
        if (record.getRecordId() == null) {
            return;
        }
        OutsourcingOrderProduct line = outsourcingOrderProductMapper.selectById(record.getRecordId());
        if (line == null || line.getOutsourcingOrderId() == null) {
            return;
        }
        Long orderId = line.getOutsourcingOrderId();
        // isScrap 在 Java 侧过滤,与委外模块自己的 scrapLineIds 一致,不依赖 Boolean 字段的列名推断
        List<Long> lineIds = outsourcingOrderProductMapper.selectList(
                        Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                                .eq(OutsourcingOrderProduct::getOutsourcingOrderId, orderId))
                .stream()
                .filter(item -> Boolean.TRUE.equals(item.getIsScrap()))
                .map(OutsourcingOrderProduct::getId)
                .toList();
        if (lineIds.isEmpty()) {
            return;
        }
        // 出库记录一条对应一行明细;缺记录(被反审删除等)就不完成,与人工完成的校验口径一致
        long approvedCount = listByRecordIds(lineIds, record.getRecordType()).stream()
                .filter(item -> ReviewStatusEnum.APPROVED.getCode().equals(item.getApprovalStatus()))
                .count();
        if (approvedCount < lineIds.size()) {
            return;
        }
        // 条件更新,只有「已确认」能推进;已作废/已完成的订单不动
        outsourcingOrderMapper.update(null, Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, orderId)
                .eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.COMPLETED.getCode()));
    }
 
    /**
     * 油品出库扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * 所以只从库存补足「出库数量 − 上次剩余油量」的差额;代储/客存没选车辆,
     * 上次剩余油量按 0 算,即全额扣减。
     * updateSubtractStockInventory 自带 qualitity &gt;= 数量 的守卫,影响行数为 0 即库存不足,
     * 抛错由外层事务回滚
     */
    private void deductOilOutStock(StockOutRecord stockOutRecord) {
        String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
        if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
            throw new BaseException("油品出库必须选择出库批次" + suffix);
        }
        if (stockOutRecord.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecord.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecord.getVehicleId(), stockOutRecord.getProductModelId(), stockOutRecord.getId());
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal deduct = quantity.subtract(lastResidual);
        if (deduct.compareTo(BigDecimal.ZERO) > 0) {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
            stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
            stockInventoryDto.setQualitity(deduct);
            if (stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto) == 0) {
                throw new BaseException("库存不足无法出库" + suffix);
            }
        }
        // 车上剩油刚好够(deduct=0)时不涉及库存更新,同样落库便于删除时按实际扣减量归还
        stockOutRecord.setDeductQuantity(deduct);
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchReAudit(List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            throw new BaseException("请选择至少一条数据");
        }
        for (Long id : ids) {
            StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
            if (stockOutRecord == null) {
                throw new BaseException("出库记录不存在,无法重新审核!!!");
            }
            if (!ReviewStatusEnum.REJECTED.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有驳回状态的记录才能重新审核,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            stockOutRecord.setApprovalStatus(ReviewStatusEnum.PENDING_REVIEW.getCode());
            stockOutRecordMapper.updateById(stockOutRecord);
        }
        return ids.size();
    }
 
    @Override
    public int updateResidualOil(Long id, BigDecimal residualOil) {
        StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
        if (stockOutRecord == null) {
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        if (residualOil == null || residualOil.compareTo(BigDecimal.ZERO) < 0) {
            throw new BaseException("剩余油量不能为空且不能小于0");
        }
        if (stockOutRecord.getStockOutNum() != null && residualOil.compareTo(stockOutRecord.getStockOutNum()) > 0) {
            throw new BaseException("剩余油量不能大于出库数量" + toPlain(stockOutRecord.getStockOutNum()));
        }
        StockOutRecord update = new StockOutRecord();
        update.setId(id);
        update.setResidualOil(residualOil);
        return stockOutRecordMapper.updateById(update);
    }
 
    private StockInventory getStockInventory(Long productModelId, String batchNo) {
        LambdaQueryWrapper<StockInventory> eq = new LambdaQueryWrapper<>();
        eq.eq(StockInventory::getProductModelId, productModelId);
        if (StringUtils.isEmpty(batchNo)) {
            eq.isNull(StockInventory::getBatchNo);
        } else {
            eq.eq(StockInventory::getBatchNo, batchNo);
        }
        return stockInventoryMapper.selectOne(eq);
    }
 
    private StockUninventory getStockUninventory(Long productModelId, String batchNo) {
        LambdaQueryWrapper<StockUninventory> eq = new LambdaQueryWrapper<>();
        eq.eq(StockUninventory::getProductModelId, productModelId);
        if (StringUtils.isEmpty(batchNo)) {
            eq.isNull(StockUninventory::getBatchNo);
        } else {
            eq.eq(StockUninventory::getBatchNo, batchNo);
        }
        return stockUninventoryMapper.selectOne(eq);
    }
 
    /**
     * 车辆回填:前端可以直接传 vehicleId,也可以只传车牌号,
     * 后者按车辆主数据反查回填。两者都拿不到就无法定位「上次剩余油量」,直接报错
     */
    private void resolveVehicle(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getVehicleId() != null) {
            return;
        }
        if (StringUtils.isEmpty(stockOutRecordDto.getTruckPlateNo())) {
            throw new BaseException("销售类出库必须选择车辆");
        }
        LambdaQueryWrapper<Vehicle> wrapper = new LambdaQueryWrapper<Vehicle>()
                .eq(Vehicle::getPlateNumber, stockOutRecordDto.getTruckPlateNo());
        Vehicle vehicle = vehicleService.getOne(wrapper, false);
        if (vehicle == null) {
            throw new BaseException("车牌号 " + stockOutRecordDto.getTruckPlateNo() + " 不在车辆管理中,请先到车辆管理添加");
        }
        stockOutRecordDto.setVehicleId(vehicle.getId());
    }
 
    /**
     * 出库数量是本次装车总量,必须落在「上次剩余油量 ~ 上次剩余油量 + 库存」区间内。
     * 代储/客存不选车辆,上次剩余油量按 0 算,退化成「不超过当前库存」
     */
    private void checkOilOutAvailable(StockOutRecordDto stockOutRecordDto) {
        String suffix = ",出库批次:" + stockOutRecordDto.getOutboundBatches();
        if (stockOutRecordDto.getProductModelId() == null) {
            throw new BaseException("油品出库必须选择产品规格" + suffix);
        }
        BigDecimal quantity = stockOutRecordDto.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("油品出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecordDto.getVehicleId(), stockOutRecordDto.getProductModelId(), null);
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal stockQuantity = getStockQuantity(stockOutRecordDto.getProductModelId(), stockOutRecordDto.getBatchNo());
        BigDecimal availableQty = lastResidual.add(stockQuantity);
        if (quantity.compareTo(availableQty) > 0) {
            throw new BaseException("出库数量超出可用量(上次剩余" + toPlain(lastResidual)
                    + ",库存" + toPlain(stockQuantity) + ",可用" + toPlain(availableQty) + ")" + suffix);
        }
    }
 
    /**
     * 该车该规格上次送完货后剩余的油量,取最近一条填过残油且已审批的记录。
     * excludeId 用于排除「本次要审批的这条自己」——它自己的 residual_oil 是送完之后的值,不能用来算本次扣减
     */
    private BigDecimal getLastResidualOil(Long vehicleId, Long productModelId, Long excludeId) {
        if (vehicleId == null || productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockOutRecord> wrapper = new LambdaQueryWrapper<StockOutRecord>()
                .eq(StockOutRecord::getVehicleId, vehicleId)
                .eq(StockOutRecord::getProductModelId, productModelId)
                .isNotNull(StockOutRecord::getResidualOil)
                .eq(StockOutRecord::getApprovalStatus, ReviewStatusEnum.APPROVED.getCode());
        if (excludeId != null) {
            wrapper.lt(StockOutRecord::getId, excludeId);
        }
        wrapper.orderByDesc(StockOutRecord::getId).last("limit 1");
        StockOutRecord last = stockOutRecordMapper.selectOne(wrapper);
        return last == null || last.getResidualOil() == null ? BigDecimal.ZERO : last.getResidualOil();
    }
 
    /**
     * 当前库存量,按规格合计;传了批次号再叠加批次条件
     */
    private BigDecimal getStockQuantity(Long productModelId, String batchNo) {
        if (productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                .eq(StockInventory::getProductModelId, productModelId);
        if (!StringUtils.isEmpty(batchNo)) {
            wrapper.eq(StockInventory::getBatchNo, batchNo);
        }
        return stockInventoryMapper.selectList(wrapper).stream()
                .map(StockInventory::getQualitity)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
 
    @Override
    public OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query) {
        if (query == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择产品规格");
        }
        // 代储/客存不选车辆,上次剩余取不到,按 0 算,可用量就等于当前库存
        BigDecimal lastResidualOil = getLastResidualOil(query.getVehicleId(), query.getProductModelId(), query.getId());
        BigDecimal stockQuantity = getStockQuantity(query.getProductModelId(), query.getBatchNo());
        OilOutAvailableDto result = new OilOutAvailableDto();
        result.setLastResidualOil(lastResidualOil);
        result.setStockQuantity(stockQuantity);
        result.setAvailableQty(lastResidualOil.add(stockQuantity));
        return result;
    }
 
    private String toPlain(BigDecimal value) {
        return value == null ? "0" : value.stripTrailingZeros().toPlainString();
    }
 
    @Override
    public List<StockOutRecord> listByRecordIds(List<Long> recordIds, String recordType) {
        if (CollectionUtils.isEmpty(recordIds) || StringUtils.isEmpty(recordType)) {
            return Collections.emptyList();
        }
        return stockOutRecordMapper.selectByRecordIds(recordIds, recordType);
    }
 
    @Override
    public IPage<SalesLedger> listBindableSalesLedger(Page page, SalesLedgerDto salesLedgerDto) {
        // 该查询用 /*data_scope*/ 跳过数据权限,而 MP 默认会把 count 语句优化成「SELECT COUNT(*) FROM sales_ledger
        // WHERE ...」,优化后标记丢失,count 反而被追加权限条件,total 会比实际记录少。关掉 count 优化后
        // count 退化成「SELECT COUNT(*) FROM (原句) TOTAL」,标记原样保留
        page.setOptimizeCountSql(false);
        return stockOutRecordMapper.bindableSalesLedgerPage(page, salesLedgerDto);
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int releaseByRecordIds(List<Long> recordIds, String recordType) {
        List<StockOutRecord> records = listByRecordIds(recordIds, recordType);
        if (records.isEmpty()) {
            return 0;
        }
        for (StockOutRecord record : records) {
            if (!ReviewStatusEnum.APPROVED.getCode().equals(record.getApprovalStatus())) {
                continue;
            }
            // type=0 归还合格库存(stock_inventory),type=1 归还不合格库存(stock_uninventory)
            if ("0".equals(record.getType())) {
                StockInventoryDto stockInventoryDto = new StockInventoryDto();
                stockInventoryDto.setProductModelId(record.getProductModelId());
                stockInventoryDto.setBatchNo(record.getBatchNo());
                stockInventoryDto.setQualitity(record.getStockOutNum());
                if (stockInventoryMapper.updateAddStockInventory(stockInventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else if ("1".equals(record.getType())) {
                StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
                stockUninventoryDto.setProductModelId(record.getProductModelId());
                stockUninventoryDto.setBatchNo(record.getBatchNo());
                stockUninventoryDto.setQualitity(record.getStockOutNum());
                if (stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else {
                throw new BaseException("出库记录类型异常,无法归还库存,出库批次:" + record.getOutboundBatches());
            }
        }
        return stockOutRecordMapper.deleteBatchIds(records.stream().map(StockOutRecord::getId).toList());
    }
}