package com.starry.admin.modules.order.service.impl;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*;

import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.constant.OrderConstant.CommodityType;
import com.starry.admin.modules.order.module.constant.OrderConstant.Gender;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundFlag;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderType;
import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod;
import com.starry.admin.modules.order.module.constant.OrderConstant.PaymentSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.weichat.service.NotificationSender;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;

@ExtendWith(MockitoExtension.class)
class OrderLifecycleServiceImplTest {

    @InjectMocks
    private OrderLifecycleServiceImpl lifecycleService;

    @Mock
    private PlayOrderInfoMapper orderInfoMapper;

    @Mock
    private IEarningsService earningsService;

    @Mock
    private NotificationSender notificationSender;

    @Mock
    private IPlayOrderRefundInfoService orderRefundInfoService;

    @Mock
    private IPlayCustomUserInfoService customUserInfoService;

    @Mock
    private IPlayCouponDetailsService playCouponDetailsService;

    @Mock
    private IPlayCouponInfoService playCouponInfoService;

    @Mock
    private ClerkRevenueCalculator clerkRevenueCalculator;

    @Mock
    private PlayOrderLogInfoMapper orderLogInfoMapper;

    @Mock
    private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;

    @Mock
    private ApplicationEventPublisher applicationEventPublisher;

    @BeforeEach
    void initStrategies() {
        lifecycleService.initPlacementStrategies();
    }

    @Test
    void revokeCompletedOrder_updatesStatusAndPublishesEvent() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completed.setFinalAmount(BigDecimal.valueOf(188));
        completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
        PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
        revoked.setFinalAmount(BigDecimal.valueOf(188));

        when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
        lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
                .thenReturn(BigDecimal.TEN);
        when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
                .thenReturn(true);
        when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
                .thenReturn(BigDecimal.valueOf(60));

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-8");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setRefundAmount(BigDecimal.valueOf(88));
        context.setRefundReason("客户投诉");
        context.setRefundToCustomer(true);
        context.setDeductClerkEarnings(true);

        lifecycleService.revokeCompletedOrder(context);

