云
5 天以前 a82d847bbb39e2417d4c9773ebc4fc51fdb0b8fc
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
package cn.iocoder.yudao.module.crm.service.quotation;
 
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
 
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
 
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 
/**
 * 销售报价有效期判定与动态作废
 *
 * 查询/操作报价单时按 validUntil 动态判断是否过期;过期后落库为「已作废」,
 * 保证列表、详情与后续操作行为一致,不依赖定时任务。
 */
@Component
@Slf4j
public class CrmSaleQuotationExpiryHelper {
 
    private static final DateTimeFormatter REASON_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private static final Set<Integer> EXPIRABLE_STATUSES = Set.of(
            CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
            CrmSaleQuotationStatusEnum.PROCESS.getStatus(),
            CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
            CrmSaleQuotationStatusEnum.REJECT.getStatus());
 
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
 
    /**
     * 是否已过有效期。
     *
     * 前端日期控件只选到日(时间部分为 00:00:00),此时按「当天结束前仍有效」处理。
     */
    public boolean isExpired(LocalDateTime validUntil) {
        if (validUntil == null) {
            return false;
        }
        if (LocalTime.MIDNIGHT.equals(validUntil.toLocalTime())) {
            return LocalDate.now().isAfter(validUntil.toLocalDate());
        }
        return LocalDateTime.now().isAfter(validUntil);
    }
 
    public void expireIfNeeded(CrmSaleQuotationDO quotation) {
        if (quotation == null) {
            return;
        }
        expireIfNeeded(List.of(quotation));
    }
 
    public void expireIfNeeded(List<CrmSaleQuotationDO> quotations) {
        if (CollUtil.isEmpty(quotations)) {
            return;
        }
        List<CrmSaleQuotationDO> candidates = quotations.stream()
                .filter(Objects::nonNull)
                .filter(q -> EXPIRABLE_STATUSES.contains(q.getStatus()))
                .filter(q -> isExpired(q.getValidUntil()))
                .collect(Collectors.toList());
        if (candidates.isEmpty()) {
            return;
        }
        Map<Long, CrmBidBondDO> bondMap = loadBondMap(candidates);
        Long userId = getLoginUserId();
        for (CrmSaleQuotationDO quotation : candidates) {
            if (isBoundBidInProgress(quotation, bondMap)) {
                continue;
            }
            doExpire(quotation, userId);
        }
    }
 
    private Map<Long, CrmBidBondDO> loadBondMap(List<CrmSaleQuotationDO> quotations) {
        Set<Long> bondIds = convertSet(quotations, CrmSaleQuotationDO::getBidBondId, Objects::nonNull);
        if (bondIds.isEmpty()) {
            return Map.of();
        }
        return bidBondMapper.selectByIds(bondIds).stream()
                .collect(Collectors.toMap(CrmBidBondDO::getId, Function.identity(), (a, b) -> a));
    }
 
    /**
     * 投标进行中(待开标/已中标)的报价单不自动作废,避免正在参与投标的报价失效。
     */
    private boolean isBoundBidInProgress(CrmSaleQuotationDO quotation, Map<Long, CrmBidBondDO> bondMap) {
        if (quotation.getBidBondId() == null) {
            return false;
        }
        CrmBidBondDO bond = bondMap.get(quotation.getBidBondId());
        if (bond == null) {
            return false;
        }
        return CrmBidResultEnum.PENDING.getResult().equals(bond.getBidResult())
                || CrmBidResultEnum.WON.getResult().equals(bond.getBidResult());
    }
 
    private void doExpire(CrmSaleQuotationDO quotation, Long userId) {
        String reason = "报价单[" + quotation.getNo() + "]有效期至["
                + quotation.getValidUntil().format(REASON_TIME_FORMATTER) + "]已过,系统自动作废";
        LocalDateTime now = LocalDateTime.now();
        boolean wasProcessing = CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(quotation.getStatus());
        // 先落库为已作废,BPM 撤销回调只会处理「审批中」状态,不会覆盖本状态
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(quotation.getId())
                .setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus())
                .setVoidType(CrmSaleQuotationVoidTypeEnum.EXPIRED.getType())
                .setVoidReason(reason)
                .setVoidTime(now));
        quotation.setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus());
        quotation.setVoidType(CrmSaleQuotationVoidTypeEnum.EXPIRED.getType());
        quotation.setVoidReason(reason);
        quotation.setVoidTime(now);
        // 审批中的报价单需要同步撤销流程,避免审批人继续处理已失效的报价
        if (wasProcessing) {
            cancelProcessQuietly(quotation.getProcessInstanceId(), reason, userId);
        }
    }
 
    private void cancelProcessQuietly(String processInstanceId, String reason, Long userId) {
        if (processInstanceId == null || processInstanceId.isBlank()) {
            return;
        }
        try {
            bpmProcessInstanceApi.cancelProcessInstanceByAdmin(userId, processInstanceId, reason);
        } catch (Exception ex) {
            log.warn("[cancelProcessQuietly][报价单流程({})撤销失败,不影响自动作废]", processInstanceId, ex);
        }
    }
 
}