gaoluyang
2 天以前 8c65f898ed6e5b0c7a94c792c4562f6ec831dccd
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
package cn.iocoder.yudao.module.crm.service.receivable;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableItemMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
 
import java.math.BigDecimal;
import java.util.*;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*;
 
/**
 * CRM 回款 Service 实现类
 *
 * @author 赤焰
 */
@Service
@Validated
@Slf4j
public class CrmReceivableServiceImpl implements CrmReceivableService {
 
    /**
     * BPM 回款审批流程标识
     *
     * 对应 act_re_procdef.KEY_ = crm-receivable-audit;BPM 分类「回款审批」的编码为 1111(bpm_category.id=76)
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-receivable-audit";
 
    @Resource
    private CrmReceivableMapper receivableMapper;
    @Resource
    private CrmReceivableItemMapper receivableItemMapper;
 
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesApi productSalesApi;
 
    @Resource
    private CrmNoRedisDAO noRedisDAO;
 
    @Resource
    private CrmContractService contractService;
    @Resource
    @Lazy // 延迟加载,避免循环依赖
    private CrmReceivablePlanService receivablePlanService;
    @Resource
    private CrmPermissionService permissionService;
    @Resource
    private CrmInvoiceService invoiceService;
 
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_CREATE_SUB_TYPE, bizNo = "{{#receivable.id}}",
            success = CRM_RECEIVABLE_CREATE_SUCCESS)
    public Long createReceivable(CrmReceivableSaveReqVO createReqVO) {
        // 1.1 校验关联数据存在
        validateRelationDataExists(createReqVO);
        // 1.2 校验关联开票合格(审批通过 + 已上传发票附件 + 与合同匹配)
        validateInvoiceForReceivable(createReqVO);
        // 1.3 校验可回款金额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(createReqVO);
        // 1.4 校验可回款金额不超过该开票剩余可回款金额(先开票后回款)
        validateReceivablePriceExceedsInvoiceRemaining(createReqVO);
        // 1.5 生成回款编号
        String no = noRedisDAO.generate(CrmNoRedisDAO.RECEIVABLE_PREFIX);
        if (receivableMapper.selectByNo(no) != null) {
            throw exception(RECEIVABLE_NO_EXISTS);
        }
 
        // 2.1 插入回款(创建后为未提交,需显式「提交审批」才进入 BPM 审批中)
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        receivableMapper.insert(receivable);
        // 2.2 保存核销明细(未传时按关联开票自动生成一条)
        syncReceivableItems(receivable, createReqVO.getItems());
 
        // 3. 创建数据权限
        permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_RECEIVABLE.getType())
                .setBizId(receivable.getId()).setUserId(createReqVO.getOwnerUserId())
                .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人
 
        // 4. 更新关联的回款计划
        if (createReqVO.getPlanId() != null) {
            receivablePlanService.updateReceivablePlanReceivableId(receivable.getPlanId(), receivable.getId());
        }
 
        // 5. 记录操作日志上下文
        LogRecordContext.putVariable("receivable", receivable);
        LogRecordContext.putVariable("period", getReceivablePeriod(receivable.getPlanId()));
        return receivable.getId();
    }
 
    /**
     * 保存核销明细
     *
     * 传 null 时:若关联开票,自动生成一条「开票」核销明细;否则不生成。
     */
    private void syncReceivableItems(CrmReceivableDO receivable,
                                     List<CrmReceivableSaveReqVO.ReceivableItem> items) {
        if (items == null) {
            if (receivable.getInvoiceId() == null) {
                return;
            }
            CrmReceivableSaveReqVO.ReceivableItem autoItem = new CrmReceivableSaveReqVO.ReceivableItem();
            autoItem.setBizType(CrmReceivableBizTypeEnum.SALE_INVOICE.getType());
            autoItem.setBizId(receivable.getInvoiceId());
            autoItem.setReceiptPrice(receivable.getPrice());
            items = List.of(autoItem);
        }
        // 1. 校验并解析每行关联单据
        List<ResolvedReceivableItem> resolvedItems = new ArrayList<>(items.size());
        BigDecimal itemPriceSum = BigDecimal.ZERO;
        for (int i = 0; i < items.size(); i++) {
            CrmReceivableSaveReqVO.ReceivableItem item = items.get(i);
            int row = i + 1;
            CrmReceivableBizTypeEnum bizType = item == null ? null
                    : CrmReceivableBizTypeEnum.valueOf(item.getBizType());
            if (bizType == null) {
                throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row,
                        item == null ? "未填写" : item.getBizType());
            }
            if (item.getReceiptPrice() == null || item.getReceiptPrice().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_INVALID, row, bizType.getName(),
                        item.getBizId(), item.getReceiptPrice() == null ? "未填写"
                                : item.getReceiptPrice().stripTrailingZeros().toPlainString());
            }
            ResolvedReceivableItem resolved = resolveReceivableItem(row, bizType, item.getBizId());
            // 1.1 已核销金额(不含本回款单,避免编辑时把自己算进去)
            BigDecimal receiptedPrice = receivableItemMapper.selectListByBiz(bizType.getType(), item.getBizId()).stream()
                    .filter(old -> ObjectUtil.notEqual(old.getReceivableId(), receivable.getId()))
                    .map(old -> old.getReceiptPrice() == null ? BigDecimal.ZERO : old.getReceiptPrice())
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal remainPrice = resolved.totalPrice.subtract(receiptedPrice);
            if (item.getReceiptPrice().compareTo(remainPrice) > 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_EXCEEDS, row, bizType.getName(), resolved.bizNo,
                        item.getReceiptPrice().stripTrailingZeros().toPlainString(),
                        remainPrice.stripTrailingZeros().toPlainString(),
                        resolved.totalPrice.stripTrailingZeros().toPlainString(),
                        receiptedPrice.stripTrailingZeros().toPlainString());
            }
            resolved.receiptedPrice = receiptedPrice;
            resolved.receiptPrice = item.getReceiptPrice();
            resolved.remark = item.getRemark();
            resolvedItems.add(resolved);
            itemPriceSum = itemPriceSum.add(item.getReceiptPrice());
        }
        // 2. 明细合计必须等于回款金额
        if (itemPriceSum.compareTo(receivable.getPrice()) != 0) {
            throw exception(RECEIVABLE_ITEM_SUM_MISMATCH,
                    itemPriceSum.stripTrailingZeros().toPlainString(),
                    receivable.getPrice().stripTrailingZeros().toPlainString());
        }
        // 3. 重建明细
        receivableItemMapper.deleteByReceivableId(receivable.getId());
        for (ResolvedReceivableItem resolved : resolvedItems) {
            receivableItemMapper.insert(CrmReceivableItemDO.builder()
                    .receivableId(receivable.getId()).bizType(resolved.bizType.getType())
                    .bizId(resolved.bizId).bizNo(resolved.bizNo).totalPrice(resolved.totalPrice)
                    .receiptedPrice(resolved.receiptedPrice).receiptPrice(resolved.receiptPrice)
                    .remark(resolved.remark).build());
        }
    }
 
    private ResolvedReceivableItem resolveReceivableItem(int row, CrmReceivableBizTypeEnum bizType, Long bizId) {
        ResolvedReceivableItem resolved = new ResolvedReceivableItem();
        resolved.bizType = bizType;
        resolved.bizId = bizId;
        switch (bizType) {
            case SALE_ORDER -> {
                ErpSaleOrderRespDTO saleOrder = bizId == null ? null : saleOrderApi.getSaleOrder(bizId).getCheckedData();
                if (saleOrder == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOrder.getNo();
                resolved.totalPrice = saleOrder.getTotalPrice() == null ? BigDecimal.ZERO : saleOrder.getTotalPrice();
            }
            case SALE_OUT -> {
                MesWmProductSalesRespDTO saleOut = bizId == null ? null : productSalesApi.getProductSales(bizId);
                if (saleOut == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOut.getCode();
                resolved.totalPrice = saleOut.getTotalPrice() == null ? BigDecimal.ZERO : saleOut.getTotalPrice();
            }
            case SALE_INVOICE -> {
                CrmInvoiceDO invoice = bizId == null ? null : invoiceService.getInvoice(bizId);
                if (invoice == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = invoice.getNo();
                resolved.totalPrice = invoice.getPrice() == null ? BigDecimal.ZERO : invoice.getPrice();
            }
            default -> throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row, bizType.getType());
        }
        return resolved;
    }
 
    private static class ResolvedReceivableItem {
        private CrmReceivableBizTypeEnum bizType;
        private Long bizId;
        private String bizNo;
        private BigDecimal totalPrice;
        private BigDecimal receiptedPrice;
        private BigDecimal receiptPrice;
        private String remark;
    }
 
    private void validateReceivablePriceExceedsLimit(CrmReceivableSaveReqVO reqVO) {
        // 1. 计算剩余可退款金额,不包括 reqVO 自身
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        // 统计口径:草稿 + 审批中 + 审批通过(与开票级校验、统计方法 selectReceivablePriceMapByContractId 保持一致)
        List<CrmReceivableDO> receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
        BigDecimal notReceivablePrice = contract.getTotalPrice().subtract(
                CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice, BigDecimal::add, BigDecimal.ZERO));
        // 2. 校验金额是否超过
        if (reqVO.getPrice().compareTo(notReceivablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notReceivablePrice);
        }
    }
 
    /**
     * 校验关联开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // 回款必须先关联已审批且已录入的开票单。
        if (reqVO.getInvoiceId() == null) {
            throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS.getCode(),
                    "回款必须先关联开票单。请先完成开票申请、审批和发票录入,再创建回款。");
        }
        // 1. 校验开票存在
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        if (invoice == null) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS);
        }
        // 2. 校验开票审批通过
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE);
        }
        if (!Objects.equals(invoice.getEntryStatus(), 10)) {
            throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE.getCode(),
                    "关联开票单尚未录入发票。请先填写发票号码、日期并上传附件,再创建回款。");
        }
        // 3. 校验开票已上传发票附件
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId()))) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ATTACHMENT);
        }
        // 4. 校验开票与所选合同一致
        if (ObjectUtil.notEqual(invoice.getContractId(), reqVO.getContractId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
        // 5. 校验开票与所选合同客户一致(防御脏数据:合同-开票-回款一对多对多,开票必须属于该合同客户)
        if (reqVO.getCustomerId() != null && ObjectUtil.notEqual(invoice.getCustomerId(), reqVO.getCustomerId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
    }
 
    /**
     * 校验累计回款金额不超过该开票剩余可回款金额(先开票后回款)
     */
    private void validateReceivablePriceExceedsInvoiceRemaining(CrmReceivableSaveReqVO reqVO) {
        // 未关联开票时直接跳过(金额上限仍由合同级校验 validateReceivablePriceExceedsLimit 兜底)
        if (reqVO.getInvoiceId() == null) {
            return;
        }
        // 1. 计算该开票累计回款金额(不含本次,统计口径同合同金额校验:草稿 + 审批中 + 审批通过)
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        List<CrmReceivableDO> receivables = receivableMapper.selectListByInvoiceIdAndStatus(reqVO.getInvoiceId(),
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
        BigDecimal receivablePriceSum = CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice,
                BigDecimal::add, BigDecimal.ZERO);
 
        // 2. 校验本次回款金额不超过该开票剩余可回款金额(开票金额 - 已回款金额)
        BigDecimal availablePrice = invoice.getPrice().subtract(receivablePriceSum);
        if (reqVO.getPrice().compareTo(availablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_PRICE_EXCEEDS, availablePrice);
        }
    }
 
    private void validateRelationDataExists(CrmReceivableSaveReqVO reqVO) {
        if (reqVO.getOwnerUserId() != null) {
            adminUserApi.validateUser(reqVO.getOwnerUserId()); // 校验负责人存在
        }
        if (reqVO.getContractId() != null) {
            CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
            if (ObjectUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
                throw exception(RECEIVABLE_CREATE_FAIL_CONTRACT_NOT_APPROVE);
            }
            reqVO.setCustomerId(contract.getCustomerId()); // 设置客户编号
        }
        if (reqVO.getPlanId() != null) {
            CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(reqVO.getPlanId());
            if (receivablePlan == null) {
                throw exception(RECEIVABLE_PLAN_NOT_EXISTS);
            }
            if (receivablePlan.getReceivableId() != null) {
                throw exception(RECEIVABLE_PLAN_EXISTS_RECEIVABLE);
            }
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}",
            success = CRM_RECEIVABLE_UPDATE_SUCCESS)
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE)
    public void updateReceivable(CrmReceivableSaveReqVO updateReqVO) {
        Assert.notNull(updateReqVO.getId(), "回款编号不能为空");
        updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // 不允许修改的字段(invoiceId 允许修改)
        // 1.1 校验存在
        CrmReceivableDO oldReceivable = validateReceivableExists(updateReqVO.getId());
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()).setCustomerId(oldReceivable.getCustomerId())
                .setContractId(oldReceivable.getContractId()).setPlanId(oldReceivable.getPlanId()); // 设置已存在的值
        // 1.2 若未传递关联开票,沿用旧值
        if (updateReqVO.getInvoiceId() == null) {
            updateReqVO.setInvoiceId(oldReceivable.getInvoiceId());
        }
        // 1.3 校验可回款金额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(updateReqVO);
        // 1.4 校验关联开票合格
        validateInvoiceForReceivable(updateReqVO);
        // 1.5 校验可回款金额不超过该开票剩余可回款金额
        validateReceivablePriceExceedsInvoiceRemaining(updateReqVO);
 
        // 1.6 仅未提交、审核不通过可以编辑;审批中等待 BPM,审核通过需先反审核
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED,
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(oldReceivable.getAuditStatus()), "未知状态"));
        }
 
        // 2. 更新回款
        CrmReceivableDO updateObj = BeanUtils.toBean(updateReqVO, CrmReceivableDO.class);
        receivableMapper.updateById(updateObj);
        // 2.1 更新核销明细:传了 items 以 items 为准;未传且关联开票时按开票重建;否则保持不变
        if (updateReqVO.getItems() != null
                || (updateReqVO.getInvoiceId() != null && ObjectUtil.notEqual(updateReqVO.getInvoiceId(), oldReceivable.getInvoiceId()))) {
            syncReceivableItems(updateObj, updateReqVO.getItems());
        }
 
        // 3. 记录操作日志上下文
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()); // 避免操作日志出现“删除负责人”的情况
        LogRecordContext.putVariable("oldReceivable", oldReceivable);
        LogRecordContext.putVariable("period", getReceivablePeriod(oldReceivable.getPlanId()));
        LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldReceivable, CrmReceivableSaveReqVO.class));
    }
 
    private Integer getReceivablePeriod(Long planId) {
        if (Objects.isNull(planId)) {
            return null;
        }
        CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(planId);
        return receivablePlan.getPeriod();
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_SUBMIT_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_RECEIVABLE_SUBMIT_SUCCESS)
    public void submitReceivable(Long id, String remark, Long userId) {
        // 1.1 校验回款存在
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 仅未提交、审核不通过可以提交审批
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT,
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(receivable.getAuditStatus()), "未知状态"));
        }
 
        // 1.3 提交前重新校验开票、合同/开票剩余可回款金额,避免草稿期间额度被其他单据占用
        CrmReceivableSaveReqVO submitReqVO = new CrmReceivableSaveReqVO()
                .setId(receivable.getId()).setCustomerId(receivable.getCustomerId())
                .setContractId(receivable.getContractId()).setInvoiceId(receivable.getInvoiceId())
                .setPrice(receivable.getPrice());
        validateInvoiceForReceivable(submitReqVO);
        validateReceivablePriceExceedsLimit(submitReqVO);
        validateReceivablePriceExceedsInvoiceRemaining(submitReqVO);
 
        // 2. 全局免审批:业务校验通过后直接执行审批通过逻辑,无需流程模型
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId("")
                    .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
            updateReceivableAuditStatus(id, BpmProcessInstanceStatusEnum.APPROVE.getStatus());
            LogRecordContext.putVariable("receivableNo", receivable.getNo());
            return;
        }
 
        // 3. 创建 BPM 回款审批流程实例
        Map<String, Object> variables = new HashMap<>();
        if (StrUtil.isNotBlank(remark)) {
            variables.put("submitRemark", remark);
        }
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY)
                        .setBusinessKey(String.valueOf(id))
                        .setVariables(variables.isEmpty() ? null : variables));
 
        // 4. 更新工作流编号 + 审批状态
        receivableMapper.updateById(new CrmReceivableDO().setId(id)
                .setProcessInstanceId(processInstanceId)
                .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
 
        // 5. 记录操作日志上下文
        LogRecordContext.putVariable("receivableNo", receivable.getNo());
        LogRecordContext.putVariable("submitRemark", remark);
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableAuditStatus(Long id, Integer bpmResult) {
        // 1.1 校验存在:回款可能在审批期间被删除,此时静默忽略,避免影响 BPM 流程完成
        CrmReceivableDO receivable = receivableMapper.selectById(id);
        if (receivable == null) {
            log.warn("[updateReceivableAuditStatus][回款({}) 不存在,忽略 BPM 审批结果({})]", id, bpmResult);
            return;
        }
        // 1.2 只有审批中才处理:避免流程事件重复投递、或人工反审核后迟到的流程事件导致重复回写
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            log.warn("[updateReceivableAuditStatus][回款({}) 当前状态({}) 不是审批中,忽略 BPM 审批结果({})]",
                    id, receivable.getAuditStatus(), bpmResult);
            return;
        }
 
        // 2. 转换 BPM 审批结果(不复用 CrmAuditStatusUtils:其 CANCEL 分支误返回 BPM 状态 4,而非 CRM 状态 40)
        Integer auditStatus;
        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.APPROVE.getStatus();
        } else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.REJECT.getStatus();
        } else if (BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.CANCEL.getStatus();
        } else {
            log.warn("[updateReceivableAuditStatus][回款({}) 的 BPM 审批结果({}) 不是终态,忽略]", id, bpmResult);
            return;
        }
 
        // 3. 条件更新,确保并发/重复事件只有一次生效
        int updateCount = receivableMapper.updateByIdAndStatus(id, CrmAuditStatusEnum.PROCESS.getStatus(),
                new CrmReceivableDO().setAuditStatus(auditStatus));
        if (updateCount == 0) {
            log.warn("[updateReceivableAuditStatus][回款({}) 状态已被其他操作变更,忽略 BPM 审批结果({})]", id, bpmResult);
            return;
        }
 
        // 4. 仅审批通过时回写回款计划;驳回、取消不回写金额
        if (ObjectUtil.equal(auditStatus, CrmAuditStatusEnum.APPROVE.getStatus())) {
            syncReceivablePlanReceipted(receivable, true);
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableStatus(Long id, Integer status) {
        boolean approve = CrmAuditStatusEnum.APPROVE.getStatus().equals(status);
        // 1.1 校验存在
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 已提交 BPM 协同审批的回款,审批结果由流程监听器驱动,禁止手工审核,避免金额双重回写
        if (StrUtil.isNotBlank(receivable.getProcessInstanceId())) {
            if (approve) {
                throw exception(RECEIVABLE_MANUAL_AUDIT_FAIL_BPM_PROCESSING, receivable.getProcessInstanceId());
            }
            // 审批中不能反审核,否则会解除流程关联,导致流程完成后无法回写;审批通过后的反审核仍保留
            if (ObjectUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
                throw exception(RECEIVABLE_MANUAL_REVERSE_FAIL_BPM_PROCESSING, receivable.getProcessInstanceId());
            }
        }
        // 1.3 校验状态
        if (receivable.getAuditStatus().equals(status)) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
 
        // 2. 更新状态;反审核时解除 BPM 关联,回归手工审核兼容流程
        CrmReceivableDO updateObj = new CrmReceivableDO().setAuditStatus(status);
        if (!approve) {
            updateObj.setProcessInstanceId("");
        }
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(), updateObj);
        if (updateCount == 0) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
 
        // 3. 回款计划回写实际回款金额与状态
        syncReceivablePlanReceipted(receivable, approve);
    }
 
    /**
     * 同步回款计划的已回款金额与状态
     *
     * @param receivable 回款单
     * @param approve    是否审批通过:true 回写已回款;false 重置已回款
     */
    private void syncReceivablePlanReceipted(CrmReceivableDO receivable, boolean approve) {
        if (receivable.getPlanId() == null) {
            return;
        }
        if (approve) {
            receivablePlanService.updateReceivablePlanReceipted(receivable.getPlanId(),
                    receivable.getPrice(), receivable.getReturnTime());
        } else {
            receivablePlanService.resetReceivablePlanReceipted(receivable.getPlanId());
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_DELETE_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_RECEIVABLE_DELETE_SUCCESS)
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER)
    public void deleteReceivable(Long id) {
        // 1.1 校验存在
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 如果被 CrmReceivablePlanDO 所使用,则不允许删除
        if (receivable.getPlanId() != null && receivablePlanService.getReceivablePlan(receivable.getPlanId()) != null) {
            throw exception(RECEIVABLE_DELETE_FAIL);
        }
        // 1.3 审批通过时,不允许删除
        if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(RECEIVABLE_DELETE_FAIL_IS_APPROVE);
        }
 
        // 2.1 删除回款
        receivableMapper.deleteById(id);
        // 2.2 删除核销明细
        receivableItemMapper.deleteByReceivableId(id);
        // 2.3 删除数据权限
        permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE.getType(), id);
 
        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("receivable", receivable);
        LogRecordContext.putVariable("period", getReceivablePeriod(receivable.getPlanId()));
    }
 
    private CrmReceivableDO validateReceivableExists(Long id) {
        CrmReceivableDO receivable = receivableMapper.selectById(id);
        if (receivable == null) {
            throw exception(RECEIVABLE_NOT_EXISTS);
        }
        return receivable;
    }
 
    @Override
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.READ)
    public CrmReceivableDO getReceivable(Long id) {
        return receivableMapper.selectById(id);
    }
 
    @Override
    public List<CrmReceivableDO> getReceivableList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return ListUtil.empty();
        }
        return receivableMapper.selectByIds(ids);
    }
 
    @Override
    public PageResult<CrmReceivableDO> getReceivablePage(CrmReceivablePageReqVO pageReqVO, Long userId) {
        return receivableMapper.selectPage(pageReqVO, userId);
    }
 
    @Override
    public List<CrmReceivableItemDO> getReceivableItemList(Long receivableId) {
        return receivableItemMapper.selectListByReceivableId(receivableId);
    }
 
    @Override
    public List<CrmReceivableItemDO> getReceivableItemListByCustomerId(Long customerId) {
        List<CrmReceivableDO> receivables = receivableMapper.selectListByCustomerId(customerId);
        if (CollUtil.isEmpty(receivables)) {
            return ListUtil.empty();
        }
        return receivableItemMapper.selectListByReceivableIds(
                receivables.stream().map(CrmReceivableDO::getId).toList());
    }
 
    @Override
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ)
    public PageResult<CrmReceivableDO> getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO) {
        return receivableMapper.selectPageByCustomerId(pageReqVO);
    }
 
    @Override
    public Long getAuditReceivableCount(Long userId) {
        return receivableMapper.selectCountByAudit(userId);
    }
 
    @Override
    public Map<Long, BigDecimal> getReceivablePriceMapByContractId(Collection<Long> contractIds) {
        return receivableMapper.selectReceivablePriceMapByContractId(contractIds);
    }
 
    @Override
    public Map<Long, BigDecimal> getReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds) {
        return receivableMapper.selectReceivablePriceMapByInvoiceId(invoiceIds);
    }
 
    @Override
    public Long getReceivableCountByContractId(Long contractId) {
        return receivableMapper.selectCountByContractId(contractId);
    }
 
    @Override
    public BigDecimal getReceivablePriceByContractId(Long contractId) {
        Map<Long, BigDecimal> priceMap = receivableMapper.selectReceivablePriceMapByContractId(
                java.util.Collections.singleton(contractId));
        return priceMap.getOrDefault(contractId, BigDecimal.ZERO);
    }
 
    @Override
    public BigDecimal getReceivablePriceSum() {
        return receivableMapper.selectReceivablePriceSum();
    }
 
    @Override
    public BigDecimal getReceivablePriceByOwnerUserId(Long ownerUserId) {
        return receivableMapper.selectReceivablePriceSumByOwnerUserId(ownerUserId);
    }
 
}