gaoluyang
5 天以前 cdaec80a5ffa474f69e5c36f0312be3c0abf2210
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
package cn.iocoder.yudao.module.crm.service.bidbond;
 
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondSaleOrderRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.bidbond.vo.CrmBidBondSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidBondRefundStatusEnum;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
 
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
 
/**
 * CRM 投标保证金 Service 实现
 */
@Service
@Validated
@Slf4j
public class CrmBidBondServiceImpl implements CrmBidBondService {
 
    /**
     * ERP 销售订单已审核状态
     */
    private static final int ERP_SALE_ORDER_STATUS_APPROVE = 20;
 
    private static final DateTimeFormatter NO_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
 
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private CrmCustomerService customerService;
    @Resource
    private ErpSaleOrderApi erpSaleOrderApi;
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createBidBond(CrmBidBondSaveReqVO createReqVO) {
        CrmBidBondDO bond = validateAndBuild(createReqVO);
        bond.setId(null);
        bond.setNo(generateBondNo());
        bidBondMapper.insert(bond);
        return bond.getId();
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateBidBond(CrmBidBondSaveReqVO updateReqVO) {
        validateBidBondExists(updateReqVO.getId());
        CrmBidBondDO updateObj = validateAndBuild(updateReqVO);
        updateObj.setId(updateReqVO.getId());
        // 单号不允许修改,保持原值
        updateObj.setNo(null);
        bidBondMapper.updateByIdIncludingNulls(updateObj);
    }
 
    @Override
    public void deleteBidBond(Long id) {
        validateBidBondExists(id);
        bidBondMapper.deleteById(id);
    }
 
    @Override
    public CrmBidBondDO getBidBond(Long id) {
        return bidBondMapper.selectById(id);
    }
 
    @Override
    public CrmBidBondRespVO getBidBondDetail(Long id) {
        CrmBidBondDO bond = validateBidBondExists(id);
        return buildRespList(List.of(bond)).get(0);
    }
 
    @Override
    public PageResult<CrmBidBondRespVO> getBidBondPage(CrmBidBondPageReqVO pageReqVO) {
        PageResult<CrmBidBondDO> page = bidBondMapper.selectPage(pageReqVO);
        return new PageResult<>(buildRespList(page.getList()), page.getTotal());
    }
 
    @Override
    public List<CrmBidBondSaleOrderRespVO> getSaleOrderList(Long customerId) {
        if (customerId == null) {
            return List.of();
        }
        List<ErpSaleOrderRespDTO> orders = erpSaleOrderApi.getSaleOrderListByCustomerId(customerId)
                .getCheckedData();
        if (CollUtil.isEmpty(orders)) {
            return List.of();
        }
        return orders.stream()
                .filter(order -> Objects.equals(ERP_SALE_ORDER_STATUS_APPROVE, order.getStatus()))
                .sorted(Comparator.comparing(ErpSaleOrderRespDTO::getId).reversed())
                .map(order -> {
                    CrmBidBondSaleOrderRespVO respVO = new CrmBidBondSaleOrderRespVO();
                    respVO.setId(order.getId());
                    respVO.setNo(order.getNo());
                    respVO.setCustomerId(order.getCustomerId());
                    respVO.setStatus(order.getStatus());
                    respVO.setStatusName(getSaleOrderStatusName(order.getStatus()));
                    respVO.setTotalPrice(order.getTotalPrice());
                    respVO.setContractNo(order.getContractNo());
                    return respVO;
                })
                .collect(Collectors.toList());
    }
 
    // ========== 校验与组装 ==========
 
    private CrmBidBondDO validateAndBuild(CrmBidBondSaveReqVO reqVO) {
        // 1. 客户必须存在
        CrmCustomerDO customer = customerService.getCustomer(reqVO.getCustomerId());
        if (customer == null) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_CUSTOMER_NOT_EXISTS.getCode(),
                    "客户(ID=" + reqVO.getCustomerId() + ")不存在,请刷新客户列表后重新选择。");
        }
        // 2. 中标结果
        if (CrmBidResultEnum.valueOf(reqVO.getBidResult()) == null) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_BID_RESULT_INVALID.getCode(),
                    "中标结果值【" + reqVO.getBidResult() + "】不正确,可选 0待开标、1已中标、2未中标。请重新选择。");
        }
        Integer bidResult = reqVO.getBidResult();
        // 3. 保证金金额
        if (reqVO.getBondAmount() == null || reqVO.getBondAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_AMOUNT_INVALID.getCode(),
                    "保证金金额为【" + reqVO.getBondAmount() + "】,必须大于 0,请填写正确的缴纳金额。");
        }
        BigDecimal bondAmount = reqVO.getBondAmount();
 
        // 4. 关联销售订单(销售台账)
        String saleOrderNo = null;
        if (reqVO.getSaleOrderId() != null) {
            ErpSaleOrderRespDTO order = erpSaleOrderApi.getSaleOrder(reqVO.getSaleOrderId())
                    .getCheckedData();
            if (order == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_SALE_ORDER_NOT_EXISTS.getCode(),
                        "关联销售订单(ID=" + reqVO.getSaleOrderId() + ")不存在,请刷新销售台账后重新选择。");
            }
            if (!Objects.equals(ERP_SALE_ORDER_STATUS_APPROVE, order.getStatus())) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_SALE_ORDER_NOT_APPROVE.getCode(),
                        "关联销售订单[" + order.getNo() + "]当前状态为【" + getSaleOrderStatusName(order.getStatus())
                                + "】,只能关联「已审核」的销售订单。请先审核该订单,或改选其他已审核订单。");
            }
            if (!Objects.equals(reqVO.getCustomerId(), order.getCustomerId())) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_SALE_ORDER_CUSTOMER_MISMATCH.getCode(),
                        "关联销售订单[" + order.getNo() + "]的客户(ID=" + order.getCustomerId()
                                + ")与保证金客户(ID=" + reqVO.getCustomerId()
                                + ")不一致。请改选该客户名下的销售订单,或先调整保证金客户。");
            }
            if (CrmBidResultEnum.LOST.getResult().equals(bidResult)) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_LOST_CANNOT_LINK_ORDER.getCode(),
                        "中标结果为【未中标】时不能关联销售订单[" + order.getNo()
                                + "]。如已中标,请先将中标结果改为【已中标】;如确未中标,请清空关联销售订单。");
            }
            // 选中销售订单说明已中标,待开标时自动置为已中标
            if (CrmBidResultEnum.PENDING.getResult().equals(bidResult)) {
                bidResult = CrmBidResultEnum.WON.getResult();
            }
            saleOrderNo = order.getNo();
        }
 
        // 5. 退回状态与金额
        Integer refundStatus = resolveRefundStatus(reqVO.getRefundStatus(), bidResult);
        validateRefund(reqVO, bondAmount, refundStatus);
 
        CrmBidBondDO bond = BeanUtils.toBean(reqVO, CrmBidBondDO.class);
        bond.setId(reqVO.getId());
        bond.setBidResult(bidResult);
        bond.setRefundStatus(refundStatus);
        bond.setSaleOrderNo(saleOrderNo);
        return bond;
    }
 
    private Integer resolveRefundStatus(Integer refundStatus, Integer bidResult) {
        if (refundStatus != null) {
            if (CrmBidBondRefundStatusEnum.valueOf(refundStatus) == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_STATUS_INVALID.getCode(),
                        "退回状态值【" + refundStatus + "】不正确,可选 0未退回、1部分退回、2已退回、3无需退回。请重新选择。");
            }
            return refundStatus;
        }
        if (CrmBidResultEnum.LOST.getResult().equals(bidResult)) {
            return CrmBidBondRefundStatusEnum.NOT_REFUNDED.getStatus();
        }
        if (CrmBidResultEnum.WON.getResult().equals(bidResult)) {
            return CrmBidBondRefundStatusEnum.NO_NEED.getStatus();
        }
        return CrmBidBondRefundStatusEnum.NOT_REFUNDED.getStatus();
    }
 
    private void validateRefund(CrmBidBondSaveReqVO reqVO, BigDecimal bondAmount, Integer refundStatus) {
        BigDecimal refundAmount = reqVO.getRefundAmount();
        if (refundAmount != null && (refundAmount.compareTo(BigDecimal.ZERO) <= 0
                || refundAmount.compareTo(bondAmount) > 0)) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_AMOUNT_INVALID.getCode(),
                    "退回金额为【" + refundAmount + "】,保证金金额为【" + bondAmount
                            + "】,退回金额必须大于 0 且不能超过保证金金额,请修改后重新保存。");
        }
        if (CrmBidBondRefundStatusEnum.REFUNDED.getStatus().equals(refundStatus)) {
            if (refundAmount == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_AMOUNT_INVALID.getCode(),
                        "退回状态为【已退回】时,退回金额不能为空,请填写实际退回金额。");
            }
            if (refundAmount.compareTo(bondAmount) != 0) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_AMOUNT_FULL_INVALID.getCode(),
                        "退回状态为【已退回】时,退回金额【" + refundAmount + "】必须等于保证金金额【" + bondAmount
                                + "】。请补齐差额,或将退回状态改为【部分退回】。");
            }
            if (reqVO.getRefundDate() == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_DATE_REQUIRED.getCode(),
                        "退回状态为【已退回】时,退回日期不能为空,请填写实际退回日期。");
            }
        } else if (CrmBidBondRefundStatusEnum.PARTIAL_REFUNDED.getStatus().equals(refundStatus)) {
            if (refundAmount == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_AMOUNT_INVALID.getCode(),
                        "退回状态为【部分退回】时,退回金额不能为空,请填写本次实际退回金额。");
            }
            if (refundAmount.compareTo(bondAmount) >= 0) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_AMOUNT_PARTIAL_INVALID.getCode(),
                        "退回状态为【部分退回】时,退回金额【" + refundAmount + "】必须小于保证金金额【" + bondAmount
                                + "】。如已全额退回,请将退回状态改为【已退回】。");
            }
            if (reqVO.getRefundDate() == null) {
                throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_DATE_REQUIRED.getCode(),
                        "退回状态为【部分退回】时,退回日期不能为空,请填写本次实际退回日期。");
            }
        } else if (refundAmount != null) {
            throw new ServiceException(ErrorCodeConstants.BID_BOND_REFUND_STATUS_AMOUNT_CONFLICT.getCode(),
                    "退回状态为【" + CrmBidBondRefundStatusEnum.valueOf(refundStatus).getName()
                            + "】时,退回金额应为空。如需记录退款,请将退回状态改为【部分退回】或【已退回】。");
        }
    }
 
    private CrmBidBondDO validateBidBondExists(Long id) {
        CrmBidBondDO bond = bidBondMapper.selectById(id);
        if (bond == null) {
            throw ServiceExceptionUtil.exception(ErrorCodeConstants.BID_BOND_NOT_EXISTS);
        }
        return bond;
    }
 
    private String generateBondNo() {
        String date = LocalDate.now().format(NO_DATE_FORMAT);
        for (int attempt = 0; attempt < 20; attempt++) {
            int seq = (int) (Math.random() * 9000) + 1000;
            String no = "BZ" + date + String.format("%04d", seq);
            if (bidBondMapper.selectByNo(no) == null) {
                return no;
            }
        }
        throw new ServiceException(ErrorCodeConstants.BID_BOND_NO_EXISTS.getCode(),
                "保证金单号在多次尝试后仍重复,请稍后重试。");
    }
 
    // ========== 组装 ==========
 
    private List<CrmBidBondRespVO> buildRespList(List<CrmBidBondDO> bonds) {
        if (CollUtil.isEmpty(bonds)) {
            return List.of();
        }
        Set<Long> customerIds = bonds.stream().map(CrmBidBondDO::getCustomerId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, CrmCustomerDO> customerMap = customerIds.isEmpty() ? Map.of()
                : customerService.getCustomerMap(customerIds);
        Map<Long, ErpSaleOrderRespDTO> orderMap = loadSaleOrderMap(bonds);
 
        return bonds.stream().map(bond -> {
            CrmBidBondRespVO respVO = BeanUtils.toBean(bond, CrmBidBondRespVO.class);
            CrmCustomerDO customer = customerMap.get(bond.getCustomerId());
            if (customer != null) {
                respVO.setCustomerName(customer.getName());
            }
            ErpSaleOrderRespDTO order = orderMap.get(bond.getSaleOrderId());
            if (order != null) {
                respVO.setSaleOrderNo(order.getNo());
                respVO.setSaleOrderAmount(order.getTotalPrice());
            }
            return respVO;
        }).collect(Collectors.toList());
    }
 
    /**
     * 按客户批量加载关联销售订单,避免逐行 RPC
     */
    private Map<Long, ErpSaleOrderRespDTO> loadSaleOrderMap(List<CrmBidBondDO> bonds) {
        Set<Long> customerIds = bonds.stream()
                .filter(bond -> bond.getSaleOrderId() != null)
                .map(CrmBidBondDO::getCustomerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (customerIds.isEmpty()) {
            return Map.of();
        }
        Map<Long, ErpSaleOrderRespDTO> orderMap = new HashMap<>();
        for (Long customerId : customerIds) {
            try {
                List<ErpSaleOrderRespDTO> orders = erpSaleOrderApi.getSaleOrderListByCustomerId(customerId)
                        .getCheckedData();
                if (CollUtil.isNotEmpty(orders)) {
                    orders.forEach(order -> orderMap.put(order.getId(), order));
                }
            } catch (Exception ex) {
                log.warn("[loadSaleOrderMap] 获取客户({})的销售订单失败", customerId, ex);
            }
        }
        return orderMap;
    }
 
    private String getSaleOrderStatusName(Integer status) {
        if (status == null) {
            return "未知";
        }
        return switch (status) {
            case 0 -> "未提交";
            case 10 -> "未审核";
            case 20 -> "已审核";
            case 35 -> "已驳回";
            default -> "状态" + status;
        };
    }
 
}