| | |
| | | @PathVariable Long id) { |
| | | return ApiResponse.success(orderService.detail(principal.getUserId(), id)); |
| | | } |
| | | |
| | | @PostMapping("/{id}/start") |
| | | public ApiResponse<GuideOrderResponse> start(@AuthenticationPrincipal AppUserPrincipal principal, |
| | | @PathVariable Long id) { |
| | | return ApiResponse.success(orderService.start(principal.getUserId(), id)); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tourguide.modules.order.domain; |
| | | |
| | | /** 订单支付方式。 */ |
| | | public enum PaymentMethod { |
| | | WECHAT, |
| | | TEMPORARY_BYPASS; |
| | | |
| | | public String getCode() { |
| | | return name(); |
| | | } |
| | | |
| | | public boolean matches(String code) { |
| | | return name().equals(code); |
| | | } |
| | | } |
| | |
| | | import com.tourguide.modules.order.dto.GuideOrderResponse; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | | import com.tourguide.modules.order.mapper.TourOrderMapper; |
| | | import com.tourguide.modules.user.entity.GuideReceivingSession; |
| | | import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper; |
| | | import com.tourguide.modules.user.mapper.GuideReceivingSessionMapper; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import org.springframework.util.StringUtils; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Arrays; |
| | | import java.util.stream.Collectors; |
| | | |
| | |
| | | private static final String TAB_COMPLETED = "completed"; |
| | | |
| | | private final TourOrderMapper orderMapper; |
| | | private final GuideReceivingSessionMapper sessionMapper; |
| | | private final GuideRealtimeStatusMapper realtimeStatusMapper; |
| | | |
| | | public GuideTourOrderService(TourOrderMapper orderMapper) { |
| | | public GuideTourOrderService(TourOrderMapper orderMapper, GuideReceivingSessionMapper sessionMapper, |
| | | GuideRealtimeStatusMapper realtimeStatusMapper) { |
| | | this.orderMapper = orderMapper; |
| | | this.sessionMapper = sessionMapper; |
| | | this.realtimeStatusMapper = realtimeStatusMapper; |
| | | } |
| | | |
| | | public PageResponse<GuideOrderResponse> page(Long guideId, String tab, long page, long size) { |
| | |
| | | } |
| | | |
| | | public GuideOrderResponse detail(Long guideId, Long orderId) { |
| | | TourOrder order = orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>() |
| | | .eq(TourOrder::getId, orderId) |
| | | .eq(TourOrder::getGuideId, guideId) |
| | | .last("LIMIT 1")); |
| | | TourOrder order = findOwnedOrder(guideId, orderId); |
| | | if (order == null) { |
| | | throw new BusinessException(404, "订单不存在"); |
| | | } |
| | | return GuideOrderResponse.from(order); |
| | | } |
| | | |
| | | @Transactional |
| | | public GuideOrderResponse start(Long guideId, Long orderId) { |
| | | TourOrder order = findOwnedOrder(guideId, orderId); |
| | | if (order == null) { |
| | | throw new BusinessException(404, "订单不存在"); |
| | | } |
| | | if (OrderStatus.IN_SERVICE.matches(order.getStatus())) { |
| | | return GuideOrderResponse.from(order); |
| | | } |
| | | if (!OrderStatus.PENDING_MEETING.matches(order.getStatus()) || order.getReceivingSessionId() == null) { |
| | | throw new BusinessException(400, "当前订单不能开始讲解"); |
| | | } |
| | | |
| | | GuideReceivingSession session = sessionMapper.selectForUpdate(order.getReceivingSessionId(), guideId); |
| | | if (session == null) { |
| | | throw new BusinessException(400, "接单会话不存在或已结束"); |
| | | } |
| | | if ("IN_SERVICE".equals(session.getStatus())) { |
| | | order.setStatus(OrderStatus.IN_SERVICE.getCode()); |
| | | return GuideOrderResponse.from(order); |
| | | } |
| | | if (!"RECEIVING".equals(session.getStatus()) && !"ASSIGNED".equals(session.getStatus())) { |
| | | throw new BusinessException(400, "接单会话已结束,不能开始讲解"); |
| | | } |
| | | |
| | | LocalDateTime startedAt = LocalDateTime.now(); |
| | | if (sessionMapper.startSession(session.getId(), guideId) != 1 |
| | | || orderMapper.startSessionOrders(session.getId(), startedAt) == 0) { |
| | | throw new BusinessException(409, "讲解状态已变化,请刷新后重试"); |
| | | } |
| | | realtimeStatusMapper.disableReceiving(guideId); |
| | | order.setStatus(OrderStatus.IN_SERVICE.getCode()); |
| | | order.setServiceStartedAt(startedAt); |
| | | return GuideOrderResponse.from(order); |
| | | } |
| | | |
| | | private TourOrder findOwnedOrder(Long guideId, Long orderId) { |
| | | return orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>() |
| | | .eq(TourOrder::getId, orderId) |
| | | .eq(TourOrder::getGuideId, guideId) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | | private String normalizeTab(String tab) { |
| | | return StringUtils.hasText(tab) ? tab.trim() : TAB_ALL; |
| | | } |
| | |
| | | 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.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; |
| | | |
| | |
| | | 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, |
| | |
| | | GuideUserMapper guideUserMapper, |
| | | PlatformSettingsService platformSettingsService, |
| | | GuideNotificationService notificationService, |
| | | OrderRefundService orderRefundService) { |
| | | OrderRefundService orderRefundService, |
| | | GuideOrderMatchingService matchingService, |
| | | @Value("${tourguide.order.skip-payment:false}") boolean skipPayment) { |
| | | this.orderMapper = orderMapper; |
| | | this.miniUserService = miniUserService; |
| | | this.scenicSpotMapper = scenicSpotMapper; |
| | |
| | | this.platformSettingsService = platformSettingsService; |
| | | this.notificationService = notificationService; |
| | | this.orderRefundService = orderRefundService; |
| | | this.matchingService = matchingService; |
| | | this.skipPayment = skipPayment; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | order.setMeetingPointCount(1); |
| | | Integer settlementDays = platformSettingsService.getSettings().getSettlementDays(); |
| | | order.setSettlementDays(settlementDays == null ? 1 : Math.max(0, settlementDays)); |
| | | order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); |
| | | 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); |
| | | } |
| | | |
| | |
| | | package com.tourguide.modules.order.service; |
| | | |
| | | import com.tourguide.common.huifu.RequestSeqUtil; |
| | | import com.tourguide.modules.order.domain.PaymentMethod; |
| | | import com.tourguide.modules.order.domain.RefundStatus; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | | import com.tourguide.modules.order.mapper.TourOrderMapper; |
| | |
| | | import org.springframework.util.StringUtils; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | |
| | | @Service |
| | | public class OrderRefundService { |
| | |
| | | order.setRefundStatus(RefundStatus.REFUNDING.getCode()); |
| | | order.setRefundAmount(refundAmount); |
| | | order.setRefundMethod(order.getPaymentMethod()); |
| | | if (PaymentMethod.TEMPORARY_BYPASS.matches(order.getPaymentMethod())) { |
| | | order.setRefundStatus(RefundStatus.REFUNDED.getCode()); |
| | | order.setRefundTime(LocalDateTime.now()); |
| | | orderMapper.updateById(order); |
| | | return; |
| | | } |
| | | orderMapper.updateById(order); |
| | | try { |
| | | paymentRefundGateway.refund(order, refundAmount); |
| | |
| | | import com.tourguide.common.exception.BusinessException; |
| | | import com.tourguide.common.huifu.RequestSeqUtil; |
| | | import com.tourguide.modules.order.domain.OrderStatus; |
| | | import com.tourguide.modules.order.domain.PaymentMethod; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | | import com.tourguide.modules.order.entity.TourOrderPaymentAttempt; |
| | | import com.tourguide.modules.order.mapper.TourOrderMapper; |
| | |
| | | attempt.setStatus(ATTEMPT_CREATED); |
| | | paymentAttemptMapper.insert(attempt); |
| | | |
| | | order.setPaymentMethod("WECHAT"); |
| | | order.setPaymentMethod(PaymentMethod.WECHAT.getCode()); |
| | | order.setPaymentOrderNo(reqSeqId); |
| | | orderMapper.updateById(order); |
| | | |
| | |
| | | if (!OrderStatus.PENDING_PAYMENT.matches(order.getStatus())) { |
| | | throw new BusinessException(409, "订单状态不允许确认支付"); |
| | | } |
| | | order.setPaymentMethod("WECHAT"); |
| | | order.setPaymentMethod(PaymentMethod.WECHAT.getCode()); |
| | | order.setPaymentOrderNo(notice.getReqSeqId()); |
| | | order.setPaymentAmount(notice.getAmount()); |
| | | order.setPaymentTime(paidAt); |
| | |
| | | import java.util.List; |
| | | |
| | | public interface GuideReceivingSessionMapper extends BaseMapper<GuideReceivingSession> { |
| | | @Select("SELECT * FROM guide_receiving_session WHERE id = #{id} AND guide_id = #{guideId} " + |
| | | "AND deleted = 0 LIMIT 1 FOR UPDATE") |
| | | GuideReceivingSession selectForUpdate(@Param("id") Long id, @Param("guideId") Long guideId); |
| | | |
| | | @Select("SELECT s.* FROM guide_receiving_session s " + |
| | | "INNER JOIN guide_realtime_status r ON r.guide_id = s.guide_id AND r.deleted = 0 " + |
| | | "INNER JOIN guide_user g ON g.id = s.guide_id AND g.deleted = 0 AND g.status = 1 " + |
| | |
| | | "WHERE id = #{id} AND deleted = 0 AND status = 'RECEIVING' AND service_start_at <= NOW()") |
| | | int startDueSession(Long id); |
| | | |
| | | @Update("UPDATE guide_receiving_session SET status = 'IN_SERVICE', updated_at = NOW() " + |
| | | "WHERE id = #{id} AND guide_id = #{guideId} AND deleted = 0 AND status IN ('RECEIVING', 'ASSIGNED')") |
| | | int startSession(@Param("id") Long id, @Param("guideId") Long guideId); |
| | | |
| | | @Update("UPDATE guide_receiving_session SET reminder_pushed_at = #{now}, updated_at = NOW() " + |
| | | "WHERE id = #{id} AND deleted = 0 AND status = 'RECEIVING' AND reminder_pushed_at IS NULL") |
| | | int claimServiceReminder(@Param("id") Long id, @Param("now") LocalDateTime now); |
| | |
| | | tourguide: |
| | | guide-login: |
| | | fixed-verification-code: ${GUIDE_LOGIN_FIXED_CODE:} |
| | | order: |
| | | # 临时跳过微信支付;恢复真实支付时将默认值改为 false。 |
| | | skip-payment: ${TOURGUIDE_ORDER_SKIP_PAYMENT:true} |
| | | |
| | | logging: |
| | | level: |
| New file |
| | |
| | | package com.tourguide.modules.order.service; |
| | | |
| | | import com.tourguide.common.exception.BusinessException; |
| | | import com.tourguide.modules.order.domain.OrderStatus; |
| | | import com.tourguide.modules.order.dto.GuideOrderResponse; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | | import com.tourguide.modules.order.mapper.TourOrderMapper; |
| | | import com.tourguide.modules.user.entity.GuideReceivingSession; |
| | | import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper; |
| | | import com.tourguide.modules.user.mapper.GuideReceivingSessionMapper; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | class GuideTourOrderServiceTest { |
| | | private TourOrderMapper orderMapper; |
| | | private GuideReceivingSessionMapper sessionMapper; |
| | | private GuideRealtimeStatusMapper realtimeStatusMapper; |
| | | private GuideTourOrderService service; |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | orderMapper = mock(TourOrderMapper.class); |
| | | sessionMapper = mock(GuideReceivingSessionMapper.class); |
| | | realtimeStatusMapper = mock(GuideRealtimeStatusMapper.class); |
| | | service = new GuideTourOrderService(orderMapper, sessionMapper, realtimeStatusMapper); |
| | | } |
| | | |
| | | @Test |
| | | void shouldStartAllOrdersInReceivingSession() { |
| | | TourOrder order = order(OrderStatus.PENDING_MEETING); |
| | | GuideReceivingSession session = session("ASSIGNED"); |
| | | when(orderMapper.selectOne(any())).thenReturn(order); |
| | | when(sessionMapper.selectForUpdate(20L, 8L)).thenReturn(session); |
| | | when(sessionMapper.startSession(20L, 8L)).thenReturn(1); |
| | | when(orderMapper.startSessionOrders(eq(20L), any())).thenReturn(2); |
| | | |
| | | GuideOrderResponse response = service.start(8L, 10L); |
| | | |
| | | assertThat(response.getStatus()).isEqualTo("serving"); |
| | | assertThat(response.getLectureStartTime()).isNotNull(); |
| | | verify(realtimeStatusMapper).disableReceiving(8L); |
| | | } |
| | | |
| | | @Test |
| | | void shouldTreatRepeatedStartAsSuccess() { |
| | | TourOrder order = order(OrderStatus.IN_SERVICE); |
| | | when(orderMapper.selectOne(any())).thenReturn(order); |
| | | |
| | | GuideOrderResponse response = service.start(8L, 10L); |
| | | |
| | | assertThat(response.getStatus()).isEqualTo("serving"); |
| | | verify(sessionMapper, never()).selectForUpdate(any(), any()); |
| | | } |
| | | |
| | | @Test |
| | | void shouldRejectOrderOutsidePendingMeetingState() { |
| | | TourOrder order = order(OrderStatus.COMPLETED); |
| | | when(orderMapper.selectOne(any())).thenReturn(order); |
| | | |
| | | assertThatThrownBy(() -> service.start(8L, 10L)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessage("当前订单不能开始讲解"); |
| | | } |
| | | |
| | | private TourOrder order(OrderStatus status) { |
| | | TourOrder order = new TourOrder(); |
| | | order.setId(10L); |
| | | order.setGuideId(8L); |
| | | order.setReceivingSessionId(20L); |
| | | order.setStatus(status.getCode()); |
| | | order.setPeopleCount(2); |
| | | return order; |
| | | } |
| | | |
| | | private GuideReceivingSession session(String status) { |
| | | GuideReceivingSession session = new GuideReceivingSession(); |
| | | session.setId(20L); |
| | | session.setGuideId(8L); |
| | | session.setStatus(status); |
| | | return session; |
| | | } |
| | | } |
| | |
| | | import com.tourguide.modules.operation.dto.PlatformSettingsResponse; |
| | | 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.dto.MiniOrderCreateRequest; |
| | | import com.tourguide.modules.order.dto.TourOrderResponse; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | |
| | | private PlatformSettingsService platformSettingsService; |
| | | private GuideNotificationService notificationService; |
| | | private OrderRefundService orderRefundService; |
| | | private GuideOrderMatchingService matchingService; |
| | | private MiniTourOrderCommandService commandService; |
| | | |
| | | @BeforeEach |
| | |
| | | platformSettingsService = mock(PlatformSettingsService.class); |
| | | notificationService = mock(GuideNotificationService.class); |
| | | orderRefundService = mock(OrderRefundService.class); |
| | | matchingService = mock(GuideOrderMatchingService.class); |
| | | commandService = new MiniTourOrderCommandService( |
| | | orderMapper, miniUserService, scenicSpotMapper, meetingPointMapper, pricingMapper, |
| | | guideUserMapper, platformSettingsService, notificationService, orderRefundService); |
| | | guideUserMapper, platformSettingsService, notificationService, orderRefundService, |
| | | matchingService, false); |
| | | } |
| | | |
| | | @Test |
| | | void createShouldKeepOrderPendingPaymentUntilPaymentSucceeds() { |
| | | MiniOrderCreateRequest request = prepareCreateOrder(); |
| | | |
| | | TourOrderResponse response = commandService.create(7L, request); |
| | | |
| | | ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class); |
| | | verify(orderMapper).insert(captor.capture()); |
| | | TourOrder order = captor.getValue(); |
| | | assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode()); |
| | | assertThat(order.getPaymentMethod()).isNull(); |
| | | assertThat(order.getPaymentOrderNo()).isNull(); |
| | | assertThat(order.getPaymentAmount()).isNull(); |
| | | assertThat(order.getPaymentTime()).isNull(); |
| | | assertThat(order.getSettlementDays()).isEqualTo(7); |
| | | assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode()); |
| | | verify(matchingService, never()).match(any()); |
| | | } |
| | | |
| | | @Test |
| | | void createShouldRejectVisitorWithUnfinishedOrder() { |
| | | MiniUser visitor = new MiniUser(); |
| | | visitor.setId(7L); |
| | | when(miniUserService.findEnabledById(7L)).thenReturn(visitor); |
| | | when(orderMapper.selectCount(any())).thenReturn(1L); |
| | | |
| | | assertThatThrownBy(() -> commandService.create(7L, new MiniOrderCreateRequest())) |
| | | .isInstanceOf(com.tourguide.common.exception.BusinessException.class) |
| | | .hasMessage("您有一个未完成的订单,请先完成或取消后再下单"); |
| | | |
| | | verify(orderMapper, never()).insert(any(TourOrder.class)); |
| | | } |
| | | |
| | | @Test |
| | | void createShouldMarkOrderPaidAndStartMatchingWhenPaymentIsSkipped() { |
| | | MiniOrderCreateRequest request = prepareCreateOrder(); |
| | | commandService = new MiniTourOrderCommandService( |
| | | orderMapper, miniUserService, scenicSpotMapper, meetingPointMapper, pricingMapper, |
| | | guideUserMapper, platformSettingsService, notificationService, orderRefundService, |
| | | matchingService, true); |
| | | |
| | | TourOrderResponse response = commandService.create(7L, request); |
| | | |
| | | ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class); |
| | | verify(orderMapper).insert(captor.capture()); |
| | | TourOrder order = captor.getValue(); |
| | | assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_ACCEPT.getCode()); |
| | | assertThat(order.getPaymentMethod()).isEqualTo(PaymentMethod.TEMPORARY_BYPASS.getCode()); |
| | | assertThat(order.getPaymentOrderNo()).startsWith("TEMPMJ"); |
| | | assertThat(order.getPaymentAmount()).isEqualByComparingTo("144.00"); |
| | | assertThat(order.getPaymentTime()).isNotNull(); |
| | | assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_ACCEPT.getCode()); |
| | | verify(matchingService).match(99L); |
| | | } |
| | | |
| | | @Test |
| | | void cancelPendingMeetingOrderShouldNotifyAssignedGuide() { |
| | | MiniUser visitor = new MiniUser(); |
| | | visitor.setId(7L); |
| | | when(miniUserService.findEnabledById(7L)).thenReturn(visitor); |
| | | |
| | | TourOrder order = new TourOrder(); |
| | | order.setId(99L); |
| | | order.setOrderNo("MJ202607300001"); |
| | | order.setVisitorId(7L); |
| | | order.setGuideId(8L); |
| | | order.setStatus(OrderStatus.PENDING_MEETING.getCode()); |
| | | order.setPaymentTime(LocalDateTime.now()); |
| | | order.setPaymentAmount(new BigDecimal("36.00")); |
| | | when(orderMapper.selectForUpdate(99L)).thenReturn(order); |
| | | |
| | | commandService.cancel(7L, 99L); |
| | | |
| | | assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED.getCode()); |
| | | verify(orderRefundService).requestRefund(order); |
| | | verify(notificationService).notifyVisitorCancelledOrder(order); |
| | | } |
| | | |
| | | private MiniOrderCreateRequest prepareCreateOrder() { |
| | | MiniUser visitor = new MiniUser(); |
| | | visitor.setId(7L); |
| | | visitor.setNickname("游客"); |
| | |
| | | request.setMeetingPointId(12L); |
| | | request.setServicePackageId(13L); |
| | | request.setPeopleCount(4); |
| | | |
| | | TourOrderResponse response = commandService.create(7L, request); |
| | | |
| | | ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class); |
| | | verify(orderMapper).insert(captor.capture()); |
| | | TourOrder order = captor.getValue(); |
| | | assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode()); |
| | | assertThat(order.getPaymentMethod()).isNull(); |
| | | assertThat(order.getPaymentOrderNo()).isNull(); |
| | | assertThat(order.getPaymentAmount()).isNull(); |
| | | assertThat(order.getPaymentTime()).isNull(); |
| | | assertThat(order.getSettlementDays()).isEqualTo(7); |
| | | assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode()); |
| | | } |
| | | |
| | | @Test |
| | | void createShouldRejectVisitorWithUnfinishedOrder() { |
| | | MiniUser visitor = new MiniUser(); |
| | | visitor.setId(7L); |
| | | when(miniUserService.findEnabledById(7L)).thenReturn(visitor); |
| | | when(orderMapper.selectCount(any())).thenReturn(1L); |
| | | |
| | | assertThatThrownBy(() -> commandService.create(7L, new MiniOrderCreateRequest())) |
| | | .isInstanceOf(com.tourguide.common.exception.BusinessException.class) |
| | | .hasMessage("您有一个未完成的订单,请先完成或取消后再下单"); |
| | | |
| | | verify(orderMapper, never()).insert(any(TourOrder.class)); |
| | | } |
| | | |
| | | @Test |
| | | void cancelPendingMeetingOrderShouldNotifyAssignedGuide() { |
| | | MiniUser visitor = new MiniUser(); |
| | | visitor.setId(7L); |
| | | when(miniUserService.findEnabledById(7L)).thenReturn(visitor); |
| | | |
| | | TourOrder order = new TourOrder(); |
| | | order.setId(99L); |
| | | order.setOrderNo("MJ202607300001"); |
| | | order.setVisitorId(7L); |
| | | order.setGuideId(8L); |
| | | order.setStatus(OrderStatus.PENDING_MEETING.getCode()); |
| | | order.setPaymentTime(LocalDateTime.now()); |
| | | order.setPaymentAmount(new BigDecimal("36.00")); |
| | | when(orderMapper.selectForUpdate(99L)).thenReturn(order); |
| | | |
| | | commandService.cancel(7L, 99L); |
| | | |
| | | assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED.getCode()); |
| | | verify(orderRefundService).requestRefund(order); |
| | | verify(notificationService).notifyVisitorCancelledOrder(order); |
| | | return request; |
| | | } |
| | | } |
| | |
| | | package com.tourguide.modules.order.service; |
| | | |
| | | import com.tourguide.modules.order.domain.RefundStatus; |
| | | import com.tourguide.modules.order.domain.PaymentMethod; |
| | | import com.tourguide.modules.order.entity.TourOrder; |
| | | import com.tourguide.modules.order.mapper.TourOrderMapper; |
| | | import com.tourguide.modules.order.payment.PaymentRefundGateway; |
| | |
| | | org.mockito.ArgumentMatchers.any()); |
| | | } |
| | | |
| | | @Test |
| | | void temporaryBypassRefundShouldCompleteWithoutCallingGateway() { |
| | | TourOrder order = paidOrder(); |
| | | order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode()); |
| | | order.setPaymentOrderNo("TEMPMJ202608030001"); |
| | | |
| | | refundService.requestRefund(order); |
| | | |
| | | assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUNDED.getCode()); |
| | | assertThat(order.getRefundTime()).isNotNull(); |
| | | assertThat(order.getRefundMethod()).isEqualTo(PaymentMethod.TEMPORARY_BYPASS.getCode()); |
| | | verify(refundGateway, never()).refund(org.mockito.ArgumentMatchers.any(), |
| | | org.mockito.ArgumentMatchers.any()); |
| | | verify(orderMapper).updateById(order); |
| | | } |
| | | |
| | | private TourOrder paidOrder() { |
| | | TourOrder order = new TourOrder(); |
| | | order.setId(99L); |
| | |
| | | </view> |
| | | </view> |
| | | <button v-if="isServing" class="lecture-button" @tap="finishLecture">结束讲解</button> |
| | | <button v-else class="lecture-button" @tap="startLecture">开始讲解</button> |
| | | <button |
| | | v-else |
| | | class="lecture-button" |
| | | :loading="startingLecture" |
| | | :disabled="startingLecture" |
| | | @tap="startLecture" |
| | | >开始讲解</button> |
| | | </template> |
| | | <template v-else> |
| | | <view class="empty-order">暂无订单请耐心等待</view> |
| | |
| | | updateGuideOnlineStatus |
| | | } from '../../services/guide' |
| | | import { openMeetingPointPage } from '../../services/map' |
| | | import { getGuideOrders } from '../../services/order' |
| | | import { getGuideOrders, startGuideLecture } from '../../services/order' |
| | | import { playNotificationAlert } from '../../services/message' |
| | | import { getWorkbenchData } from '../../services/workbench' |
| | | import { formatDuration, maskPhone } from '../../utils/format' |
| | |
| | | countdownTimer: null, |
| | | refreshTimer: null, |
| | | refreshing: false, |
| | | startingLecture: false, |
| | | dialog: { |
| | | visible: false, |
| | | title: '提示', |
| | |
| | | }) |
| | | }, |
| | | startLecture() { |
| | | updateGuideOnlineStatus('serving', this.guide.receivingConfig || {}).then(() => { |
| | | this.loadData() |
| | | const orderId = this.activeReceivingOrder.id |
| | | if (this.startingLecture) { |
| | | return |
| | | } |
| | | if (!orderId) { |
| | | uni.showToast({ title: '当前没有可开始的订单', icon: 'none' }) |
| | | return |
| | | } |
| | | this.startingLecture = true |
| | | startGuideLecture(orderId).then((startedOrder) => { |
| | | this.updateGuide({ ...this.guide, onlineStatus: 'serving' }) |
| | | this.updateOrders(this.orders.map((order) => ( |
| | | order.id === startedOrder.id ? { ...order, ...startedOrder } : order |
| | | ))) |
| | | this.setupCountdown() |
| | | uni.showToast({ title: '已开始讲解', icon: 'none' }) |
| | | return this.loadData({ silent: true }) |
| | | }).catch((error) => { |
| | | uni.showToast({ title: error.message || '开始讲解失败', icon: 'none' }) |
| | | }).finally(() => { |
| | | this.startingLecture = false |
| | | }) |
| | | }, |
| | | finishLecture() { |
| | |
| | | return request({ url: `/guide/orders/${orderId}` }).then(normalizeGuideOrder) |
| | | } |
| | | |
| | | export function startGuideLecture(orderId) { |
| | | return request({ |
| | | url: `/guide/orders/${orderId}/start`, |
| | | method: 'POST' |
| | | }).then(normalizeGuideOrder) |
| | | } |
| | | |
| | | function normalizeOrderTab(filter) { |
| | | if (filter === 'active' || filter === 'pending') { |
| | | return 'accepted' |
| | |
| | | const textMap = { |
| | | service: '立即呼叫讲解', |
| | | meeting: '下一步', |
| | | payment: '确认支付' |
| | | payment: '确认下单' |
| | | } |
| | | return textMap[guideFlowStep.value] || textMap.service |
| | | }) |
| | | |
| | | const guideActionLoadingText = computed(() => (guideFlowStep.value === 'payment' ? '支付中...' : '处理中...')) |
| | | const guideActionLoadingText = computed(() => (guideFlowStep.value === 'payment' ? '下单中...' : '处理中...')) |
| | | |
| | | const guideActionSuccessText = computed(() => (guideFlowStep.value === 'payment' ? '支付成功' : '已完成')) |
| | | const guideActionSuccessText = computed(() => (guideFlowStep.value === 'payment' ? '下单成功' : '已完成')) |
| | | |
| | | const guideActionDisabled = computed(() => { |
| | | if (guideFlowStep.value === 'meeting') return !selectedMeetingPointId.value |
| | |
| | | const order = activeHomeOrder.value |
| | | if (!order || !order.id) return |
| | | |
| | | const routeMap = { |
| | | pendingPay: '/pages/order-pending-pay/index', |
| | | waiting: '/pages/order-waiting/index', |
| | | pendingGuide: '/pages/order-pending-guide/index', |
| | | serving: '/pages/order-serving/index', |
| | | completed: '/pages/order-completed/index', |
| | | pendingReview: '/pages/order-pending-review/index', |
| | | canceled: '/pages/order-canceled/index' |
| | | } |
| | | const target = routeMap[order.status] || '/pages/orders/index' |
| | | |
| | | uni.navigateTo({ |
| | | url: `${target}?orderId=${order.id}` |
| | | url: `${getActiveOrderRoute(order)}?orderId=${order.id}` |
| | | }) |
| | | } |
| | | |
| | |
| | | |
| | | try { |
| | | paymentSubmitting.value = true |
| | | const orderId = await createPendingGuideOrder() |
| | | const order = await createPendingGuideOrder() |
| | | paymentSubmitting.value = false |
| | | paymentSuccess.value = true |
| | | |
| | | setTimeout(() => { |
| | | uni.navigateTo({ |
| | | url: `/pages/order-pending-pay/index?orderId=${orderId}` |
| | | url: `${getActiveOrderRoute(activeHomeOrder.value)}?orderId=${order.id}` |
| | | }) |
| | | }, 220) |
| | | } catch (error) { |
| | |
| | | peopleCount: selectedServiceQuantity.value |
| | | }) |
| | | activeHomeOrder.value = normalizeActiveHomeOrder(order) |
| | | return order.id |
| | | return order |
| | | } |
| | | |
| | | function getActiveOrderRoute(order) { |
| | | const routeMap = { |
| | | pendingPay: '/pages/order-pending-pay/index', |
| | | waiting: '/pages/order-waiting/index', |
| | | pendingGuide: '/pages/order-pending-guide/index', |
| | | serving: '/pages/order-serving/index', |
| | | completed: '/pages/order-completed/index', |
| | | pendingReview: '/pages/order-pending-review/index', |
| | | canceled: '/pages/order-canceled/index' |
| | | } |
| | | return routeMap[order && order.status] || '/pages/orders/index' |
| | | } |
| | | |
| | | function normalizeActiveHomeOrder(order) { |
| | |
| | | |
| | | <script setup> |
| | | import { computed, ref } from 'vue' |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| | | import AppNav from '@/components/app-nav/app-nav.vue' |
| | | import CancelOrderSheet from '@/components/cancel-order-sheet/cancel-order-sheet.vue' |
| | | import { cancelTourOrder, getTourOrder } from '@/services/order' |
| | |
| | | |
| | | useLoginGuard('/pages/order-pending-guide/index') |
| | | |
| | | const ORDER_POLL_INTERVAL = 3000 |
| | | |
| | | const orderId = ref('') |
| | | const orderDetail = ref({}) |
| | | let orderPollTimer = null |
| | | let orderRefreshing = false |
| | | let pageActive = false |
| | | let redirecting = false |
| | | let loadErrorShown = false |
| | | |
| | | const meetingPoint = computed(() => ({ |
| | | longitude: Number(orderDetail.value.meetingPointLongitude || 0), |
| | |
| | | |
| | | onLoad((options = {}) => { |
| | | orderId.value = options.orderId || options.id || '' |
| | | loadOrderDetail() |
| | | }) |
| | | |
| | | async function loadOrderDetail() { |
| | | onShow(() => { |
| | | pageActive = true |
| | | redirecting = false |
| | | startOrderPolling() |
| | | }) |
| | | |
| | | onHide(() => { |
| | | pageActive = false |
| | | stopOrderPolling() |
| | | }) |
| | | |
| | | onUnload(() => { |
| | | pageActive = false |
| | | stopOrderPolling() |
| | | }) |
| | | |
| | | function startOrderPolling() { |
| | | stopOrderPolling() |
| | | if (!orderId.value) { |
| | | uni.showToast({ title: '订单参数缺失', icon: 'none' }) |
| | | return |
| | | } |
| | | try { |
| | | orderDetail.value = await getTourOrder(orderId.value) |
| | | } catch (error) { |
| | | uni.showToast({ title: error.message || '订单加载失败', icon: 'none' }) |
| | | |
| | | refreshOrderStatus() |
| | | orderPollTimer = setInterval(refreshOrderStatus, ORDER_POLL_INTERVAL) |
| | | } |
| | | |
| | | function stopOrderPolling() { |
| | | if (orderPollTimer) { |
| | | clearInterval(orderPollTimer) |
| | | orderPollTimer = null |
| | | } |
| | | } |
| | | |
| | | async function refreshOrderStatus() { |
| | | if (!pageActive || !orderId.value || orderRefreshing || redirecting) return |
| | | |
| | | orderRefreshing = true |
| | | try { |
| | | const detail = await getTourOrder(orderId.value) |
| | | if (!pageActive) return |
| | | orderDetail.value = detail |
| | | loadErrorShown = false |
| | | redirectForOrderStatus(detail && detail.status) |
| | | } catch (error) { |
| | | if (!loadErrorShown) { |
| | | loadErrorShown = true |
| | | uni.showToast({ title: error.message || '订单状态更新失败', icon: 'none' }) |
| | | } |
| | | console.warn('待讲解订单状态查询失败', error) |
| | | } finally { |
| | | orderRefreshing = false |
| | | } |
| | | } |
| | | |
| | | function redirectForOrderStatus(status) { |
| | | const routeMap = { |
| | | PENDING_PAYMENT: '/pages/order-pending-pay/index', |
| | | PENDING_ACCEPT: '/pages/order-waiting/index', |
| | | IN_SERVICE: '/pages/order-serving/index', |
| | | COMPLETED: '/pages/order-pending-review/index', |
| | | CANCELLED: '/pages/order-canceled/index', |
| | | REFUNDED: '/pages/order-canceled/index' |
| | | } |
| | | const target = routeMap[status] |
| | | if (!target || redirecting) return |
| | | |
| | | redirecting = true |
| | | stopOrderPolling() |
| | | uni.redirectTo({ |
| | | url: `${target}?orderId=${orderId.value}`, |
| | | fail() { |
| | | redirecting = false |
| | | if (pageActive) startOrderPolling() |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function formatAmount(value) { |
| | | const amount = Number(value || 0) |
| | | return Number.isFinite(amount) ? amount.toFixed(2) : '0.00' |
| | |
| | | <view class="status-panel"> |
| | | <text class="status-title">讲解中</text> |
| | | <text class="countdown-text">{{ countdownText }}</text> |
| | | <view class="team-badge" :style="familyBadgeStyle"> |
| | | <text class="team-badge__text">家庭号 {{ familyGroup.number }}</text> |
| | | <view v-if="order.familyNo" class="team-badge" :style="familyBadgeStyle"> |
| | | <text class="team-badge__text">家庭号 {{ order.familyNo }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="detail-card"> |
| | | <view class="guide-row"> |
| | | <view class="guide-avatar"> |
| | | <image class="guide-avatar__icon" src="/static/ui-icons/user-blue.svg" mode="aspectFit" /> |
| | | <image class="guide-avatar__icon" :src="order.guideAvatar || '/static/ui-icons/user-blue.svg'" mode="aspectFill" /> |
| | | </view> |
| | | <view class="guide-main"> |
| | | <text class="guide-name">导游 · 赵四</text> |
| | | <text class="guide-name">导游 · {{ order.guideName }}</text> |
| | | <view class="guide-stars"> |
| | | <text v-for="item in 5" :key="item" class="guide-star">★</text> |
| | | <text v-for="item in 5" :key="item" class="guide-star" :class="{ 'guide-star--inactive': item > guideStarCount }">★</text> |
| | | </view> |
| | | </view> |
| | | <view class="phone-action" @tap="handleCallGuide"> |
| | |
| | | |
| | | <view class="price-row"> |
| | | <view class="unit-block"> |
| | | <text class="unit-price">¥{{ order.unitPrice }}/人</text> |
| | | <text class="unit-price">¥{{ order.unitPrice }}{{ order.unit }}</text> |
| | | <text class="quantity">x{{ order.quantity }}</text> |
| | | </view> |
| | | <text class="total-price">¥{{ order.total }}</text> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, onBeforeUnmount, ref } from 'vue' |
| | | import { computed, ref } from 'vue' |
| | | import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| | | import AppNav from '@/components/app-nav/app-nav.vue' |
| | | import { getTourOrder } from '@/services/order' |
| | | import { resolveProfileAssetUrl } from '@/services/profile' |
| | | import { useLoginGuard } from '@/utils/auth-guard' |
| | | |
| | | useLoginGuard('/pages/order-serving/index') |
| | | |
| | | const order = { |
| | | id: 'MJ20260612003', |
| | | scenic: '青城山景区', |
| | | service: '拼团·快讲', |
| | | duration: '30分钟', |
| | | meetingPoint: '青城山正门汇合', |
| | | unitPrice: 36, |
| | | quantity: 4, |
| | | total: '366.00' |
| | | } |
| | | const ORDER_POLL_INTERVAL = 3000 |
| | | |
| | | const familyGroup = { |
| | | number: 1, |
| | | color: '#278BFF' |
| | | } |
| | | const orderId = ref('') |
| | | const orderDetail = ref({}) |
| | | const remainingSeconds = ref(0) |
| | | let countdownTimer = null |
| | | let orderPollTimer = null |
| | | let orderRefreshing = false |
| | | let pageActive = false |
| | | let redirecting = false |
| | | let loadErrorShown = false |
| | | |
| | | const remainingSeconds = ref(30 * 60 * 60) |
| | | const timer = setInterval(() => { |
| | | if (remainingSeconds.value > 0) { |
| | | remainingSeconds.value -= 1 |
| | | } |
| | | }, 1000) |
| | | const order = computed(() => ({ |
| | | id: orderDetail.value.id || orderId.value, |
| | | scenic: orderDetail.value.scenicName || '-', |
| | | service: formatServiceTitle(orderDetail.value.packageName, orderDetail.value.orderType), |
| | | duration: orderDetail.value.durationMinutes ? `${orderDetail.value.durationMinutes}分钟` : '-', |
| | | meetingPoint: orderDetail.value.meetingPointName || '-', |
| | | unitPrice: formatAmount(orderDetail.value.unitPrice), |
| | | unit: String(orderDetail.value.orderType || '').startsWith('GROUP_') ? '/人' : '/团', |
| | | quantity: Number(orderDetail.value.peopleCount || 1), |
| | | total: formatAmount(orderDetail.value.paymentAmount ?? orderDetail.value.amount), |
| | | familyNo: orderDetail.value.familyNo || '', |
| | | guideName: orderDetail.value.guideName || '未分配', |
| | | guideMobile: orderDetail.value.guideMobile || '', |
| | | guideAvatar: resolveProfileAssetUrl(orderDetail.value.guideAvatar), |
| | | guideRating: Number(orderDetail.value.guideRating || 0) |
| | | })) |
| | | |
| | | const guideStarCount = computed(() => Math.max(0, Math.min(5, Math.round(order.value.guideRating)))) |
| | | |
| | | const countdownText = computed(() => { |
| | | const hours = Math.floor(remainingSeconds.value / 3600) |
| | |
| | | }) |
| | | |
| | | const familyBadgeStyle = computed(() => ({ |
| | | backgroundColor: familyGroup.color |
| | | backgroundColor: '#278BFF' |
| | | })) |
| | | |
| | | const detailRows = [ |
| | | { label: '下单时间', value: '2026-06-04 11:31:02' }, |
| | | { label: '接单时间', value: '2026-06-04 11:31:02' }, |
| | | { label: '支付金额', value: '¥366.00' }, |
| | | { label: '支付流水号', value: '46145113464411' }, |
| | | { label: '讲解开始时间', value: '2026-06-04 11:31:02' }, |
| | | { label: '讲解结束时间', value: '-' } |
| | | ] |
| | | const detailRows = computed(() => [ |
| | | { label: '下单时间', value: formatDateTime(orderDetail.value.placedAt) }, |
| | | { label: '接单时间', value: formatDateTime(orderDetail.value.acceptedAt) }, |
| | | { label: '支付金额', value: `¥${formatAmount(orderDetail.value.paymentAmount ?? orderDetail.value.amount)}` }, |
| | | { label: '支付流水号', value: orderDetail.value.paymentOrderNo || '-' }, |
| | | { label: '讲解开始时间', value: formatDateTime(orderDetail.value.serviceStartedAt) }, |
| | | { label: '讲解结束时间', value: formatDateTime(orderDetail.value.completedAt) } |
| | | ]) |
| | | |
| | | onBeforeUnmount(() => { |
| | | clearInterval(timer) |
| | | onLoad((options = {}) => { |
| | | orderId.value = options.orderId || options.id || '' |
| | | }) |
| | | |
| | | onShow(() => { |
| | | pageActive = true |
| | | redirecting = false |
| | | startOrderPolling() |
| | | startCountdown() |
| | | }) |
| | | |
| | | onHide(() => { |
| | | pageActive = false |
| | | clearTimers() |
| | | }) |
| | | |
| | | onUnload(() => { |
| | | pageActive = false |
| | | clearTimers() |
| | | }) |
| | | |
| | | function startOrderPolling() { |
| | | if (!orderId.value) { |
| | | uni.showToast({ title: '订单参数缺失', icon: 'none' }) |
| | | return |
| | | } |
| | | |
| | | clearInterval(orderPollTimer) |
| | | refreshOrderStatus() |
| | | orderPollTimer = setInterval(refreshOrderStatus, ORDER_POLL_INTERVAL) |
| | | } |
| | | |
| | | async function refreshOrderStatus() { |
| | | if (!pageActive || !orderId.value || orderRefreshing || redirecting) return |
| | | |
| | | orderRefreshing = true |
| | | try { |
| | | const detail = await getTourOrder(orderId.value) |
| | | if (!pageActive) return |
| | | orderDetail.value = detail |
| | | loadErrorShown = false |
| | | syncCountdown(detail) |
| | | redirectForOrderStatus(detail && detail.status) |
| | | } catch (error) { |
| | | if (!loadErrorShown) { |
| | | loadErrorShown = true |
| | | uni.showToast({ title: error.message || '订单状态更新失败', icon: 'none' }) |
| | | } |
| | | console.warn('讲解中订单状态查询失败', error) |
| | | } finally { |
| | | orderRefreshing = false |
| | | } |
| | | } |
| | | |
| | | function syncCountdown(detail) { |
| | | const durationSeconds = Math.max(0, Number(detail && detail.durationMinutes) || 0) * 60 |
| | | const startedAt = parseDateTime(detail && detail.serviceStartedAt) |
| | | const elapsedSeconds = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0 |
| | | remainingSeconds.value = Math.max(0, durationSeconds - elapsedSeconds) |
| | | } |
| | | |
| | | function startCountdown() { |
| | | clearInterval(countdownTimer) |
| | | countdownTimer = setInterval(() => { |
| | | if (remainingSeconds.value > 0) remainingSeconds.value -= 1 |
| | | }, 1000) |
| | | } |
| | | |
| | | function redirectForOrderStatus(status) { |
| | | const routeMap = { |
| | | PENDING_PAYMENT: '/pages/order-pending-pay/index', |
| | | PENDING_ACCEPT: '/pages/order-waiting/index', |
| | | PENDING_MEETING: '/pages/order-pending-guide/index', |
| | | COMPLETED: '/pages/order-pending-review/index', |
| | | CANCELLED: '/pages/order-canceled/index', |
| | | REFUNDED: '/pages/order-canceled/index' |
| | | } |
| | | const target = routeMap[status] |
| | | if (!target || redirecting) return |
| | | |
| | | redirecting = true |
| | | clearTimers() |
| | | uni.redirectTo({ |
| | | url: `${target}?orderId=${orderId.value}`, |
| | | fail() { |
| | | redirecting = false |
| | | if (pageActive) { |
| | | startOrderPolling() |
| | | startCountdown() |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function clearTimers() { |
| | | if (countdownTimer) { |
| | | clearInterval(countdownTimer) |
| | | countdownTimer = null |
| | | } |
| | | if (orderPollTimer) { |
| | | clearInterval(orderPollTimer) |
| | | orderPollTimer = null |
| | | } |
| | | } |
| | | |
| | | function parseDateTime(value) { |
| | | if (!value) return 0 |
| | | const timestamp = new Date(String(value).replace(' ', 'T')).getTime() |
| | | return Number.isFinite(timestamp) ? timestamp : 0 |
| | | } |
| | | |
| | | function formatTime(value) { |
| | | return String(value).padStart(2, '0') |
| | | } |
| | | |
| | | function formatAmount(value) { |
| | | const amount = Number(value || 0) |
| | | return Number.isFinite(amount) ? amount.toFixed(2) : '0.00' |
| | | } |
| | | |
| | | function formatDateTime(value) { |
| | | return value ? String(value).replace('T', ' ').slice(0, 19) : '-' |
| | | } |
| | | |
| | | function formatServiceTitle(packageName, orderType) { |
| | | if (packageName) return String(packageName).replace(/\s*·\s*/g, '·') |
| | | const serviceTitleMap = { |
| | | GROUP_QUICK: '拼团·快讲', |
| | | GROUP_PREMIUM: '拼团·精讲', |
| | | PRIVATE_QUICK: '包团·快讲', |
| | | PRIVATE_PREMIUM: '包团·精讲' |
| | | } |
| | | return serviceTitleMap[orderType] || '讲解服务' |
| | | } |
| | | |
| | | function handleCallGuide() { |
| | | uni.showActionSheet({ |
| | | itemList: ['拨打导游电话'], |
| | | success() { |
| | | uni.showToast({ |
| | | title: '电话联系开发中', |
| | | icon: 'none' |
| | | }) |
| | | } |
| | | }) |
| | | if (!order.value.guideMobile) { |
| | | uni.showToast({ title: '暂无导游联系电话', icon: 'none' }) |
| | | return |
| | | } |
| | | uni.makePhoneCall({ phoneNumber: order.value.guideMobile }) |
| | | } |
| | | </script> |
| | | |
| | |
| | | line-height: 1; |
| | | } |
| | | |
| | | .guide-star--inactive { |
| | | color: #d8dde6; |
| | | } |
| | | |
| | | .phone-action { |
| | | display: flex; |
| | | flex-direction: column; |