        verify(orderInfoMapper).update(isNull(), any());
        verify(orderLogInfoMapper).insert(any());
        ArgumentCaptor<OrderRevocationEvent> captor = ArgumentCaptor.forClass(OrderRevocationEvent.class);
        verify(applicationEventPublisher).publishEvent(captor.capture());
        OrderRevocationEvent event = captor.getValue();
        assertEquals(orderId, event.getContext().getOrderId());
        assertTrue(event.getContext().isDeductClerkEarnings());
        assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount());
        assertEquals(BigDecimal.valueOf(60), event.getContext().getEarningsAdjustAmount());
    }

    @Test
    void revokeCompletedOrder_defersBalanceCreditToListener() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completed.setFinalAmount(BigDecimal.valueOf(208));
        completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
        PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
        revoked.setFinalAmount(BigDecimal.valueOf(208));

        when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
        lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
                .thenReturn(true);

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-9");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setRefundAmount(BigDecimal.valueOf(108));
        context.setRefundReason("质量问题");
        context.setRefundToCustomer(true);
        context.setDeductClerkEarnings(false);

        lifecycleService.revokeCompletedOrder(context);

        verify(customUserInfoService, never()).getById(anyString());
        verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
    }

    @Test
    void revokeCompletedOrder_reverseStrategyDefaultsCounterClerkWhenMissing() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
        PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
        lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
                .thenReturn(BigDecimal.TEN);

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-locked");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setRefundToCustomer(false);
        context.setDeductClerkEarnings(true);

        context.setEarningsAdjustAmount(BigDecimal.valueOf(10));

        assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
        verify(orderInfoMapper).update(isNull(), any());
        verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
    }

    @Test
    void revokeCompletedOrder_throwsWhenDeductExceedsAvailable() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
        when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
                .thenReturn(BigDecimal.valueOf(40));

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-counter");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setRefundToCustomer(false);
        context.setRefundAmount(BigDecimal.ZERO);
        context.setDeductClerkEarnings(true);
        context.setEarningsAdjustAmount(BigDecimal.valueOf(50));

        CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
        assertEquals("扣回金额不能超过本单收益40", ex.getMessage());
    }

    @Test
    void revokeCompletedOrder_throwsWhenNoEarningsToDeduct() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
        when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
                .thenReturn(BigDecimal.ZERO);

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-empty");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setRefundToCustomer(false);
        context.setRefundAmount(BigDecimal.ZERO);
        context.setDeductClerkEarnings(true);

        CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
        assertEquals("本单店员收益已全部扣回", ex.getMessage());
    }

    @Test
    void revokeCompletedOrder_rejectsNonNormalOrders() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity giftOrder = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        giftOrder.setOrderType(OrderConstant.OrderType.GIFT.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(giftOrder);

        OrderRevocationContext context = new OrderRevocationContext();
        context.setOrderId(orderId);
        context.setOperatorId("admin-block");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setDeductClerkEarnings(false);

        CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
        assertEquals("仅支持撤销普通服务订单", ex.getMessage());
        verify(orderInfoMapper, never()).update(isNull(), any());
    }

    @Test
    void placeOrder_throwsWhenCommandNull() {
        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null));
    }

    @Test
    void placeOrder_throwsWhenContextNull() {
        OrderPlacementCommand command = mock(OrderPlacementCommand.class);
        when(command.getOrderContext()).thenReturn(null);

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_throwsWhenPaymentInfoMissing() {
        OrderCreationContext context = OrderCreationContext.builder()
                .orderId("ctx-001")
                .orderNo("NO-001")
                .orderStatus(OrderStatus.PENDING)
                .orderType(OrderType.NORMAL)
                .placeType(PlaceType.OTHER)
                .rewardType(RewardType.NOT_APPLICABLE)
                .isFirstOrder(false)
                .commodityInfo(CommodityInfo.builder()
                        .commodityId("commodity")
                        .commodityType(CommodityType.SERVICE)
                        .commodityPrice(BigDecimal.TEN)
                        .serviceDuration("60")
                        .commodityName("服务")
                        .commodityNumber("1")
                        .build())
                .paymentInfo(null)
                .purchaserBy("customer-1")
                .build();

        OrderPlacementCommand command = OrderPlacementCommand.builder()
                .orderContext(context)
                .deductBalance(false)
                .build();

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_specifiedOrder_persistsAndUpdatesCoupon() {
        OrderCreationContext request = OrderCreationContext.builder()
                .orderId("order-init-001")
                .orderNo("NO20241001")
                .orderStatus(OrderStatus.PENDING)
                .orderType(OrderType.NORMAL)
                .placeType(PlaceType.SPECIFIED)
                .rewardType(RewardType.NOT_APPLICABLE)
                .isFirstOrder(true)
                .commodityInfo(CommodityInfo.builder()
                        .commodityId("commodity-01")
                        .commodityType(CommodityType.SERVICE)
                        .commodityPrice(BigDecimal.valueOf(199))
                        .serviceDuration("60")
                        .commodityName("服务A")
                        .commodityNumber("1")
                        .build())
                .paymentInfo(PaymentInfo.builder()
                        .orderMoney(BigDecimal.valueOf(199))
                        .finalAmount(BigDecimal.valueOf(179))
                        .discountAmount(BigDecimal.valueOf(20))
                        .couponIds(Collections.singletonList("coupon-1"))
                        .payMethod(PayMethod.WECHAT.getCode())
                        .build())
                .purchaserBy("customer-1")
                .acceptBy("clerk-1")
                .weiChatCode("wx-001")
                .remark("备注")
                .build();

        OrderPlacementCommand command = OrderPlacementCommand.builder()
                .orderContext(request)
                .deductBalance(false)
                .pricingInput(OrderPlacementCommand.PricingInput.builder()
                        .unitPrice(BigDecimal.valueOf(199))
                        .quantity(1)
                        .couponIds(request.getPaymentInfo().getCouponIds())
                        .commodityId("commodity-01")
                        .placeType(PlaceType.SPECIFIED)
                        .build())
                .build();

        ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
        revenueVo.setRevenueAmount(BigDecimal.valueOf(89.5));
        revenueVo.setRevenueRatio(50);

        when(orderInfoMapper.selectCount(any())).thenReturn(0L);
        when(orderInfoMapper.insert(any())).thenReturn(1);
        when(clerkRevenueCalculator.calculateEstimatedRevenue(
                anyString(), anyList(), anyString(), anyString(), any())).thenReturn(revenueVo);
        doNothing().when(customUserInfoService).saveOrderInfo(any());
        doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());

        PlayCouponDetailsEntity couponDetails = new PlayCouponDetailsEntity();
        couponDetails.setCouponId("coupon-master");
        when(playCouponDetailsService.getById("coupon-1")).thenReturn(couponDetails);

        PlayCouponInfoEntity couponInfo = new PlayCouponInfoEntity();
        couponInfo.setDiscountType("0");
        couponInfo.setDiscountAmount(BigDecimal.valueOf(20));
        when(playCouponInfoService.selectPlayCouponInfoById("coupon-master")).thenReturn(couponInfo);
        when(playCouponInfoService.getCouponReasonForUnavailableUse(
                eq(couponInfo),
                eq(PlaceType.SPECIFIED.getCode()),
                eq("commodity-01"),
                eq(1),
                any())).thenReturn("");

        OrderPlacementResult result = lifecycleService.placeOrder(command);
        PlayOrderInfoEntity created = result.getOrder();

        verify(customUserInfoService).saveOrderInfo(created);
        verify(orderInfoMapper).insert(created);
        verify(playCouponDetailsService).updateCouponUseStateByIds(
                request.getPaymentInfo().getCouponIds(), CouponUseState.USED.getCode());
        verify(clerkRevenueCalculator).calculateEstimatedRevenue(
                request.getAcceptBy(),
                request.getPaymentInfo().getCouponIds(),
                request.getPlaceType().getCode(),
                YesNoFlag.YES.getCode(),
                request.getPaymentInfo().getOrderMoney());

        assertEquals(YesNoFlag.YES.getCode(), created.getFirstOrder());
        assertEquals(revenueVo.getRevenueAmount(), created.getEstimatedRevenue());
        assertEquals(revenueVo.getRevenueRatio(), created.getEstimatedRevenueRatio());
        assertEquals(PayMethod.WECHAT.getCode(), created.getPayMethod());
    }

    @Test
    void placeOrder_otherType_skipsPricingAndUsesExistingAmounts() {
        PaymentInfo payment = payment(BigDecimal.valueOf(50), BigDecimal.valueOf(50), BigDecimal.ZERO, Collections.emptyList());
        OrderCreationContext context = baseContext(
                PlaceType.OTHER,
                RewardType.NOT_APPLICABLE,
                payment,
                null,
                null);

        stubDefaultPersistence();
        OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));

        assertMoneyEquals("50.00", result.getAmountBreakdown().getGrossAmount());
        assertMoneyEquals("0.00", result.getAmountBreakdown().getDiscountAmount());
        assertMoneyEquals("50.00", result.getAmountBreakdown().getNetAmount());
        verify(playCouponDetailsService, never()).updateCouponUseStateByIds(anyList(), anyString());
    }

    @Test
    void placeOrder_otherType_withCoupons_marksUsageButKeepsAmounts() {
        List<String> coupons = Collections.singletonList("coupon-1");
        PaymentInfo payment = payment(BigDecimal.valueOf(60), BigDecimal.valueOf(60), BigDecimal.ZERO, coupons);
        OrderCreationContext context = baseContext(
                PlaceType.OTHER,
                RewardType.NOT_APPLICABLE,
                payment,
                null,
                null);

        stubDefaultPersistence();
        lifecycleService.placeOrder(command(context, null, false, null, null));

        verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
    }

    @Test
    void placeOrder_specifiedWithoutCoupons_calculatesTotallyFromPricing() {
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                "clerk-001");

        stubDefaultPersistence();

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(20), 2, Collections.emptyList(), "commodity-x", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("40.00", context.getPaymentInfo().getOrderMoney());
        assertMoneyEquals("0.00", context.getPaymentInfo().getDiscountAmount());
        assertMoneyEquals("40.00", context.getPaymentInfo().getFinalAmount());
        assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedWithFullReductionCouponReducesNetAmount() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();
        stubCoupon("coupon-1", "coupon-master", "0", BigDecimal.valueOf(15), "");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-123", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("15.00", result.getAmountBreakdown().getDiscountAmount());
        assertMoneyEquals("15.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedWithMultipleCouponsCapsNetAtZero() {
        List<String> coupons = Arrays.asList("coupon-1", "coupon-2");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();

        PlayCouponDetailsEntity firstDetail = new PlayCouponDetailsEntity();
        firstDetail.setCouponId("master-1");
        PlayCouponDetailsEntity secondDetail = new PlayCouponDetailsEntity();
        secondDetail.setCouponId("master-2");
        when(playCouponDetailsService.getById("coupon-1")).thenReturn(firstDetail);
        when(playCouponDetailsService.getById("coupon-2")).thenReturn(secondDetail);

        PlayCouponInfoEntity firstInfo = new PlayCouponInfoEntity();
        firstInfo.setDiscountType("0");
        firstInfo.setDiscountAmount(BigDecimal.valueOf(40));
        PlayCouponInfoEntity secondInfo = new PlayCouponInfoEntity();
        secondInfo.setDiscountType("0");
        secondInfo.setDiscountAmount(BigDecimal.valueOf(30));
        when(playCouponInfoService.selectPlayCouponInfoById("master-1")).thenReturn(firstInfo);
        when(playCouponInfoService.selectPlayCouponInfoById("master-2")).thenReturn(secondInfo);
        when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
                .thenReturn("");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(60), 1, coupons, "commodity-max", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedWithPercentageFold_eightyPercentApplied() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();
        stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(8), "");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(50), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedWithPercentageFoldGreaterThanTenTreatsAsFree() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();
        stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(20), "");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(40), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedWithNonPositivePercentageIgnoresDiscount() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();
        stubCoupon("coupon-1", "master-1", "1", BigDecimal.ZERO, "");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_specifiedThrowsWhenCouponDetailMissing() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                null);

        stubDefaultPersistence();
        when(playCouponDetailsService.getById("coupon-1")).thenReturn(null);

        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-missing", PlaceType.SPECIFIED),
                false,
                null,
                null);

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_specifiedThrowsWhenCouponIneligible() {
        List<String> coupons = Collections.singletonList("coupon-1");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                null);

        stubDefaultPersistence();
        stubCoupon("coupon-1", "master-1", "0", BigDecimal.valueOf(10), "订单类型不符合");

        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-invalid", PlaceType.SPECIFIED),
                false,
                null,
                null);

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_specifiedSkipsUnsupportedCouponTypeButSucceeds() {
        List<String> coupons = Collections.singletonList("coupon-unsupported");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-001");

        stubDefaultPersistence();
        PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
        detail.setCouponId("master-unsupported");
        when(playCouponDetailsService.getById("coupon-unsupported")).thenReturn(detail);
        PlayCouponInfoEntity info = new PlayCouponInfoEntity();
        info.setDiscountType("X");
        info.setDiscountAmount(BigDecimal.valueOf(999));
        when(playCouponInfoService.selectPlayCouponInfoById("master-unsupported")).thenReturn(info);
        when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
                .thenReturn("");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(45), 1, coupons, "commodity-unsupported", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("45.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_randomWithValidRequirementsSucceeds() {
        RandomOrderRequirements requirements = RandomOrderRequirements.builder()
                .clerkGender(Gender.MALE)
                .clerkLevelId("level-1")
                .excludeHistory("0")
                .build();
        OrderCreationContext context = baseContext(
                PlaceType.RANDOM,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                requirements,
                null);

        stubDefaultPersistence();
        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(35), 2, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
                false,
                null,
                null));

        assertMoneyEquals("70.00", result.getAmountBreakdown().getGrossAmount());
    }

    @Test
    void placeOrder_randomWithoutRequirementsThrows() {
        OrderCreationContext context = baseContext(
                PlaceType.RANDOM,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
                false,
                null,
                null);

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_rewardGiftWithoutAcceptByDoesNotAutoComplete() {
        OrderCreationContext context = baseContext(
                PlaceType.REWARD,
                RewardType.GIFT,
                payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        stubDefaultPersistence();
        lifecycleService.placeOrder(command(context, null, false, null, null));

        verify(orderInfoMapper, never()).selectById(anyString());
        verify(notificationSender, never()).sendOrderFinishMessageAsync(any());
        verify(earningsService, never()).createFromOrder(any());
    }

    @Test
    void placeOrder_rewardGiftWithAcceptByTriggersAutoComplete() {
        PlayOrderInfoEntity inProgress = buildOrder("reward-order", OrderStatus.IN_PROGRESS.getCode());
        inProgress.setAcceptBy("clerk-1");
        PlayOrderInfoEntity completed = buildOrder("reward-order", OrderStatus.COMPLETED.getCode());

        OrderCreationContext context = baseContext(
                PlaceType.REWARD,
                RewardType.GIFT,
                payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
                null,
                "clerk-1");

        stubDefaultPersistence();
        when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        mockEarningsCounts(0L, 1L);
        doNothing().when(customUserInfoService).handleOrderCompletion(any());
        doNothing().when(earningsService).createFromOrder(any());

        lifecycleService.placeOrder(command(context, null, false, null, null));

        verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
        verify(customUserInfoService).handleOrderCompletion(any());
        verify(earningsService).createFromOrder(completed);
        verify(notificationSender).sendOrderFinishMessageAsync(completed);
    }

    @Test
    void placeOrder_rewardTipWithoutAcceptByDoesNotAutoComplete() {
        OrderCreationContext context = baseContext(
                PlaceType.REWARD,
                RewardType.BALANCE,
                payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        stubDefaultPersistence();
        lifecycleService.placeOrder(command(context, null, false, null, null));

        verify(orderInfoMapper, never()).selectById(anyString());
        verify(notificationSender, never()).sendOrderFinishMessageAsync(any());
        verify(earningsService, never()).createFromOrder(any());
    }

    @Test
    void placeOrder_rewardTipWithAcceptByTriggersAutoComplete() {
        PlayOrderInfoEntity inProgress = buildOrder("tip-reward", OrderStatus.IN_PROGRESS.getCode());
        inProgress.setAcceptBy("clerk-3");
        PlayOrderInfoEntity completed = buildOrder("tip-reward", OrderStatus.COMPLETED.getCode());

        OrderCreationContext context = baseContext(
                PlaceType.REWARD,
                RewardType.BALANCE,
                payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
                null,
                "clerk-3");

        stubDefaultPersistence();
        when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        mockEarningsCounts(0L, 1L);
        doNothing().when(customUserInfoService).handleOrderCompletion(any());
        doNothing().when(earningsService).createFromOrder(any());

        lifecycleService.placeOrder(command(context, null, false, null, null));

        verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
        verify(customUserInfoService).handleOrderCompletion(any());
        verify(earningsService).createFromOrder(completed);
        verify(notificationSender).sendOrderFinishMessageAsync(completed);
    }

    @Test
    void placeOrder_withBalanceDeductionAndSufficientFundsUpdatesBalance() {
        List<String> coupons = Collections.emptyList();
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-balance");

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(100));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
        doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());

        stubDefaultPersistence();

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(20), 2, coupons, "commodity-balance", PlaceType.SPECIFIED),
                true,
                null,
                null));

        assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
        verify(customUserInfoService).updateAccountBalanceById(
                eq(context.getPurchaserBy()),
                eq(customer.getAccountBalance()),
                eq(customer.getAccountBalance().subtract(new BigDecimal("40.00"))),
                eq(BalanceOperationType.CONSUME.getCode()),
                eq("下单"),
                eq(new BigDecimal("40.00")),
                eq(BigDecimal.ZERO),
                anyString());
    }

    @Test
    void placeOrder_withBalanceDeductionUsesCustomAction() {
        List<String> coupons = Collections.emptyList();
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(50));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
        doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());

        stubDefaultPersistence();

        lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(10), 2, coupons, "commodity-act", PlaceType.SPECIFIED),
                true,
                "测试扣款",
                null));

        verify(customUserInfoService).updateAccountBalanceById(
                eq(context.getPurchaserBy()),
                any(),
                any(),
                eq(BalanceOperationType.CONSUME.getCode()),
                eq("测试扣款"),
                eq(new BigDecimal("20.00")),
                eq(BigDecimal.ZERO),
                anyString());
    }

    @Test
    void placeOrder_withInsufficientBalanceThrows() {
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);

        stubDefaultPersistence();
        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-insufficient", PlaceType.SPECIFIED),
                true,
                null,
                null);

        assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
        verify(customUserInfoService, never()).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
        verify(orderInfoMapper, never()).insert(any());
        verify(customUserInfoService, never()).saveOrderInfo(any());
    }

    @Test
    void placeOrder_withMissingCustomerThrows() {
        OrderCreationContext context = baseContext(
                PlaceType.OTHER,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(null);

        stubDefaultPersistence();

        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(10), 1, Collections.emptyList(), "commodity-missing", PlaceType.SPECIFIED),
                true,
                null,
                null);

        assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
    }

    @Test
    void placeOrder_noDeductionHandlesNullFinalAmount() {
        PaymentInfo payment = payment(BigDecimal.valueOf(15), null, BigDecimal.ZERO, Collections.emptyList());
        OrderCreationContext context = baseContext(
                PlaceType.OTHER,
                RewardType.NOT_APPLICABLE,
                payment,
                null,
                null);

        stubDefaultPersistence();
        OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));

        assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_deductFalseStillMarksCouponUsage() {
        List<String> coupons = Collections.singletonList("coupon-flag");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                null);

        stubDefaultPersistence();
        stubCoupon("coupon-flag", "master-flag", "0", BigDecimal.valueOf(5), "");

        lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-flag", PlaceType.SPECIFIED),
                false,
                null,
                null));

        verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
    }

    @Test
    void placeOrder_randomWithBalanceDeductionSucceeds() {
        RandomOrderRequirements requirements = RandomOrderRequirements.builder()
                .clerkGender(Gender.FEMALE)
                .clerkLevelId("level-9")
                .excludeHistory("1")
                .build();
        OrderCreationContext context = baseContext(
                PlaceType.RANDOM,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                requirements,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(300));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
        doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());

        stubDefaultPersistence();

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(60), 2, Collections.emptyList(), "commodity-random", PlaceType.RANDOM),
                true,
                "随机扣款",
                null));

        assertMoneyEquals("120.00", result.getAmountBreakdown().getNetAmount());
        verify(customUserInfoService).updateAccountBalanceById(
                eq(context.getPurchaserBy()),
                any(),
                any(),
                eq(BalanceOperationType.CONSUME.getCode()),
                eq("随机扣款"),
                eq(new BigDecimal("120.00")),
                eq(BigDecimal.ZERO),
                anyString());
    }

    @Test
    void placeOrder_randomWithDeductionThrowsWhenInsufficientBalance() {
        RandomOrderRequirements requirements = RandomOrderRequirements.builder()
                .clerkGender(Gender.UNKNOWN)
                .clerkLevelId("level-2")
                .excludeHistory("0")
                .build();
        OrderCreationContext context = baseContext(
                PlaceType.RANDOM,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                requirements,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);

        stubDefaultPersistence();

        OrderPlacementCommand command = command(
                context,
                pricing(BigDecimal.valueOf(30), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
                true,
                null,
                null);

        assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
        verify(orderInfoMapper, never()).insert(any());
        verify(customUserInfoService, never()).saveOrderInfo(any());
    }

    @Test
    void placeOrder_withMixedCouponTypesAppliesValidOnes() {
        List<String> coupons = Arrays.asList("coupon-invalid", "coupon-valid");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-1");

        stubDefaultPersistence();

        PlayCouponDetailsEntity invalidDetail = new PlayCouponDetailsEntity();
        invalidDetail.setCouponId("master-invalid");
        PlayCouponDetailsEntity validDetail = new PlayCouponDetailsEntity();
        validDetail.setCouponId("master-valid");
        when(playCouponDetailsService.getById("coupon-invalid")).thenReturn(invalidDetail);
        when(playCouponDetailsService.getById("coupon-valid")).thenReturn(validDetail);

        PlayCouponInfoEntity invalidInfo = new PlayCouponInfoEntity();
        invalidInfo.setDiscountType("X");
        invalidInfo.setDiscountAmount(BigDecimal.valueOf(100));
        PlayCouponInfoEntity validInfo = new PlayCouponInfoEntity();
        validInfo.setDiscountType("0");
        validInfo.setDiscountAmount(BigDecimal.valueOf(10));
        when(playCouponInfoService.selectPlayCouponInfoById("master-invalid")).thenReturn(invalidInfo);
        when(playCouponInfoService.selectPlayCouponInfoById("master-valid")).thenReturn(validInfo);
        when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
                .thenReturn("");

        OrderPlacementResult result = lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(35), 1, coupons, "commodity-mixed", PlaceType.SPECIFIED),
                false,
                null,
                null));

        assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
    }

    @Test
    void placeOrder_rejectsCouponWhenRestrictionReasonPresent() {
        List<String> coupons = Collections.singletonList("coupon-reused");
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
                null,
                "clerk-1");

        stubDefaultPersistence();

        PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
        detail.setCouponId("coupon-master");
        detail.setUseState(CouponUseState.USED.getCode());
        detail.setObtainingTime(LocalDateTime.now().minusDays(1));
        when(playCouponDetailsService.getById("coupon-reused")).thenReturn(detail);

        PlayCouponInfoEntity info = new PlayCouponInfoEntity();
        info.setCouponOnLineState("1");
        info.setValidityPeriodType("0");
        when(playCouponInfoService.selectPlayCouponInfoById("coupon-master")).thenReturn(info);
        when(playCouponInfoService.getCouponDetailRestrictionReason(eq(info), eq(detail.getUseState()), any(LocalDateTime.class)))
                .thenReturn(Optional.of("优惠券已使用"));

        CustomException exception = assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-reused", PlaceType.SPECIFIED),
                false,
                null,
                null)));

        assertEquals("优惠券不可用 - 优惠券已使用", exception.getMessage());
        verify(playCouponInfoService).getCouponDetailRestrictionReason(eq(info), eq(detail.getUseState()), any(LocalDateTime.class));
    }

    @Test
    void placeOrder_withDeductionFetchesCustomerBalance() {
        OrderCreationContext context = baseContext(
                PlaceType.SPECIFIED,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(70));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
        doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
        stubDefaultPersistence();

        lifecycleService.placeOrder(command(
                context,
                pricing(BigDecimal.valueOf(15), 2, Collections.emptyList(), "commodity-snap", PlaceType.SPECIFIED),
                true,
                null,
                null));

        verify(customUserInfoService, times(2)).selectById(context.getPurchaserBy());
        verify(customUserInfoService).updateAccountBalanceById(
                eq(customer.getId()),
                eq(customer.getAccountBalance()),
                eq(customer.getAccountBalance().subtract(new BigDecimal("30.00"))),
                eq(BalanceOperationType.CONSUME.getCode()),
                eq("下单"),
                eq(new BigDecimal("30.00")),
                eq(BigDecimal.ZERO),
                anyString());
    }

    @Test
    void placeOrder_withZeroFinalAmountStillUpdatesLedger() {
        OrderCreationContext context = baseContext(
                PlaceType.OTHER,
                RewardType.NOT_APPLICABLE,
                payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
                null,
                null);

        PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
        when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
        doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());

        stubDefaultPersistence();

        lifecycleService.placeOrder(command(
                context,
                null,
                true,
                null,
                null));

        verify(customUserInfoService).updateAccountBalanceById(
                eq(context.getPurchaserBy()),
                eq(customer.getAccountBalance()),
                eq(customer.getAccountBalance()),
                eq(BalanceOperationType.CONSUME.getCode()),
                eq("下单"),
                argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
                argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
                anyString());
    }


    @Test
    void completeOrder_inProgress_createsEarningsAndNotifies() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity inProgress = buildOrder(orderId, OrderStatus.IN_PROGRESS.getCode());
        inProgress.setOrderEndTime(null);

        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completed.setOrderEndTime(LocalDateTime.now());

        when(orderInfoMapper.selectById(orderId)).thenReturn(inProgress, completed);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
        mockEarningsCounts(0L, 1L);

        OrderCompletionContext context = OrderCompletionContext.of(
                OrderActor.CLERK,
                inProgress.getAcceptBy(),
                OrderTriggerSource.WX_CLERK);

        lifecycleService.completeOrder(orderId, context);

        verify(orderInfoMapper).update(isNull(), any());
        verify(customUserInfoService).handleOrderCompletion(completed);
        verify(earningsService).createFromOrder(completed);
        verify(notificationSender).sendOrderFinishMessageAsync(completed);
    }

    @Test
    void completeOrder_alreadyCompleted_skipsEarningsCreation() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity alreadyCompleted = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        alreadyCompleted.setOrderEndTime(null);

        PlayOrderInfoEntity completedWithEnd = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completedWithEnd.setOrderEndTime(LocalDateTime.now());

        when(orderInfoMapper.selectById(orderId)).thenReturn(alreadyCompleted, completedWithEnd, completedWithEnd);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(1);

        lifecycleService.completeOrder(orderId, OrderCompletionContext.of(
                OrderActor.ADMIN,
                "admin-1",
                OrderTriggerSource.ADMIN_CONSOLE));

        verify(earningsService, never()).createFromOrder(any());
        verify(notificationSender, never()).sendOrderFinishMessageAsync(any());
    }

    @Test
    void completeOrder_whenTransitionAlreadyApplied_skipsSideEffects() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity inProgress = buildOrder(orderId, OrderStatus.IN_PROGRESS.getCode());
        inProgress.setOrderEndTime(null);

        PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
        completed.setOrderEndTime(LocalDateTime.now());

        when(orderInfoMapper.selectById(orderId)).thenReturn(inProgress, completed, completed);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(0);

        lifecycleService.completeOrder(orderId, OrderCompletionContext.of(
                OrderActor.CLERK,
                inProgress.getAcceptBy(),
                OrderTriggerSource.WX_CLERK));

        verify(customUserInfoService, never()).handleOrderCompletion(any());
        verify(earningsService, never()).createFromOrder(any());
        verify(notificationSender, never()).sendOrderFinishMessageAsync(any());
        verify(orderLogInfoMapper, never()).insert(any());
    }

    @Test
    void refundOrder_partialAmount_updatesLedgerAndRecords() {
        String orderId = UUID.randomUUID().toString();
        BigDecimal finalAmount = BigDecimal.valueOf(100);
        BigDecimal refundAmount = BigDecimal.valueOf(40);

        PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode());
        order.setFinalAmount(finalAmount);
        order.setOrderMoney(finalAmount);
        order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
        order.setPayMethod(PayMethod.WECHAT.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(order, order);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(1);

        PlayCustomUserInfoEntity customer = new PlayCustomUserInfoEntity();
        customer.setId(order.getPurchaserBy());
        customer.setAccountBalance(BigDecimal.valueOf(10));
        when(customUserInfoService.getById(order.getPurchaserBy())).thenReturn(customer);
        when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(true);

        OrderRefundContext context = new OrderRefundContext();
        context.setOrderId(orderId);
        context.setRefundAmount(refundAmount);
        context.setRefundReason("部分退款测试");
        context.setOperatorType(OperatorType.ADMIN.getCode());
        context.setOperatorId("admin-1");
        context.withTriggerSource(OrderTriggerSource.ADMIN_API);

        lifecycleService.refundOrder(context);

        verify(orderInfoMapper).update(isNull(), any());

        verify(customUserInfoService).updateAccountBalanceById(
                eq(order.getPurchaserBy()),
                eq(customer.getAccountBalance()),
                eq(customer.getAccountBalance().add(refundAmount)),
                eq(BalanceOperationType.REFUND.getCode()),
                eq("订单退款"),
                eq(refundAmount),
                eq(BigDecimal.ZERO),
                eq(orderId));

        verify(orderRefundInfoService).add(
                eq(orderId),
                eq(order.getPurchaserBy()),
                eq(order.getAcceptBy()),
                eq(order.getPayMethod()),
                eq(OrderRefundRecordType.PARTIAL.getCode()),
                eq(refundAmount),
                eq("部分退款测试"),
                eq(OperatorType.ADMIN.getCode()),
                eq("admin-1"),
                eq(OrderRefundState.PROCESSING.getCode()),
                eq(ReviewRequirement.NOT_REQUIRED.getCode()));
    }

    @Test
    void refundOrder_throwsWhenAlreadyRefunded() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode());
        order.setFinalAmount(BigDecimal.TEN);
        order.setOrderMoney(BigDecimal.TEN);
        order.setRefundType(OrderRefundFlag.REFUNDED.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(order);

        OrderRefundContext context = new OrderRefundContext();
        context.setOrderId(orderId);
        context.setRefundAmount(BigDecimal.ONE);
        context.withTriggerSource(OrderTriggerSource.ADMIN_API);

        assertThrows(CustomException.class, () -> lifecycleService.refundOrder(context));
        verify(orderInfoMapper, never()).update(isNull(), any());
        verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString());
    }

    @Test
    void refundOrder_duplicateRequest_isIdempotent() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode());
        order.setFinalAmount(BigDecimal.TEN);
        order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());

        PlayOrderInfoEntity afterUpdate = buildOrder(orderId, OrderStatus.CANCELLED.getCode());
        afterUpdate.setRefundType(OrderRefundFlag.REFUNDED.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(order, afterUpdate);
        when(orderInfoMapper.update(isNull(), any())).thenReturn(0);

        OrderRefundContext context = new OrderRefundContext();
        context.setOrderId(orderId);
        context.setRefundAmount(BigDecimal.ONE);
        context.withTriggerSource(OrderTriggerSource.ADMIN_API);

        when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(true);

        lifecycleService.refundOrder(context);

        verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any());
        verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString());
        verify(orderLogInfoMapper, never()).insert(any());
    }

    @Test
    void refundOrder_balanceWithoutConsumption_throws() {
        String orderId = UUID.randomUUID().toString();
        PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode());
        order.setFinalAmount(BigDecimal.TEN);
        order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
        order.setPaymentSource(PaymentSource.BALANCE.getCode());

        when(orderInfoMapper.selectById(orderId)).thenReturn(order);
        when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(false);

        OrderRefundContext context = new OrderRefundContext();
        context.setOrderId(orderId);
        context.setRefundAmount(BigDecimal.ONE);
        context.withTriggerSource(OrderTriggerSource.ADMIN_API);

        assertThrows(CustomException.class, () -> lifecycleService.refundOrder(context));
        verify(orderInfoMapper, never()).update(isNull(), any());
        verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any());
        verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString());
    }

    private PlayOrderInfoEntity buildOrder(String orderId, String status) {
        PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
        entity.setId(orderId);
        entity.setOrderStatus(status);
        entity.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
        entity.setAcceptBy("clerk-1");
        entity.setPurchaserBy("customer-1");
        entity.setTenantId("tenant-1");
        return entity;
    }

    private PlayCustomUserInfoEntity buildCustomer(String id, BigDecimal balance) {
        PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
        entity.setId(id);
        entity.setAccountBalance(balance);
        return entity;
    }

    private void stubDefaultPersistence() {
        lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L);
        lenient().when(orderInfoMapper.insert(any())).thenReturn(1);
        lenient().doNothing().when(customUserInfoService).saveOrderInfo(any());
        lenient().doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());
        lenient().when(playCouponInfoService.getCouponDetailRestrictionReason(any(), any(), any()))
                .thenReturn(Optional.empty());
        ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
        revenueVo.setRevenueAmount(BigDecimal.ZERO);
        revenueVo.setRevenueRatio(0);
        lenient().when(clerkRevenueCalculator.calculateEstimatedRevenue(
                anyString(), anyList(), anyString(), anyString(), any())).thenReturn(revenueVo);
    }

    private PaymentInfo payment(BigDecimal gross, BigDecimal net, BigDecimal discount, List<String> couponIds) {
        return PaymentInfo.builder()
                .orderMoney(gross)
                .finalAmount(net)
                .discountAmount(discount)
                .couponIds(couponIds)
                .build();
    }

    private OrderCreationContext baseContext(PlaceType placeType, RewardType rewardType, PaymentInfo paymentInfo,
            RandomOrderRequirements randomRequirements, String acceptBy) {
        return OrderCreationContext.builder()
                .orderId(UUID.randomUUID().toString())
                .orderNo("NO-" + System.nanoTime())
                .orderStatus(OrderStatus.PENDING)
                .orderType(OrderType.NORMAL)
                .placeType(placeType)
                .rewardType(rewardType)
                .isFirstOrder(true)
                .commodityInfo(CommodityInfo.builder()
                        .commodityId("commodity-" + placeType.getCode())
                        .commodityType(CommodityType.SERVICE)
                        .commodityPrice(BigDecimal.TEN)
                        .serviceDuration("60")
                        .commodityName("服务")
                        .commodityNumber("1")
                        .build())
                .paymentInfo(paymentInfo)
                .purchaserBy("customer-" + placeType.getCode())
                .acceptBy(acceptBy)
                .randomOrderRequirements(randomRequirements)
                .build();
    }

    private OrderPlacementCommand.PricingInput pricing(BigDecimal unitPrice, int quantity, List<String> couponIds,
            String commodityId, PlaceType placeType) {
        return OrderPlacementCommand.PricingInput.builder()
                .unitPrice(unitPrice)
                .quantity(quantity)
                .couponIds(couponIds)
                .commodityId(commodityId)
                .placeType(placeType)
                .build();
    }

    private OrderPlacementCommand command(OrderCreationContext context,
            OrderPlacementCommand.PricingInput pricing,
            boolean deduct,
            String action,
            PlayCustomUserInfoEntity snapshot) {
        OrderPlacementCommand.OrderPlacementCommandBuilder builder = OrderPlacementCommand.builder()
                .orderContext(context)
                .deductBalance(deduct)
                .balanceOperationAction(action);
        if (pricing != null) {
            builder.pricingInput(pricing);
        }
        return builder.build();
    }

    private void stubCoupon(String detailId, String masterId, String discountType, BigDecimal discountAmount,
            String reason) {
        PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
        detail.setCouponId(masterId);
        when(playCouponDetailsService.getById(detailId)).thenReturn(detail);

        PlayCouponInfoEntity info = new PlayCouponInfoEntity();
        info.setDiscountType(discountType);
        info.setDiscountAmount(discountAmount);
        when(playCouponInfoService.selectPlayCouponInfoById(masterId)).thenReturn(info);
        when(playCouponInfoService.getCouponReasonForUnavailableUse(
                eq(info), anyString(), anyString(), anyInt(), any())).thenReturn(reason);
        when(playCouponInfoService.getCouponDetailRestrictionReason(eq(info), any(), any()))
                .thenReturn(Optional.empty());
    }

    private PlayCustomUserInfoEntity customer(String id, BigDecimal balance) {
        PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
        entity.setId(id);
        entity.setAccountBalance(balance.setScale(2));
        return entity;
    }

    private void assertMoneyEquals(String expected, BigDecimal actual) {
        assertEquals(0, actual.compareTo(new BigDecimal(expected)));
    }

    private void mockEarningsCounts(long... counts) {
        LambdaQueryChainWrapper<EarningsLineEntity> chain = Mockito.mock(LambdaQueryChainWrapper.class);
        when(chain.eq(any(), any())).thenReturn(chain);
        if (counts.length == 0) {
            when(chain.count()).thenReturn(0L);
        } else {
            org.mockito.stubbing.OngoingStubbing<Long> stubbing = when(chain.count()).thenReturn(counts[0]);
            for (int i = 1; i < counts.length; i++) {
                stubbing = stubbing.thenReturn(counts[i]);
            }
        }
        when(earningsService.lambdaQuery()).thenReturn(chain);
    }
}
