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);
|
}
|
}
|
|
}
|