package com.tourguide.modules.order.service;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.tourguide.common.exception.BusinessException;
|
import com.tourguide.modules.operation.entity.OperationScenicSpot;
|
import com.tourguide.modules.operation.entity.ScenicMeetingPoint;
|
import com.tourguide.modules.operation.entity.ScenicPricing;
|
import com.tourguide.modules.operation.mapper.OperationScenicSpotMapper;
|
import com.tourguide.modules.operation.mapper.ScenicMeetingPointMapper;
|
import com.tourguide.modules.operation.mapper.ScenicPricingMapper;
|
import com.tourguide.modules.operation.service.PlatformSettingsService;
|
import com.tourguide.modules.order.domain.OrderStatus;
|
import com.tourguide.modules.order.domain.PaymentMethod;
|
import com.tourguide.modules.order.domain.RefundStatus;
|
import com.tourguide.modules.order.dto.MiniOrderCreateRequest;
|
import com.tourguide.modules.order.dto.TourOrderResponse;
|
import com.tourguide.modules.order.entity.TourOrder;
|
import com.tourguide.modules.order.mapper.TourOrderMapper;
|
import com.tourguide.modules.system.service.GuideNotificationService;
|
import com.tourguide.modules.user.entity.MiniUser;
|
import com.tourguide.modules.user.entity.GuideUser;
|
import com.tourguide.modules.user.mapper.GuideUserMapper;
|
import com.tourguide.modules.user.service.MiniUserService;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
import java.math.BigDecimal;
|
import java.time.LocalDateTime;
|
import java.time.format.DateTimeFormatter;
|
import java.util.UUID;
|
|
/** 小程序讲解订单创建及状态流转服务。 */
|
@Service
|
public class MiniTourOrderCommandService {
|
private static final int DEFAULT_UNPAID_CANCEL_MINUTES = 15;
|
private final TourOrderMapper orderMapper;
|
private final MiniUserService miniUserService;
|
private final OperationScenicSpotMapper scenicSpotMapper;
|
private final ScenicMeetingPointMapper meetingPointMapper;
|
private final ScenicPricingMapper pricingMapper;
|
private final GuideUserMapper guideUserMapper;
|
private final PlatformSettingsService platformSettingsService;
|
private final GuideNotificationService notificationService;
|
private final OrderRefundService orderRefundService;
|
private final GuideOrderMatchingService matchingService;
|
private final boolean skipPayment;
|
|
public MiniTourOrderCommandService(TourOrderMapper orderMapper,
|
MiniUserService miniUserService,
|
OperationScenicSpotMapper scenicSpotMapper,
|
ScenicMeetingPointMapper meetingPointMapper,
|
ScenicPricingMapper pricingMapper,
|
GuideUserMapper guideUserMapper,
|
PlatformSettingsService platformSettingsService,
|
GuideNotificationService notificationService,
|
OrderRefundService orderRefundService,
|
GuideOrderMatchingService matchingService,
|
@Value("${tourguide.order.skip-payment:false}") boolean skipPayment) {
|
this.orderMapper = orderMapper;
|
this.miniUserService = miniUserService;
|
this.scenicSpotMapper = scenicSpotMapper;
|
this.meetingPointMapper = meetingPointMapper;
|
this.pricingMapper = pricingMapper;
|
this.guideUserMapper = guideUserMapper;
|
this.platformSettingsService = platformSettingsService;
|
this.notificationService = notificationService;
|
this.orderRefundService = orderRefundService;
|
this.matchingService = matchingService;
|
this.skipPayment = skipPayment;
|
}
|
|
@Transactional
|
public TourOrderResponse create(Long visitorId, MiniOrderCreateRequest request) {
|
MiniUser visitor = miniUserService.findEnabledById(visitorId);
|
ensureNoUnfinishedOrder(visitor.getId());
|
OperationScenicSpot scenicSpot = requireScenicSpot(request.getScenicSpotId());
|
ScenicMeetingPoint meetingPoint = requireMeetingPoint(request.getMeetingPointId(), scenicSpot.getId());
|
ScenicPricing pricing = requirePricing(request.getServicePackageId(), scenicSpot.getId());
|
validatePeopleCount(pricing, request.getPeopleCount());
|
|
boolean groupService = pricing.getServiceType().startsWith("GROUP_");
|
BigDecimal originalUnitPrice = valueOrZero(pricing.getOriginalPrice());
|
BigDecimal discountUnitPrice = valueOrZero(pricing.getDiscountPrice());
|
BigDecimal multiplier = groupService ? BigDecimal.valueOf(request.getPeopleCount()) : BigDecimal.ONE;
|
BigDecimal originalAmount = originalUnitPrice.multiply(multiplier);
|
BigDecimal subtotalAmount = discountUnitPrice.multiply(multiplier);
|
BigDecimal discountAmount = originalAmount.subtract(subtotalAmount).max(BigDecimal.ZERO);
|
LocalDateTime now = LocalDateTime.now();
|
|
TourOrder order = new TourOrder();
|
order.setOrderNo(generateOrderNo());
|
order.setVisitorId(visitor.getId());
|
order.setVisitorNickname(visitor.getNickname());
|
order.setVisitorMobile(visitor.getMobile());
|
order.setScenicSpotId(scenicSpot.getId());
|
order.setScenicName(scenicSpot.getScenicName());
|
order.setMeetingPointId(meetingPoint.getId());
|
order.setMeetingPointName(meetingPoint.getPointName());
|
order.setPackageName(packageName(pricing.getServiceType()));
|
order.setServicePackageId(pricing.getId());
|
order.setOrderType(pricing.getServiceType());
|
order.setPeopleCount(request.getPeopleCount());
|
order.setUnitPrice(discountUnitPrice);
|
order.setOriginalPrice(originalAmount);
|
order.setDiscountPrice(discountUnitPrice);
|
order.setSubtotalAmount(subtotalAmount);
|
order.setDiscountType(discountAmount.signum() > 0 ? "SCENIC_PRICE" : "NONE");
|
order.setDiscountAmount(discountAmount);
|
order.setAmount(subtotalAmount);
|
order.setPlacedAt(now);
|
order.setMeetingPointCount(1);
|
Integer settlementDays = platformSettingsService.getSettings().getSettlementDays();
|
order.setSettlementDays(settlementDays == null ? 1 : Math.max(0, settlementDays));
|
if (skipPayment) {
|
order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode());
|
order.setPaymentOrderNo("TEMP" + order.getOrderNo());
|
order.setPaymentAmount(subtotalAmount);
|
order.setPaymentTime(now);
|
order.setStatus(OrderStatus.PENDING_ACCEPT.getCode());
|
} else {
|
order.setStatus(OrderStatus.PENDING_PAYMENT.getCode());
|
}
|
order.setRefundStatus(RefundStatus.NONE.getCode());
|
orderMapper.insert(order);
|
if (skipPayment) {
|
matchingService.match(order.getId());
|
}
|
return TourOrderResponse.from(order);
|
}
|
|
public TourOrderResponse active(Long visitorId) {
|
miniUserService.findEnabledById(visitorId);
|
TourOrder order = orderMapper.selectOne(
|
new LambdaQueryWrapper<TourOrder>()
|
.eq(TourOrder::getVisitorId, visitorId)
|
.in(TourOrder::getStatus, OrderStatus.unfinishedStatusCodes())
|
.orderByDesc(TourOrder::getId)
|
.last("LIMIT 1"));
|
return order == null ? null : TourOrderResponse.from(order);
|
}
|
|
public TourOrderResponse detail(Long visitorId, Long orderId) {
|
TourOrder order = requireVisitorOrder(visitorId, orderId);
|
OperationScenicSpot scenicSpot = scenicSpotMapper.selectOne(
|
new LambdaQueryWrapper<OperationScenicSpot>()
|
.eq(OperationScenicSpot::getScenicName, order.getScenicName())
|
.orderByDesc(OperationScenicSpot::getId)
|
.last("LIMIT 1"));
|
ScenicMeetingPoint meetingPoint = scenicSpot == null ? null : meetingPointMapper.selectOne(
|
new LambdaQueryWrapper<ScenicMeetingPoint>()
|
.eq(ScenicMeetingPoint::getScenicSpotId, scenicSpot.getId())
|
.eq(ScenicMeetingPoint::getPointName, order.getMeetingPointName())
|
.orderByDesc(ScenicMeetingPoint::getId)
|
.last("LIMIT 1"));
|
ScenicPricing pricing = scenicSpot == null ? null : pricingMapper.selectOne(
|
new LambdaQueryWrapper<ScenicPricing>()
|
.eq(ScenicPricing::getScenicSpotId, scenicSpot.getId())
|
.eq(ScenicPricing::getServiceType, order.getOrderType())
|
.orderByDesc(ScenicPricing::getId)
|
.last("LIMIT 1"));
|
GuideUser guide = order.getGuideId() == null ? null : guideUserMapper.selectById(order.getGuideId());
|
int unpaidCancelMinutes = scenicSpot == null || scenicSpot.getUnpaidCancelMinutes() == null
|
|| scenicSpot.getUnpaidCancelMinutes() < 1
|
? DEFAULT_UNPAID_CANCEL_MINUTES : scenicSpot.getUnpaidCancelMinutes();
|
return TourOrderResponse.from(order)
|
.enrich(scenicSpot, meetingPoint, pricing, guide)
|
.withUnpaidCancelMinutes(unpaidCancelMinutes)
|
.withUnacceptedAutoRefundMinutes(
|
platformSettingsService.getSettings().getUnacceptedAutoRefundMinutes());
|
}
|
|
@Transactional
|
public TourOrderResponse cancel(Long visitorId, Long orderId) {
|
TourOrder order = orderMapper.selectForUpdate(orderId);
|
if (order == null || visitorId == null || !visitorId.equals(order.getVisitorId())) {
|
throw new BusinessException(404, "订单不存在");
|
}
|
if (OrderStatus.CANCELLED.matches(order.getStatus())) {
|
return TourOrderResponse.from(order);
|
}
|
boolean notifyGuide = OrderStatus.PENDING_MEETING.matches(order.getStatus()) && order.getGuideId() != null;
|
if (!OrderStatus.PENDING_PAYMENT.matches(order.getStatus())
|
&& !OrderStatus.PENDING_ACCEPT.matches(order.getStatus())
|
&& !OrderStatus.PENDING_MEETING.matches(order.getStatus())) {
|
throw new BusinessException(409, "当前订单状态不支持取消");
|
}
|
order.setStatus(OrderStatus.CANCELLED.getCode());
|
order.setCancelledAt(LocalDateTime.now());
|
if (order.getPaymentTime() == null) {
|
orderMapper.updateById(order);
|
} else {
|
orderRefundService.requestRefund(order);
|
}
|
if (notifyGuide) notificationService.notifyVisitorCancelledOrder(order);
|
return TourOrderResponse.from(order);
|
}
|
|
private OperationScenicSpot requireScenicSpot(Long id) {
|
OperationScenicSpot scenicSpot = scenicSpotMapper.selectOne(
|
new LambdaQueryWrapper<OperationScenicSpot>()
|
.eq(OperationScenicSpot::getId, id)
|
.eq(OperationScenicSpot::getStatus, 1));
|
if (scenicSpot == null) throw new BusinessException(404, "景区不存在或未启用");
|
return scenicSpot;
|
}
|
|
private void ensureNoUnfinishedOrder(Long visitorId) {
|
Long count = orderMapper.selectCount(
|
new LambdaQueryWrapper<TourOrder>()
|
.eq(TourOrder::getVisitorId, visitorId)
|
.in(TourOrder::getStatus, OrderStatus.unfinishedStatusCodes()));
|
if (count != null && count > 0) {
|
throw new BusinessException(409, "您有一个未完成的订单,请先完成或取消后再下单");
|
}
|
}
|
|
private ScenicMeetingPoint requireMeetingPoint(Long id, Long scenicSpotId) {
|
ScenicMeetingPoint point = meetingPointMapper.selectOne(
|
new LambdaQueryWrapper<ScenicMeetingPoint>()
|
.eq(ScenicMeetingPoint::getId, id)
|
.eq(ScenicMeetingPoint::getScenicSpotId, scenicSpotId)
|
.eq(ScenicMeetingPoint::getStatus, 1));
|
if (point == null) throw new BusinessException(400, "汇合点不属于当前景区或已停用");
|
return point;
|
}
|
|
private ScenicPricing requirePricing(Long id, Long scenicSpotId) {
|
ScenicPricing pricing = pricingMapper.selectOne(
|
new LambdaQueryWrapper<ScenicPricing>()
|
.eq(ScenicPricing::getId, id)
|
.eq(ScenicPricing::getScenicSpotId, scenicSpotId));
|
if (pricing == null) throw new BusinessException(400, "讲解套餐不属于当前景区");
|
if (pricing.getDiscountPrice() == null || pricing.getDiscountPrice().signum() <= 0) {
|
throw new BusinessException(409, "讲解套餐价格未配置");
|
}
|
return pricing;
|
}
|
|
private TourOrder requireVisitorOrder(Long visitorId, Long orderId) {
|
miniUserService.findEnabledById(visitorId);
|
TourOrder order = orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>()
|
.eq(TourOrder::getId, orderId)
|
.eq(TourOrder::getVisitorId, visitorId));
|
if (order == null) throw new BusinessException(404, "订单不存在");
|
return order;
|
}
|
|
private void validatePeopleCount(ScenicPricing pricing, Integer peopleCount) {
|
if (pricing.getServiceType().startsWith("GROUP_")
|
&& pricing.getMaxParticipants() != null
|
&& peopleCount > pricing.getMaxParticipants()) {
|
throw new BusinessException(400, "讲解人数不能超过套餐人数上限");
|
}
|
}
|
|
private BigDecimal valueOrZero(BigDecimal value) {
|
return value == null ? BigDecimal.ZERO : value;
|
}
|
|
private String packageName(String serviceType) {
|
if ("GROUP_QUICK".equals(serviceType)) return "拼团 · 快讲";
|
if ("GROUP_PREMIUM".equals(serviceType)) return "拼团 · 精讲";
|
if ("PRIVATE_QUICK".equals(serviceType)) return "包团 · 快讲";
|
if ("PRIVATE_PREMIUM".equals(serviceType)) return "包团 · 精讲";
|
throw new BusinessException(400, "不支持的讲解套餐类型");
|
}
|
|
private String generateOrderNo() {
|
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
|
String random = UUID.randomUUID().toString().replace("-", "").substring(0, 5).toUpperCase();
|
return "MJ" + time + random;
|
}
|
}
|