Compare commits

..

6 Commits

Author SHA1 Message Date
irving
49867a30dd fix: stabilize order api tests
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-11 22:22:48 -05:00
irving
51c4a5438d feat: improve wechat order query coverage 2025-11-11 20:48:20 -05:00
irving
e616dd6a13 WIP 2025-11-10 23:42:00 -05:00
irving
ed0edf584a Merge branch 'feat/performance-filtering' 2025-11-10 22:39:31 -05:00
irving
b9250566fb test: cover clerk performance date ranges 2025-11-10 22:33:27 -05:00
irving
984e33bd94 add back up dev db script 2025-11-10 21:17:13 -05:00
24 changed files with 1331 additions and 9 deletions

18
backup-dev-db.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
DB_HOST="primary"
DB_PORT="3306"
DB_NAME="play-with"
DB_USER="root"
DB_PASSWORD="123456"
stamp="$(date +%F)"
backup_dir="yunpei/backup/dev/${stamp}"
mkdir -p "${backup_dir}"
echo "[backup] dumping ${DB_NAME} from ${DB_HOST}:${DB_PORT} -> ${backup_dir}/dev.sql.gz"
mysqldump -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
| gzip > "${backup_dir}/dev.sql.gz"
echo "[backup] done"

View File

@@ -7,6 +7,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
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.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
@@ -106,6 +107,26 @@ public class PlayOrderInfoController {
return R.ok("退款成功");
}
@ApiOperation(value = "撤销已完成订单", notes = "管理员操作撤销,支持可选退款与收益处理")
@PostMapping("/revokeCompleted")
public R revokeCompleted(@Validated @RequestBody PlayOrderRevocationVo vo) {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(vo.getOrderId());
context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason());
OrderRevocationContext.EarningsAdjustStrategy strategy = vo.getEarningsStrategy() != null
? vo.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
context.setEarningsStrategy(strategy);
context.setCounterClerkId(vo.getCounterClerkId());
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
orderLifecycleService.revokeCompletedOrder(context);
return R.ok("撤销成功");
}
/**
* 管理后台强制取消进行中订单
*/

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationBalanceListener {
private final IPlayCustomUserInfoService customUserInfoService;
public OrderRevocationBalanceListener(IPlayCustomUserInfoService customUserInfoService) {
this.customUserInfoService = customUserInfoService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
if (!event.getContext().isRefundToCustomer()) {
return;
}
BigDecimal refundAmount = Optional.ofNullable(event.getContext().getRefundAmount()).orElse(BigDecimal.ZERO);
if (refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
PlayCustomUserInfoEntity customer = customUserInfoService.getById(order.getPurchaserBy());
if (customer == null) {
throw new CustomException("顾客信息不存在");
}
BigDecimal currentBalance = Optional.ofNullable(customer.getAccountBalance()).orElse(BigDecimal.ZERO);
customUserInfoService.updateAccountBalanceById(
customer.getId(),
currentBalance,
currentBalance.add(refundAmount),
BalanceOperationType.REFUND.getCode(),
"已完成订单撤销退款",
refundAmount,
BigDecimal.ZERO,
order.getId());
}
}

View File

@@ -0,0 +1,56 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationEarningsListener {
private final IEarningsService earningsService;
public OrderRevocationEarningsListener(IEarningsService earningsService) {
this.earningsService = earningsService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
OrderRevocationContext context = event.getContext();
switch (context.getEarningsStrategy()) {
case NONE:
return;
case REVERSE_CLERK:
earningsService.reverseByOrder(event.getOrderSnapshot().getId(), context.getOperatorId());
return;
case COUNTER_TO_PEIPEI:
createCounterLine(event);
return;
default:
throw new CustomException("未知的收益处理策略");
}
}
private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext();
PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = context.getCounterClerkId();
if (targetClerkId == null) {
throw new CustomException("需要指定收益冲销目标账号");
}
BigDecimal amount = context.getRefundAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO);
}
earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId());
}
}

View File

@@ -19,7 +19,8 @@ public class OrderConstant {
ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"),
CANCELLED("4", "已取消");
CANCELLED("4", "已取消"),
REVOKED("5", "已撤销");
private final String code;
private final String description;

View File

@@ -0,0 +1,56 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.lang.Nullable;
@Data
public class OrderRevocationContext {
@NotBlank
private String orderId;
@Nullable
private String operatorId;
@Nullable
private String operatorType;
@Nullable
private BigDecimal refundAmount;
@Nullable
private String refundReason;
private boolean refundToCustomer;
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE;
@Nullable
private String counterClerkId;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource;
return this;
}
public enum EarningsAdjustStrategy {
NONE("NO_ADJUST"),
REVERSE_CLERK("REV_CLERK"),
COUNTER_TO_PEIPEI("CTR_PEIPEI");
private final String logCode;
EarningsAdjustStrategy(String logCode) {
this.logCode = logCode;
}
public String getLogCode() {
return logCode;
}
}
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.order.module.event;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import lombok.Getter;
@Getter
public class OrderRevocationEvent {
private final OrderRevocationContext context;
private final PlayOrderInfoEntity orderSnapshot;
public OrderRevocationEvent(OrderRevocationContext context, PlayOrderInfoEntity orderSnapshot) {
this.context = context;
this.orderSnapshot = orderSnapshot;
}
}

View File

@@ -0,0 +1,32 @@
package com.starry.admin.modules.order.module.vo;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
@ApiModel(value = "订单撤销参数", description = "撤销已完成订单的请求参数")
public class PlayOrderRevocationVo {
@NotBlank(message = "订单ID不能为空")
@ApiModelProperty(value = "订单ID", required = true)
private String orderId;
@ApiModelProperty(value = "是否退还顾客余额")
private boolean refundToCustomer;
@ApiModelProperty(value = "退款金额,未填写则默认订单实付金额")
private BigDecimal refundAmount;
@ApiModelProperty(value = "撤销原因")
private String refundReason;
@ApiModelProperty(value = "收益处理策略NONE/REVERSE_CLERK/COUNTER_TO_PEIPEI")
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE;
@ApiModelProperty(value = "收益冲销目标账号ID策略为 COUNTER_TO_PEIPEI 时必填")
private String counterClerkId;
}

View File

@@ -5,6 +5,7 @@ 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.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService {
@@ -14,4 +15,6 @@ public interface IOrderLifecycleService {
void completeOrder(String orderId, OrderCompletionContext context);
void refundOrder(OrderRefundContext context);
void revokeCompletedOrder(OrderRevocationContext context);
}

View File

@@ -199,6 +199,8 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
*/
List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId);
void revokeCompletedOrder(OrderRevocationContext context);
/**
* 修改订单状态为接单 只有管理员或者店员本人才能操作
*

View File

@@ -34,10 +34,12 @@ 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.entity.PlayOrderLogInfoEntity;
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.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
@@ -61,9 +63,11 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -77,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private enum LifecycleOperation {
CREATE,
COMPLETE,
REFUND
REFUND,
REVOKE_COMPLETED
}
@Resource
@@ -110,6 +115,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct
@@ -520,7 +528,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~");
}
if (isBalancePaidOrder(order) && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
@@ -603,6 +612,107 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单ID不能为空");
}
PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId());
if (order == null) {
throw new CustomException("订单不存在");
}
if (OrderStatus.REVOKED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("订单已撤销");
}
if (!OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("当前状态无法撤销");
}
OrderRevocationContext.EarningsAdjustStrategy strategy = context.getEarningsStrategy() != null
? context.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
if (strategy != OrderRevocationContext.EarningsAdjustStrategy.NONE && earningsService.hasLockedLines(order.getId())) {
throw new CustomException("收益已提现或处理中,无法撤销");
}
String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode();
context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
context.setOperatorId(operatorId);
BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal refundAmount = context.getRefundAmount();
if (refundAmount == null) {
refundAmount = context.isRefundToCustomer() ? finalAmount : BigDecimal.ZERO;
}
if (refundAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomException("退款金额不能小于0");
}
if (refundAmount.compareTo(finalAmount) > 0) {
throw new CustomException("退款金额不能大于支付金额");
}
context.setRefundAmount(refundAmount);
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
}
UpdateWrapper<PlayOrderInfoEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", order.getId())
.eq("order_status", OrderStatus.COMPLETED.getCode())
.set("order_status", OrderStatus.REVOKED.getCode())
.set("order_cancel_time", LocalDateTime.now())
.set("refund_amount", refundAmount)
.set("refund_reason", context.getRefundReason());
if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
updateWrapper.set("refund_type", OrderRefundFlag.REFUNDED.getCode());
}
boolean updated = orderInfoMapper.update(null, updateWrapper) > 0;
PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId());
if (!updated && (latest == null || !OrderStatus.REVOKED.getCode().equals(latest.getOrderStatus()))) {
throw new CustomException("订单状态已变化,无法撤销");
}
if (latest == null) {
latest = order;
latest.setOrderStatus(OrderStatus.REVOKED.getCode());
}
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
OrderRefundRecordType recordType = finalAmount.compareTo(refundAmount) == 0
? OrderRefundRecordType.FULL
: OrderRefundRecordType.PARTIAL;
orderRefundInfoService.add(
latest.getId(),
latest.getPurchaserBy(),
latest.getAcceptBy(),
latest.getPayMethod(),
recordType.getCode(),
refundAmount,
context.getRefundReason(),
context.getOperatorType(),
context.getOperatorId(),
OrderRefundState.PROCESSING.getCode(),
ReviewRequirement.NOT_REQUIRED.getCode());
}
OrderActor actor = resolveCompletionActor(context.getOperatorType());
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
strategy != null
? strategy.getLogCode()
: OrderRevocationContext.EarningsAdjustStrategy.NONE.getLogCode());
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);
applicationEventPublisher.publishEvent(new OrderRevocationEvent(context, latest));
}
private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) {
throw new CustomException("订单创建请求不能为空");

View File

@@ -547,6 +547,11 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class));
if (StringUtils.isBlank(vo.getOrderType())) {
lambdaQueryWrapper.notIn(PlayOrderInfoEntity::getOrderType,
OrderConstant.OrderType.RECHARGE.getCode(),
OrderConstant.OrderType.WITHDRAWAL.getCode());
}
IPage<PlayCustomOrderListReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper);
// 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构
@@ -943,6 +948,14 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
notificationSender.sendOrderCancelMessageAsync(latest, refundReason);
}
@Override
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单信息缺失");
}
orderLifecycleService.revokeCompletedOrder(context);
}
@Override
public PlayOrderInfoEntity queryByOrderNo(String orderNo) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderDetailsReturnVo {
private String orderNo;
/**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消
* 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/
private String orderStatus;

View File

@@ -16,14 +16,15 @@ public class PlayCustomOrderInfoQueryVo extends BasePageEntity {
private String id;
/**
* 订单状态【0:1:2:3:4】 0已下单 1已接单 2已开始 3已完成 4已取消
* 订单状态【0:1:2:3:4:5
* 0已下单 1已接单 2已开始 3已完成 4已取消 5已撤销
*/
private String orderStatus;
/**
* 订单类型【0充值订单1提现订单2普通订单】
* 订单类型(为空时默认排除充值/提现)
*/
private String orderType = "2";
private String orderType;
/**
* 下单类型0指定单1随机单。2打赏单

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderListReturnVo {
private String orderNo;
/**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消
* 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/
private String orderStatus;

View File

@@ -17,4 +17,10 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now);
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
void reverseByOrder(String orderId, String operatorId);
void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
boolean hasLockedLines(String orderId);
}

View File

@@ -1,6 +1,8 @@
package com.starry.admin.modules.withdraw.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
@@ -12,6 +14,7 @@ import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -90,4 +93,49 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
if (acc.compareTo(amount) < 0) return new ArrayList<>();
return picked;
}
@Override
public void reverseByOrder(String orderId, String operatorId) {
if (StrUtil.isBlank(orderId)) {
return;
}
this.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("available", "frozen"))
.set(EarningsLineEntity::getStatus, "reversed"));
}
@Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
throw new IllegalArgumentException("创建冲销收益时参数缺失");
}
BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.abs();
if (normalized.compareTo(BigDecimal.ZERO) == 0) {
return;
}
EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid());
line.setOrderId(orderId);
line.setTenantId(tenantId);
line.setClerkId(targetClerkId);
line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ORDER);
line.setStatus("available");
line.setUnlockTime(LocalDateTime.now());
this.save(line);
}
@Override
public boolean hasLockedLines(String orderId) {
if (StrUtil.isBlank(orderId)) {
return false;
}
Long count = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("withdrawing", "withdrawn"))
.count();
return count != null && count > 0;
}
}

View File

@@ -227,7 +227,7 @@ class BlindBoxPoolControllerApiTest extends AbstractApiTest {
private PlayGiftInfoEntity seedGift(String name) {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-admin-" + IdUtils.getUuid().substring(0, 12));
gift.setId("gift-admin-" + IdUtils.getUuid());
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setName(name);

View File

@@ -33,6 +33,7 @@ import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {

View File

@@ -0,0 +1,529 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService;
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
@Autowired
private IPlayOrderEvaluateInfoService playOrderEvaluateInfoService;
@Autowired
private IPlayOrderComplaintInfoService playOrderComplaintInfoService;
private final List<String> orderIdsToCleanup = new ArrayList<>();
private final List<String> evalIdsToCleanup = new ArrayList<>();
private final List<String> complaintIdsToCleanup = new ArrayList<>();
@AfterEach
void cleanUpOrders() {
if (!orderIdsToCleanup.isEmpty()) {
playOrderInfoService.removeByIds(orderIdsToCleanup);
orderIdsToCleanup.clear();
}
if (!evalIdsToCleanup.isEmpty()) {
playOrderEvaluateInfoService.removeByIds(evalIdsToCleanup);
evalIdsToCleanup.clear();
}
if (!complaintIdsToCleanup.isEmpty()) {
playOrderComplaintInfoService.removeByIds(complaintIdsToCleanup);
complaintIdsToCleanup.clear();
}
}
@Test
void queryByPageFiltersRevokedOrdersAndDetailShowsReason() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String remark = "revoked-flow-" + IdUtils.getUuid();
placeRandomOrder(remark, customerToken);
ensureTenantContext();
PlayOrderInfoEntity createdOrder = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getRemark, remark)
.orderByDesc(PlayOrderInfoEntity::getCreatedTime)
.last("limit 1")
.one();
assertThat(createdOrder).as("Order with remark %s should exist", remark).isNotNull();
String orderId = createdOrder.getId();
ensureTenantContext();
playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.GIFT.getCode())
.eq(PlayOrderInfoEntity::getId, orderId)
.update();
ensureTenantContext();
playOrderInfoService.updateStateTo1(
OrderConstant.OperatorType.CLERK.getCode(),
ApiTestDataSeeder.DEFAULT_CLERK_ID,
ApiTestDataSeeder.DEFAULT_CLERK_ID,
orderId);
ensureTenantContext();
playOrderInfoService.updateStateTo23(
OrderConstant.OperatorType.CLERK.getCode(),
ApiTestDataSeeder.DEFAULT_CLERK_ID,
OrderConstant.OrderStatus.IN_PROGRESS.getCode(),
orderId);
ensureTenantContext();
playOrderInfoService.updateStateTo23(
OrderConstant.OperatorType.ADMIN.getCode(),
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
OrderConstant.OrderStatus.COMPLETED.getCode(),
orderId);
String revokeReason = "auto-revoke-" + IdUtils.getUuid();
String revokePayload = "{" +
"\"orderId\":\"" + orderId + "\"," +
"\"refundToCustomer\":false," +
"\"refundReason\":\"" + revokeReason + "\"," +
"\"earningsStrategy\":\"NONE\"" +
"}";
mockMvc.perform(post("/order/order/revokeCompleted")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(revokePayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ObjectNode filterPayload = basePayload(1, 10);
filterPayload.put("orderStatus", OrderConstant.OrderStatus.REVOKED.getCode());
JsonNode listRoot = executeOrderQuery(customerToken, filterPayload);
JsonNode dataNode = listRoot.path("data");
JsonNode records = dataNode.isArray() ? dataNode : dataNode.path("records");
assertThat(records.isArray()).as("List response should contain records array").isTrue();
assertThat(records.size()).as("Should return at least one revoked order").isGreaterThan(0);
boolean found = false;
for (JsonNode node : records) {
assertThat(node.path("orderStatus").asText()).isEqualTo("5");
if (orderId.equals(node.path("id").asText())) {
found = true;
}
}
assertThat(found).as("Revoked order should be present in filter result").isTrue();
MvcResult detailResult = mockMvc.perform(get("/wx/custom/order/queryById")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.param("id", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode detailRoot = objectMapper.readTree(detailResult.getResponse().getContentAsString());
JsonNode detail = detailRoot.path("data");
assertThat(detail.path("orderStatus").asText()).isEqualTo("5");
assertThat(detail.path("refundReason").asText()).isEqualTo(revokeReason);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageSkipsRechargeOrdersByDefaultButAllowsExplicitFilter() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String rechargeRemark = "recharge-like-" + IdUtils.getUuid();
LocalDateTime now = LocalDateTime.now().minusMinutes(20);
PlayOrderInfoEntity rechargeOrder = persistOrder(now, order -> {
order.setRemark(rechargeRemark);
order.setOrderType(OrderConstant.OrderType.RECHARGE.getCode());
});
String giftRemark = "gift-like-" + IdUtils.getUuid();
PlayOrderInfoEntity giftOrder = persistOrder(now.plusMinutes(5), order -> {
order.setRemark(giftRemark);
order.setOrderType(OrderConstant.OrderType.GIFT.getCode());
});
ObjectNode defaultPayload = basePayload(1, 20);
JsonNode defaultRecords = queryOrders(customerToken, defaultPayload);
assertThat(defaultRecords.size()).isGreaterThan(0);
assertThat(defaultRecords).noneMatch(node -> rechargeOrder.getId().equals(node.path("id").asText()));
ObjectNode explicitPayload = basePayload(1, 20);
explicitPayload.put("orderType", OrderConstant.OrderType.GIFT.getCode());
JsonNode filteredRecords = queryOrders(customerToken, explicitPayload);
assertThat(filteredRecords)
.anyMatch(node -> giftOrder.getId().equals(node.path("id").asText()));
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().minusMinutes(30);
PlayOrderInfoEntity own = persistOrder(base, order -> order.setOrderNo("OWN-" + IdUtils.getUuid().substring(0, 6)));
PlayOrderInfoEntity foreign = persistOrder(base.plusMinutes(5), order -> {
order.setPurchaserBy("other-customer");
order.setOrderNo("FOREIGN-" + IdUtils.getUuid().substring(0, 6));
});
ObjectNode payload = basePayload(1, 20);
JsonNode records = queryOrders(token, payload);
List<String> ids = new ArrayList<>();
records.forEach(node -> ids.add(node.path("id").asText()));
assertThat(ids).contains(own.getId());
assertThat(ids).doesNotContain(foreign.getId());
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageSupportsPagingMeta() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().plusHours(2);
String pageMarker = "PAGE-" + IdUtils.getUuid().substring(0, 4);
String pageGroup = "group-" + pageMarker;
PlayOrderInfoEntity first = persistOrder(base, order -> {
order.setOrderNo(pageMarker + "A");
order.setGroupId(pageGroup);
});
PlayOrderInfoEntity second = persistOrder(base.plusMinutes(2), order -> {
order.setOrderNo(pageMarker + "B");
order.setGroupId(pageGroup);
});
PlayOrderInfoEntity third = persistOrder(base.plusMinutes(4), order -> {
order.setOrderNo(pageMarker + "C");
order.setGroupId(pageGroup);
});
ArrayNode purchaserWindow = range(base.minusMinutes(1), base.plusMinutes(5));
ObjectNode pageOne = basePayload(1, 2);
pageOne.set("purchaserTime", purchaserWindow);
pageOne.put("orderNo", pageMarker);
pageOne.put("groupId", pageGroup);
JsonNode rootPageOne = executeOrderQuery(token, pageOne);
JsonNode recordsOne = recordsFromRoot(rootPageOne);
assertThat(recordsOne.size()).isEqualTo(2);
assertThat(rootPageOne.path("pageInfo").path("pageSize").asInt()).isEqualTo(2);
assertThat(rootPageOne.path("pageInfo").path("currentPage").asInt()).isEqualTo(1);
ObjectNode pageTwo = basePayload(2, 2);
pageTwo.set("purchaserTime", purchaserWindow);
pageTwo.put("orderNo", pageMarker);
pageTwo.put("groupId", pageGroup);
JsonNode rootPageTwo = executeOrderQuery(token, pageTwo);
JsonNode recordsTwo = recordsFromRoot(rootPageTwo);
assertThat(recordsTwo.size()).isGreaterThan(0);
assertThat(rootPageTwo.path("pageInfo").path("pageSize").asInt()).isEqualTo(2);
assertThat(rootPageTwo.path("pageInfo").path("currentPage").asInt()).isEqualTo(2);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageFiltersByOrderStatus() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
PlayOrderInfoEntity pending = persistOrder(LocalDateTime.now().minusMinutes(50),
order -> order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode()));
PlayOrderInfoEntity completed = persistOrder(LocalDateTime.now().minusMinutes(40),
order -> order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode()));
PlayOrderInfoEntity revoked = persistOrder(LocalDateTime.now().minusMinutes(30),
order -> order.setOrderStatus(OrderConstant.OrderStatus.REVOKED.getCode()));
ObjectNode payload = basePayload(1, 10);
payload.put("orderStatus", OrderConstant.OrderStatus.REVOKED.getCode());
JsonNode records = queryOrders(token, payload);
assertThat(records.size()).isGreaterThan(0);
records.forEach(node -> assertThat(node.path("orderStatus").asText())
.isEqualTo(OrderConstant.OrderStatus.REVOKED.getCode()));
assertThat(findById(records, revoked.getId())).isNotNull();
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageFiltersByPlaceTypeAndCompositeCriteria() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().minusMinutes(90);
PlayOrderInfoEntity target = persistOrder(base, order -> {
order.setOrderStatus(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
order.setPlaceType(OrderConstant.PlaceType.SPECIFIED.getCode());
order.setOrderNo("FOCUS-" + IdUtils.getUuid().substring(0, 4));
order.setUseCoupon("1");
order.setBackendEntry("1");
order.setFirstOrder("1");
order.setGroupId("group-focus");
order.setSex("1");
order.setPurchaserTime(base.plusMinutes(5));
order.setAcceptTime(base.plusMinutes(10));
order.setOrderEndTime(base.plusMinutes(50));
});
persistOrder(base.plusMinutes(5), order -> {
order.setOrderStatus(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
order.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
order.setUseCoupon("0");
order.setBackendEntry("0");
order.setFirstOrder("0");
order.setGroupId("group-noise");
order.setSex("2");
});
ObjectNode payload = basePayload(1, 10);
payload.put("placeType", OrderConstant.PlaceType.SPECIFIED.getCode());
payload.put("orderNo", target.getOrderNo().substring(0, 6));
payload.put("useCoupon", "1");
payload.put("backendEntry", "1");
payload.put("firstOrder", "1");
payload.put("groupId", "group-focus");
payload.put("sex", "1");
payload.set("purchaserTime", range(target.getPurchaserTime().minusMinutes(1), target.getPurchaserTime().plusMinutes(1)));
payload.set("acceptTime", range(target.getAcceptTime().minusMinutes(1), target.getAcceptTime().plusMinutes(1)));
payload.set("endOrderTime", range(target.getOrderEndTime().minusMinutes(1), target.getOrderEndTime().plusMinutes(1)));
JsonNode records = queryOrders(token, payload);
assertThat(records.size()).isGreaterThan(0);
JsonNode targetNode = findById(records, target.getId());
assertThat(targetNode).isNotNull();
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageMarksEvaluateAndComplaintFlags() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
PlayOrderInfoEntity evaluated = persistOrder(LocalDateTime.now().minusMinutes(10),
order -> order.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4)));
PlayOrderInfoEntity complained = persistOrder(LocalDateTime.now().minusMinutes(8),
order -> order.setOrderNo("COMP-" + IdUtils.getUuid().substring(0, 4)));
markEvaluated(evaluated.getId());
markComplained(complained.getId());
ObjectNode payload = basePayload(1, 20);
JsonNode records = queryOrders(token, payload);
String evalFlag = findById(records, evaluated.getId()).path("evaluate").asText();
String complaintFlag = findById(records, complained.getId()).path("complaint").asText();
assertThat(evalFlag).isEqualTo("1");
assertThat(complaintFlag).isEqualTo("1");
} finally {
CustomSecurityContextHolder.remove();
}
}
private void placeRandomOrder(String remark, String customerToken) throws Exception {
String payload = "{" +
"\"sex\":\"2\"," +
"\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
"\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
"\"commodityQuantity\":1," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"excludeHistory\":\"0\"," +
"\"couponIds\":[]," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/random")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value("下单成功"));
}
private PlayOrderInfoEntity persistOrder(LocalDateTime baseTime, java.util.function.Consumer<PlayOrderInfoEntity> customizer) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-" + IdUtils.getUuid());
order.setOrderNo("WXQ-" + IdUtils.getUuid().substring(0, 8));
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode());
order.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
order.setPlaceType(OrderConstant.PlaceType.SPECIFIED.getCode());
order.setRewardType("0");
order.setFirstOrder("0");
order.setRefundType("0");
order.setRefundAmount(BigDecimal.ZERO);
order.setOrderMoney(new BigDecimal("99.00"));
order.setFinalAmount(new BigDecimal("99.00"));
order.setDiscountAmount(BigDecimal.ZERO);
order.setEstimatedRevenue(new BigDecimal("40.00"));
order.setEstimatedRevenueRatio(40);
order.setUseCoupon("0");
order.setBackendEntry("0");
order.setCouponIds(java.util.Collections.emptyList());
order.setPaymentSource("balance");
order.setPayMethod("0");
order.setPayState("1");
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setPurchaserTime(baseTime);
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
order.setAcceptTime(baseTime.plusMinutes(5));
order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
order.setOrderStartTime(baseTime.plusMinutes(10));
order.setOrderEndTime(baseTime.plusMinutes(40));
order.setOrdersExpiredState("0");
order.setOrderSettlementState("0");
order.setSex("2");
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setCommodityType("1");
order.setCommodityPrice(new BigDecimal("99.00"));
order.setCommodityName("Weixin Order");
order.setServiceDuration("60min");
order.setCommodityNumber("1");
order.setRemark("auto");
order.setBackendRemark("auto");
Date createdDate = toDate(baseTime);
order.setCreatedTime(createdDate);
order.setUpdatedTime(createdDate);
order.setCreatedBy("wx-test");
order.setUpdatedBy("wx-test");
customizer.accept(order);
ensureTenantContext();
playOrderInfoService.save(order);
orderIdsToCleanup.add(order.getId());
return order;
}
private ObjectNode basePayload(int pageNum, int pageSize) {
ObjectNode node = objectMapper.createObjectNode();
node.put("pageNum", pageNum);
node.put("pageSize", pageSize);
return node;
}
private ArrayNode range(LocalDateTime start, LocalDateTime end) {
ArrayNode node = objectMapper.createArrayNode();
node.add(DATE_TIME_FORMATTER.format(start));
node.add(DATE_TIME_FORMATTER.format(end));
return node;
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private String ensureCustomerToken() {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
return customerToken;
}
private void markEvaluated(String orderId) {
PlayOrderEvaluateInfoEntity entity = new PlayOrderEvaluateInfoEntity();
entity.setId("eval-" + IdUtils.getUuid());
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOrderId(orderId);
entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
entity.setAnonymous("0");
entity.setEvaluateType("0");
entity.setEvaluateLevel(5);
entity.setEvaluateCon("Great job");
entity.setEvaluateTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
entity.setHidden("0");
ensureTenantContext();
playOrderEvaluateInfoService.save(entity);
evalIdsToCleanup.add(entity.getId());
}
private void markComplained(String orderId) {
PlayOrderComplaintInfoEntity entity = new PlayOrderComplaintInfoEntity();
entity.setId("complaint-" + IdUtils.getUuid());
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOrderId(orderId);
entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
entity.setComplaintCon("Need assistance");
entity.setComplaintTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
entity.setHidden("0");
ensureTenantContext();
playOrderComplaintInfoService.save(entity);
complaintIdsToCleanup.add(entity.getId());
}
private JsonNode findById(JsonNode records, String id) {
for (JsonNode node : records) {
if (id.equals(node.path("id").asText())) {
return node;
}
}
throw new AssertionError("Record with id " + id + " not found in response");
}
private JsonNode queryOrders(String customerToken, ObjectNode payload) throws Exception {
JsonNode root = executeOrderQuery(customerToken, payload);
JsonNode dataNode = root.path("data");
return dataNode.isArray() ? dataNode : dataNode.path("records");
}
private JsonNode recordsFromRoot(JsonNode root) {
JsonNode dataNode = root.path("data");
return dataNode.isArray() ? dataNode : dataNode.path("records");
}
private JsonNode executeOrderQuery(String customerToken, ObjectNode payload) throws Exception {
MvcResult result = mockMvc.perform(post("/wx/custom/order/queryByPage")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString());
}
}

View File

@@ -0,0 +1,80 @@
package com.starry.admin.modules.order.listener;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderRevocationEarningsListenerTest {
@Mock
private IEarningsService earningsService;
private OrderRevocationEarningsListener listener;
@BeforeEach
void setUp() {
listener = new OrderRevocationEarningsListener(earningsService);
}
@Test
void handle_counterStrategyFallsBackToEstimatedRevenueWhenRefundAmountZero() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-counter-1");
context.setOperatorId("admin-op");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI);
context.setRefundAmount(BigDecimal.ZERO);
context.setCounterClerkId("ops-clerk");
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-counter-1");
order.setTenantId("tenant-77");
order.setEstimatedRevenue(BigDecimal.valueOf(68));
listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService)
.createCounterLine(order.getId(), order.getTenantId(), "ops-clerk", BigDecimal.valueOf(68), "admin-op");
}
@Test
void handle_reverseStrategyRevertsAvailableLines() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-2");
context.setOperatorId("admin-reviewer");
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-reverse-2");
listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService).reverseByOrder("order-reverse-2", "admin-reviewer");
}
@Test
void handle_noneStrategyIsNoOp() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-none-3");
context.setOperatorId("admin-noop");
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-none-3");
listener.handle(new OrderRevocationEvent(context, order));
verifyNoInteractions(earningsService);
}
}

View File

@@ -40,9 +40,12 @@ 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.OrderRevocationContext.EarningsAdjustStrategy;
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;
@@ -65,10 +68,12 @@ 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 {
@@ -106,11 +111,99 @@ class OrderLifecycleServiceImplTest {
@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(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
.thenReturn(true);
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.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
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());
assertEquals(EarningsAdjustStrategy.REVERSE_CLERK, event.getContext().getEarningsStrategy());
assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount());
}
@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.setEarningsStrategy(EarningsAdjustStrategy.NONE);
lifecycleService.revokeCompletedOrder(context);
verify(customUserInfoService, never()).getById(anyString());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
}
@Test
void revokeCompletedOrder_blocksWhenEarningsLocked() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed);
when(earningsService.hasLockedLines(orderId)).thenReturn(true);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-locked");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper, never()).update(isNull(), any());
verify(applicationEventPublisher, never()).publishEvent(any());
}
@Test
void placeOrder_throwsWhenCommandNull() {
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null));
@@ -1223,6 +1316,13 @@ class OrderLifecycleServiceImplTest {
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);

View File

@@ -25,13 +25,17 @@ import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.utils.DateRangeUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Month;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -318,6 +322,106 @@ class PlayClerkPerformanceServiceImplTest {
Arrays.asList(o1.getId(), o2.getId(), o3.getId(), o4.getId(), o5.getId())));
}
@Test
@DisplayName("queryOverview filters completed orders according to complex date/time ranges")
void queryOverviewHonorsDateRangesForMultipleClerks() {
PlayClerkUserInfoEntity alpha = buildClerk("c-alpha", "Alpha", "g-alpha", "l-alpha");
PlayClerkUserInfoEntity beta = buildClerk("c-beta", "Beta", "g-beta", "l-beta");
PlayClerkUserInfoEntity gamma = buildClerk("c-gamma", "Gamma", "g-gamma", "l-gamma");
List<PlayClerkUserInfoEntity> clerks = Arrays.asList(alpha, beta, gamma);
when(playPersonnelGroupInfoService.getValidClerkIdList(any(), any()))
.thenReturn(Arrays.asList(alpha.getId(), beta.getId(), gamma.getId()));
when(clerkUserInfoService.list((Wrapper<PlayClerkUserInfoEntity>) any())).thenReturn(clerks);
when(playClerkLevelInfoService.selectAll()).thenReturn(Arrays.asList(
level(alpha.getLevelId(), "铂金"),
level(beta.getLevelId(), "黄金"),
level(gamma.getLevelId(), "白银")));
when(playPersonnelGroupInfoService.selectAll()).thenReturn(Arrays.asList(
group(alpha.getGroupId(), "一组"),
group(beta.getGroupId(), "二组"),
group(gamma.getGroupId(), "三组")));
Map<String, List<PlayOrderInfoEntity>> ordersByClerk = new HashMap<>();
ordersByClerk.put(alpha.getId(), Arrays.asList(
order(alpha.getId(), "userA1", "1", "0", "0", new BigDecimal("100.00"), new BigDecimal("70.00"),
LocalDateTime.of(2024, Month.JULY, 1, 10, 0)),
order(alpha.getId(), "userA2", "0", "0", "0", new BigDecimal("210.00"), new BigDecimal("120.00"),
LocalDateTime.of(2024, Month.JULY, 3, 15, 30)),
order(alpha.getId(), "userA3", "0", "0", "0", new BigDecimal("150.00"), new BigDecimal("80.00"),
LocalDateTime.of(2024, Month.JULY, 7, 9, 15)),
order(alpha.getId(), "userA4", "0", "0", "0", new BigDecimal("130.00"), new BigDecimal("75.00"),
LocalDateTime.of(2024, Month.JULY, 10, 18, 45))));
ordersByClerk.put(beta.getId(), Arrays.asList(
order(beta.getId(), "userB1", "1", "0", "0", new BigDecimal("95.00"), new BigDecimal("50.00"),
LocalDateTime.of(2024, Month.JULY, 2, 11, 30)),
order(beta.getId(), "userB2", "0", "0", "0", new BigDecimal("120.00"), new BigDecimal("65.00"),
LocalDateTime.of(2024, Month.JULY, 4, 13, 0)),
order(beta.getId(), "userB3", "0", "0", "0", new BigDecimal("85.00"), new BigDecimal("40.00"),
LocalDateTime.of(2024, Month.JULY, 8, 16, 0)),
order(beta.getId(), "userB4", "0", "0", "0", new BigDecimal("150.00"), new BigDecimal("85.00"),
LocalDateTime.of(2024, Month.JULY, 10, 19, 0))));
ordersByClerk.put(gamma.getId(), Arrays.asList(
order(gamma.getId(), "userC1", "1", "0", "0", new BigDecimal("70.00"), new BigDecimal("35.00"),
LocalDateTime.of(2024, Month.JULY, 1, 8, 0)),
order(gamma.getId(), "userC2", "0", "0", "0", new BigDecimal("135.00"), new BigDecimal("70.00"),
LocalDateTime.of(2024, Month.JULY, 5, 17, 20)),
order(gamma.getId(), "userC3", "0", "0", "0", new BigDecimal("75.00"), new BigDecimal("45.00"),
LocalDateTime.of(2024, Month.JULY, 6, 14, 50)),
order(gamma.getId(), "userC4", "0", "0", "0", new BigDecimal("160.00"), new BigDecimal("90.00"),
LocalDateTime.of(2024, Month.JULY, 9, 21, 10))));
when(playOrderInfoService.clerkSelectOrderInfoList(anyString(), anyString(), anyString()))
.thenAnswer(invocation -> {
String clerkId = invocation.getArgument(0);
String startStr = invocation.getArgument(1);
String endStr = invocation.getArgument(2);
LocalDateTime start = LocalDateTime.parse(startStr, DateRangeUtils.DATE_TIME_FORMATTER);
LocalDateTime end = LocalDateTime.parse(endStr, DateRangeUtils.DATE_TIME_FORMATTER);
return ordersByClerk.getOrDefault(clerkId, Collections.emptyList()).stream()
.filter(order -> !order.getPurchaserTime().isBefore(start)
&& !order.getPurchaserTime().isAfter(end))
.collect(Collectors.toList());
});
setAuthentication();
try {
ClerkPerformanceOverviewResponseVo fullRange =
service.queryOverview(buildOverviewVo("2024-07-01", "2024-07-10"));
ClerkPerformanceOverviewSummaryVo fullSummary = fullRange.getSummary();
assertEquals(new BigDecimal("1480.00"), fullSummary.getTotalGmv());
assertEquals(12, fullSummary.getTotalOrderCount());
assertEquals(new BigDecimal("590.00"), snapshotFor(fullRange.getRankings(), alpha.getId()).getGmv());
assertEquals(new BigDecimal("450.00"), snapshotFor(fullRange.getRankings(), beta.getId()).getGmv());
assertEquals(new BigDecimal("440.00"), snapshotFor(fullRange.getRankings(), gamma.getId()).getGmv());
ClerkPerformanceOverviewResponseVo midRange =
service.queryOverview(buildOverviewVo("2024-07-04", "2024-07-08"));
ClerkPerformanceOverviewSummaryVo midSummary = midRange.getSummary();
assertEquals(5, midSummary.getTotalOrderCount());
assertEquals(new BigDecimal("565.00"), midSummary.getTotalGmv());
List<ClerkPerformanceSnapshotVo> midRankings = midRange.getRankings();
assertEquals("c-gamma", midRankings.get(0).getClerkId());
assertEquals(new BigDecimal("210.00"), midRankings.get(0).getGmv());
assertEquals("c-beta", midRankings.get(1).getClerkId());
assertEquals(new BigDecimal("205.00"), midRankings.get(1).getGmv());
assertEquals("c-alpha", midRankings.get(2).getClerkId());
assertEquals(new BigDecimal("150.00"), midRankings.get(2).getGmv());
ClerkPerformanceOverviewResponseVo shortRange = service.queryOverview(
buildOverviewVo("2024-07-10 18:30:00", "2024-07-10 18:59:59"));
ClerkPerformanceOverviewSummaryVo shortSummary = shortRange.getSummary();
assertEquals(1, shortSummary.getTotalOrderCount());
assertEquals(new BigDecimal("130.00"), shortSummary.getTotalGmv());
ClerkPerformanceSnapshotVo alphaSnapshot = snapshotFor(shortRange.getRankings(), alpha.getId());
assertEquals(new BigDecimal("130.00"), alphaSnapshot.getGmv());
assertEquals(1, alphaSnapshot.getOrderCount());
assertEquals(BigDecimal.ZERO, snapshotFor(shortRange.getRankings(), beta.getId()).getGmv());
assertEquals(BigDecimal.ZERO, snapshotFor(shortRange.getRankings(), gamma.getId()).getGmv());
} finally {
clearAuthentication();
}
}
@Test
@DisplayName("getClerkPerformanceInfo should ignore non-completed orders when aggregating GMV")
void getClerkPerformanceInfoSkipsNonCompletedOrders() {
PlayClerkUserInfoEntity clerk = buildClerk("c9", "Nine", "gX", "lX");
@@ -348,6 +452,22 @@ class PlayClerkPerformanceServiceImplTest {
assertTrue(idCaptor.getValue().contains(completed.getId()));
}
private ClerkPerformanceOverviewQueryVo buildOverviewVo(String start, String end) {
ClerkPerformanceOverviewQueryVo vo = new ClerkPerformanceOverviewQueryVo();
vo.setEndOrderTime(Arrays.asList(start, end));
vo.setIncludeSummary(true);
vo.setIncludeRankings(true);
vo.setLimit(10);
return vo;
}
private ClerkPerformanceSnapshotVo snapshotFor(List<ClerkPerformanceSnapshotVo> snapshots, String clerkId) {
return snapshots.stream()
.filter(snapshot -> clerkId.equals(snapshot.getClerkId()))
.findFirst()
.orElseThrow(() -> new AssertionError("Snapshot not found for clerk " + clerkId));
}
private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) {
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(id);

View File

@@ -12,6 +12,8 @@ import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -68,4 +70,54 @@ class EarningsServiceImplTest {
verify(baseMapper, never()).insert(any());
}
@Test
void createCounterLine_persistsNegativeAvailableLine() {
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals(new BigDecimal("-88"), saved.getAmount());
assertEquals("clerk-c", saved.getClerkId());
assertEquals("available", saved.getStatus());
}
@Test
void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() {
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg", new BigDecimal("-60")),
line("pos", new BigDecimal("40")));
when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines);
List<EarningsLineEntity> picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(30), now);
assertEquals(0, picked.size(), "净额不足时不应允许提现");
}
@Test
void findWithdrawable_allowsWithdrawalAfterPositiveLinesCoverDebt() {
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg", new BigDecimal("-60")),
line("first", new BigDecimal("40")),
line("second", new BigDecimal("150")));
when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines);
List<EarningsLineEntity> picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(70), now);
assertEquals(3, picked.size());
assertEquals("second", picked.get(2).getId());
}
private EarningsLineEntity line(String id, BigDecimal amount) {
EarningsLineEntity entity = new EarningsLineEntity();
entity.setId(id);
entity.setAmount(amount);
entity.setStatus("available");
return entity;
}
}