Compare commits

..

7 Commits

Author SHA1 Message Date
irving
5331fd75a2 tiny fix sorting
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-14 02:00:33 -05:00
irving
5c0de2201c fix: 店員排序穩定&apitest 連線 2025-11-14 01:52:21 -05:00
irving
29f168dd67 add import 2025-11-14 01:29:37 -05:00
irving
48348609a8 fix: 店員列表排序去重 2025-11-14 01:27:29 -05:00
irving
25554bac84 test: 修復店員排序測試與收益扣回即時解鎖 2025-11-14 01:25:06 -05:00
irving
cec5e965f6 feat: 完成撤销收益扣回與限額改動 2025-11-14 00:58:12 -05:00
hucs-dev
4cd2950051 fix: 🚀解决排序问题 2025-11-14 11:32:06 +08:00
19 changed files with 940 additions and 283 deletions

View File

@@ -54,6 +54,7 @@ import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -69,9 +70,7 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30 * @since 2024-03-30
*/ */
@Service @Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
implements
IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员"; private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问"; private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
@@ -131,8 +130,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class); lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId"); lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId); lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper); PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) { if (levelInfo != null) {
@@ -157,8 +155,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 查询店员 * 查询店员
* *
* @param id * @param id 店员主键
* 店员主键
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -173,13 +170,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override @Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) { public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class); PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) { if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream() Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
if (pendingTypes.contains("0")) { if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false); result.setNicknameAllowEdit(false);
} }
@@ -217,12 +210,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
// 查询店员服务项目 // 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>(); List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) { for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
clerkCommodityEntity.getEnablingState()));
} }
result.setCommodity(playClerkCommodityQueryVos); result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity()); result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
@@ -265,10 +256,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isBlank(clerkId)) { if (StrUtil.isBlank(clerkId)) {
return; return;
} }
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class) LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
.eq(PlayClerkUserInfoEntity::getId, clerkId)
.set(PlayClerkUserInfoEntity::getToken, "empty")
.set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper); this.baseMapper.update(null, wrapper);
} }
@@ -286,21 +274,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
@Override @Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
// 修改用户余额 // 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation)); this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录 // 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
} }
/** /**
* 查询店员列表 * 查询店员列表
* *
* @param vo * @param vo 店员查询对象
* 店员查询对象
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -311,12 +295,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的 // 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1"); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段 // 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
"address");
// 等级表 // 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName"); lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
// 服务项目表 // 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) { if (StrUtil.isNotBlank(vo.getNickname())) {
@@ -345,11 +327,28 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
// 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先 // 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END") lambdaQueryWrapper
.orderByAsc(PlayClerkLevelInfoEntity::getOrderNumber) .orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState); .orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END")
.orderByAsc(PlayClerkLevelInfoEntity::getOrderNumber)
.orderByAsc(PlayClerkUserInfoEntity::getCreatedTime)
.orderByAsc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper); IPage<PlayClerkUserInfoResultVo> rawPage = this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
if (rawPage != null && rawPage.getRecords() != null) {
List<PlayClerkUserInfoResultVo> deduped = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (PlayClerkUserInfoResultVo record : rawPage.getRecords()) {
String id = record.getId();
if (id == null || !seen.add(id)) {
continue;
}
deduped.add(record);
}
rawPage.setRecords(deduped);
}
return rawPage;
} }
@Override @Override
@@ -364,8 +363,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) { public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员 // 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname") lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选 // 加入组员的筛选
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null); List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
@@ -377,14 +375,11 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
} }
// 查询店员订单信息 // 查询店员订单信息
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities); lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0"); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
} }
@Override @Override
@@ -483,12 +478,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序 // 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState) lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage( IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) { for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0"); BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -519,10 +511,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) { if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId); lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
.list(lambdaQueryWrapper); customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
} }
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) { for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0"); record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -537,8 +527,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 新增店员 * 新增店员
* *
* @param playClerkUserInfo * @param playClerkUserInfo 店员
* 店员
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -552,16 +541,12 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 修改店员 * 修改店员
* *
* @param playClerkUserInfo * @param playClerkUserInfo 店员
* 店员
* @return 结果 * @return 结果
*/ */
@Override @Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) { public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
&& (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getListingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null; PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) { if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId()); beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
@@ -576,8 +561,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 批量删除店员 * 批量删除店员
* *
* @param ids * @param ids 需要删除的店员主键
* 需要删除的店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -588,8 +572,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 删除店员信息 * 删除店员信息
* *
* @param id * @param id 店员主键
* 店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -603,9 +586,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId); lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
} }
@@ -621,8 +602,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId()); LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo); Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token")); data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) { if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator"); data.fluentPut("role", "operator");
return data; return data;
@@ -632,8 +612,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader"); data.fluentPut("role", "leader");
return data; return data;
} }
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) { if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter"); data.fluentPut("role", "waiter");
return data; return data;
@@ -645,12 +624,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (beforeUpdate == null) { if (beforeUpdate == null) {
return; return;
} }
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
beforeUpdate.getOnboardingState())
|| ListingStatus.transitionedToDelisted(updatedPayload.getListingState(),
beforeUpdate.getListingState())
|| ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(),
beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId()); invalidateClerkSession(beforeUpdate.getId());
} }
} }

View File

@@ -15,6 +15,7 @@ import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.annotation.Log; import com.starry.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
@@ -28,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -59,6 +61,9 @@ public class PlayOrderInfoController {
@Resource @Resource
private IPlayClerkUserInfoService clerkUserInfoService; private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/** /**
* 分页查询订单列表 * 分页查询订单列表
*/ */
@@ -115,11 +120,8 @@ public class PlayOrderInfoController {
context.setRefundToCustomer(vo.isRefundToCustomer()); context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount()); context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason()); context.setRefundReason(vo.getRefundReason());
OrderRevocationContext.EarningsAdjustStrategy strategy = vo.getEarningsStrategy() != null context.setDeductClerkEarnings(vo.isDeductClerkEarnings());
? vo.getEarningsStrategy() context.setEarningsAdjustAmount(vo.getDeductAmount());
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
context.setEarningsStrategy(strategy);
context.setCounterClerkId(vo.getCounterClerkId());
context.setOperatorType(OperatorType.ADMIN.getCode()); context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId()); context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API); context.withTriggerSource(OrderTriggerSource.ADMIN_API);
@@ -127,6 +129,29 @@ public class PlayOrderInfoController {
return R.ok("撤销成功"); return R.ok("撤销成功");
} }
@ApiOperation(value = "撤销限额", notes = "查询指定订单可退金额与可扣回收益")
@GetMapping("/{id}/revocationLimits")
public R getRevocationLimits(@PathVariable("id") String id) {
PlayOrderInfoEntity order = orderInfoService.selectOrderInfoById(id);
BigDecimal maxRefundAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal maxDeductAmount = BigDecimal.ZERO;
if (order.getAcceptBy() != null) {
maxDeductAmount = Optional.ofNullable(earningsService.getRemainingEarningsForOrder(order.getId(), order.getAcceptBy()))
.orElse(BigDecimal.ZERO);
}
if (maxDeductAmount.compareTo(BigDecimal.ZERO) < 0) {
maxDeductAmount = BigDecimal.ZERO;
}
PlayOrderRevocationLimitsVo limitsVo = new PlayOrderRevocationLimitsVo();
limitsVo.setOrderId(order.getId());
limitsVo.setMaxRefundAmount(maxRefundAmount);
limitsVo.setMaxDeductAmount(maxDeductAmount);
limitsVo.setDefaultDeductAmount(maxDeductAmount);
limitsVo.setDeductible(order.getAcceptBy() != null);
return R.ok(limitsVo);
}
/** /**
* 管理后台强制取消进行中订单 * 管理后台强制取消进行中订单
*/ */

View File

@@ -26,30 +26,29 @@ public class OrderRevocationEarningsListener {
return; return;
} }
OrderRevocationContext context = event.getContext(); OrderRevocationContext context = event.getContext();
switch (context.getEarningsStrategy()) { if (!context.isDeductClerkEarnings()) {
case NONE: return;
return;
case REVERSE_CLERK:
earningsService.reverseByOrder(event.getOrderSnapshot().getId(), context.getOperatorId());
return;
case COUNTER_TO_PEIPEI:
createCounterLine(event);
return;
default:
throw new CustomException("未知的收益处理策略");
} }
createCounterLine(event);
} }
private void createCounterLine(OrderRevocationEvent event) { private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext(); OrderRevocationContext context = event.getContext();
if (context == null) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot(); PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = context.getCounterClerkId(); String targetClerkId = order.getAcceptBy();
if (targetClerkId == null) { if (targetClerkId == null || targetClerkId.trim().isEmpty()) {
throw new CustomException("需要指定收益冲销目标账号"); throw new CustomException("需要指定收益冲销目标账号");
} }
BigDecimal amount = context.getRefundAmount(); BigDecimal amount = context.getEarningsAdjustAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO); amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
} }
earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId()); earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId());
} }

View File

@@ -26,31 +26,15 @@ public class OrderRevocationContext {
private boolean refundToCustomer; private boolean refundToCustomer;
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE; private boolean deductClerkEarnings;
@Nullable
private String counterClerkId;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN; private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
@Nullable
private BigDecimal earningsAdjustAmount;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) { public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource; this.triggerSource = triggerSource;
return this; 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,26 @@
package com.starry.admin.modules.order.module.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import lombok.Data;
@Data
@ApiModel(value = "撤销限额信息", description = "展示撤销时可退金额、可扣回收益等信息")
public class PlayOrderRevocationLimitsVo {
@ApiModelProperty("订单ID")
private String orderId;
@ApiModelProperty("最大可退金额")
private BigDecimal maxRefundAmount = BigDecimal.ZERO;
@ApiModelProperty("最大可扣回收益")
private BigDecimal maxDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("建议扣回金额")
private BigDecimal defaultDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("是否存在可扣回店员")
private boolean deductible;
}

View File

@@ -1,6 +1,5 @@
package com.starry.admin.modules.order.module.vo; 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.ApiModel;
import io.swagger.annotations.ApiModelProperty; import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -24,9 +23,9 @@ public class PlayOrderRevocationVo {
@ApiModelProperty(value = "撤销原因") @ApiModelProperty(value = "撤销原因")
private String refundReason; private String refundReason;
@ApiModelProperty(value = "收益处理策略NONE/REVERSE_CLERK/COUNTER_TO_PEIPEI") @ApiModelProperty(value = "是否扣回店员收益")
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE; private boolean deductClerkEarnings;
@ApiModelProperty(value = "收益冲销目标账号ID策略为 COUNTER_TO_PEIPEI 时必填") @ApiModelProperty(value = "扣回金额,未填写则默认按本单收益全额扣回")
private String counterClerkId; private BigDecimal deductAmount;
} }

View File

@@ -632,18 +632,32 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("仅支持撤销普通服务订单"); throw new CustomException("仅支持撤销普通服务订单");
} }
OrderRevocationContext.EarningsAdjustStrategy strategy = context.getEarningsStrategy() != null
? context.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
if (strategy == OrderRevocationContext.EarningsAdjustStrategy.REVERSE_CLERK
&& earningsService.hasLockedLines(order.getId())) {
throw new CustomException("收益已提现或处理中,无法撤销");
}
String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode(); String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode();
context.setOperatorType(operatorType); context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId(); String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
context.setOperatorId(operatorId); context.setOperatorId(operatorId);
if (context.isDeductClerkEarnings()) {
String targetClerkId = order.getAcceptBy();
if (StrUtil.isBlank(targetClerkId)) {
throw new CustomException("未找到可冲销的店员收益账号");
}
BigDecimal availableEarnings = Optional.ofNullable(
earningsService.getRemainingEarningsForOrder(order.getId(), targetClerkId))
.orElse(BigDecimal.ZERO);
if (availableEarnings.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("本单店员收益已全部扣回");
}
BigDecimal requested = context.getEarningsAdjustAmount();
if (requested == null || requested.compareTo(BigDecimal.ZERO) <= 0) {
requested = availableEarnings;
}
if (requested.compareTo(availableEarnings) > 0) {
throw new CustomException("扣回金额不能超过本单收益" + availableEarnings);
}
context.setEarningsAdjustAmount(requested);
} else {
context.setEarningsAdjustAmount(BigDecimal.ZERO);
}
BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO); BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal refundAmount = context.getRefundAmount(); BigDecimal refundAmount = context.getRefundAmount();
@@ -708,9 +722,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
String operationType = String.format( String operationType = String.format(
"%s_%s", "%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(), LifecycleOperation.REVOKE_COMPLETED.name(),
strategy != null context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
? strategy.getLogCode()
: OrderRevocationContext.EarningsAdjustStrategy.NONE.getLogCode());
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED, recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType); context.getRefundReason(), operationType);

View File

@@ -8,7 +8,8 @@ import com.fasterxml.jackson.annotation.JsonValue;
*/ */
public enum EarningsType { public enum EarningsType {
ORDER("ORDER"), ORDER("ORDER"),
COMMISSION("COMMISSION"); COMMISSION("COMMISSION"),
ADJUSTMENT("ADJUSTMENT");
@EnumValue @EnumValue
@JsonValue @JsonValue

View File

@@ -18,9 +18,7 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, 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); void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
boolean hasLockedLines(String orderId); BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId);
} }

View File

@@ -2,7 +2,6 @@ package com.starry.admin.modules.withdraw.service.impl;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -15,7 +14,6 @@ import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -98,17 +96,6 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
return picked; 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 @Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) { public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) { if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
@@ -119,27 +106,63 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
return; return;
} }
LocalDateTime now = LocalDateTime.now();
LocalDateTime resolvedUnlock = now;
String resolvedStatus = "available";
List<EarningsLineEntity> references = this.baseMapper.selectList(new LambdaQueryWrapper<EarningsLineEntity>()
.eq(EarningsLineEntity::getOrderId, orderId)
.eq(EarningsLineEntity::getClerkId, targetClerkId)
.eq(EarningsLineEntity::getDeleted, false)
.orderByAsc(EarningsLineEntity::getUnlockTime));
EarningsLineEntity reference = references.stream()
.filter(line -> line.getAmount() != null && line.getAmount().compareTo(BigDecimal.ZERO) > 0)
.findFirst()
.orElse(null);
if (reference == null) {
throw new IllegalStateException("未找到可冲销的收益记录");
}
LocalDateTime refUnlock = reference.getUnlockTime();
String refStatus = reference.getStatus();
boolean shouldPreserveFreeze = "frozen".equalsIgnoreCase(refStatus)
&& refUnlock != null
&& refUnlock.isAfter(now);
if (shouldPreserveFreeze) {
resolvedUnlock = refUnlock;
resolvedStatus = "frozen";
} else {
resolvedUnlock = now;
resolvedStatus = "available";
}
EarningsLineEntity line = new EarningsLineEntity(); EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid()); line.setId(IdUtils.getUuid());
line.setOrderId(orderId); line.setOrderId(orderId);
line.setTenantId(tenantId); line.setTenantId(tenantId);
line.setClerkId(targetClerkId); line.setClerkId(targetClerkId);
line.setAmount(normalized.negate()); line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ORDER); line.setEarningType(EarningsType.ADJUSTMENT);
line.setStatus("available"); line.setStatus(resolvedStatus);
line.setUnlockTime(LocalDateTime.now()); line.setUnlockTime(resolvedUnlock);
this.save(line); this.save(line);
} }
@Override @Override
public boolean hasLockedLines(String orderId) { public BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId) {
if (StrUtil.isBlank(orderId)) { if (StrUtil.hasBlank(orderId, clerkId)) {
return false; return BigDecimal.ZERO;
} }
Long count = this.lambdaQuery() List<EarningsLineEntity> lines = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId) .eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("withdrawing", "withdrawn")) .eq(EarningsLineEntity::getClerkId, clerkId)
.count(); .eq(EarningsLineEntity::getDeleted, false)
return count != null && count > 0; .list();
BigDecimal total = BigDecimal.ZERO;
for (EarningsLineEntity line : lines) {
BigDecimal amount = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
total = total.add(amount);
}
return total;
} }
} }

View File

@@ -14,8 +14,8 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true&connectionCollation=utf8mb4_general_ci&sessionVariables=collation_connection=utf8mb4_general_ci url: jdbc:mysql://localhost:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true&connectionCollation=utf8mb4_general_ci&sessionVariables=collation_connection=utf8mb4_general_ci
username: apitest username: root
password: apitest password: root
druid: druid:
enable: true enable: true
db-type: mysql db-type: mysql

View File

@@ -319,4 +319,63 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
clerkIdsToCleanup.add(clerkId); clerkIdsToCleanup.add(clerkId);
return clerkId; return clerkId;
} }
@Test
void listOrderingStableWithMultipleCriteria() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("stable", 10L, 80);
String filterToken = "stable-" + IdUtils.getUuid().substring(0, 6);
String pinnedOnline = createClerk(filterToken + "-pinned-online", level.getId(), "1");
togglePin(pinnedOnline, "1");
String pinnedOffline = createClerk(filterToken + "-pinned-offline", level.getId(), "0");
togglePin(pinnedOffline, "1");
String online1 = createClerk(filterToken + "-online-one", level.getId(), "1");
pause(50);
String online2 = createClerk(filterToken + "-online-two", level.getId(), "1");
String offline = createClerk(filterToken + "-offline", level.getId(), "0");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode records = root.path("data");
assertThat(records.isArray()).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
assertThat(orderedIds.indexOf(pinnedOnline))
.isLessThan(orderedIds.indexOf(pinnedOffline));
assertThat(orderedIds.indexOf(pinnedOffline))
.isLessThan(orderedIds.indexOf(online1));
assertThat(orderedIds.indexOf(online1))
.isLessThan(orderedIds.indexOf(offline));
assertThat(orderedIds.indexOf(online1))
.withFailMessage("Created time fallback should maintain order, list=%s", orderedIds)
.isLessThan(orderedIds.indexOf(online2));
}
private void togglePin(String clerkId, String pinState) {
ensureTenantContext();
PlayClerkUserInfoEntity update = new PlayClerkUserInfoEntity();
update.setId(clerkId);
update.setPinToTopState(pinState);
clerkUserInfoService.update(update);
}
private void pause(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
} }

View File

@@ -1,6 +1,7 @@
package com.starry.admin.api; package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -210,12 +211,12 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception { void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
ensureTenantContext(); ensureTenantContext();
String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase(); String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().minusHours(2); LocalDateTime reference = LocalDateTime.now().plusHours(2);
PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> { PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode()); order.setOrderStatus(OrderStatus.COMPLETED.getCode());
}); });
PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.minusMinutes(10), order -> { PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode()); order.setOrderStatus(OrderStatus.COMPLETED.getCode());
}); });
@@ -225,7 +226,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
ObjectNode clerkKeywordPayload = baseQuery(); ObjectNode clerkKeywordPayload = baseQuery();
clerkKeywordPayload.put("keyword", "小测官"); clerkKeywordPayload.put("keyword", "小测官");
clerkKeywordPayload.set("purchaserTime", range(reference.minusMinutes(5), reference.plusMinutes(5))); clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15)));
clerkKeywordPayload.put("placeType", "1"); clerkKeywordPayload.put("placeType", "1");
RecordsResponse clerkResponse = executeList(clerkKeywordPayload); RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
JsonNode clerkRecords = clerkResponse.records; JsonNode clerkRecords = clerkResponse.records;
@@ -239,7 +240,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordRespectsAdditionalFilters() throws Exception { void listByPage_keywordRespectsAdditionalFilters() throws Exception {
ensureTenantContext(); ensureTenantContext();
String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase(); String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().minusHours(3); LocalDateTime reference = LocalDateTime.now().plusHours(3);
PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> { PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> {
order.setOrderStatus("3"); order.setOrderStatus("3");
@@ -270,119 +271,183 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00")); entity.setFinalAmount(new BigDecimal("166.00"));
}); });
seedLockedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn"); seedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId()); payload.put("orderId", order.getId());
payload.put("refundToCustomer", false); payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO); payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-保留收益"); payload.put("refundReason", "API撤销-保留收益");
payload.put("earningsStrategy", "NONE"); payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted") MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT) .header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER) .header(USER_HEADER, DEFAULT_USER)
.content(payload.toString())) .content(payload.toString()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200)) .andReturn();
.andExpect(jsonPath("$.message").value("操作成功"));
JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
} }
@Test @Test
void revokeCompletedOrder_reverseClerkBlockedWhenLocked() throws Exception { void revokeCompletedOrder_reverseClerkCreatesNegativeLineEvenWhenLocked() throws Exception {
ensureTenantContext(); ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2); LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> { PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("210.00")); entity.setFinalAmount(new BigDecimal("210.00"));
}); });
seedLockedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn"); seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId()); payload.put("orderId", order.getId());
payload.put("refundToCustomer", false); payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("20.00")); payload.put("refundAmount", new BigDecimal("20.00"));
payload.put("refundReason", "API撤销-冲销收益"); payload.put("refundReason", "API撤销-冲销收益");
payload.put("earningsStrategy", "REVERSE_CLERK"); payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("20.00"));
mockMvc.perform(post("/order/order/revokeCompleted") MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT) .header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER) .header(USER_HEADER, DEFAULT_USER)
.content(payload.toString())) .content(payload.toString()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500)) .andReturn();
.andExpect(jsonPath("$.message").value("收益已提现或处理中,无法撤销"));
} JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
@Test
void revokeCompletedOrder_counterStrategyCreatesNegativeLineAfterWithdrawal() throws Exception {
ensureTenantContext(); ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(90);
PlayOrderInfoEntity order = persistOrder("RVK", "counter", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("230.00"));
entity.setEstimatedRevenue(new BigDecimal("150.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("140.00"), "withdrawn");
String counterClerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID + "-pei-hold";
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("50.00"));
payload.put("refundReason", "API撤销-转待扣");
payload.put("earningsStrategy", "COUNTER_TO_PEIPEI");
payload.put("counterClerkId", counterClerkId);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
List<EarningsLineEntity> lines = earningsService.lambdaQuery() List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId()) .eq(EarningsLineEntity::getOrderId, order.getId())
.list(); .list();
assertThat(lines).hasSize(2); assertThat(lines).hasSize(2);
EarningsLineEntity negativeLine = lines.stream()
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0) .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst() .findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行")); .orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-50.00")); assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
assertThat(counterLine.getStatus()).isEqualTo("available"); assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId); earningsLineIdsToCleanup.add(negativeLine.getId());
earningsLineIdsToCleanup.add(counterLine.getId());
} }
@Test @Test
void revokeCompletedOrder_refundAndCounterCreatesRecords() throws Exception { void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
ensureTenantContext(); ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(45); LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "refundCounter", reference, entity -> { PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("320.00")); entity.setFinalAmount(new BigDecimal("166.00"));
entity.setEstimatedRevenue(new BigDecimal("180.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
}); });
seedLockedEarningLine(order.getId(), new BigDecimal("110.00"), "withdrawn"); earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
String counterClerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID + "-refund-counter"; .remove();
LocalDateTime unlockAt = LocalDateTime.now().plusHours(3).withNano(0);
seedEarningLine(order.getId(), new BigDecimal("90.00"), "frozen", unlockAt);
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId()); payload.put("orderId", order.getId());
payload.put("refundToCustomer", true); payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("60.00")); payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-退款并待扣"); payload.put("refundReason", "API撤销-冻结扣回");
payload.put("earningsStrategy", "COUNTER_TO_PEIPEI"); payload.put("deductClerkEarnings", true);
payload.put("counterClerkId", counterClerkId); payload.put("deductAmount", new BigDecimal("30.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("frozen");
assertThat(negativeLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_deductMakesWithdrawnLineAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "withdrawn", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("0.00"));
payload.put("refundReason", "API撤销-提现扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("40.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("available");
assertThat(negativeLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_defaultsDeductAmountWhenMissing() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(50);
PlayOrderInfoEntity order = persistOrder("RVK", "autoDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("260.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("75.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-自动扣回");
payload.put("deductClerkEarnings", true);
mockMvc.perform(post("/order/order/revokeCompleted") mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -390,12 +455,55 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.header(USER_HEADER, DEFAULT_USER) .header(USER_HEADER, DEFAULT_USER)
.content(payload.toString())) .content(payload.toString()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200));
.andExpect(jsonPath("$.message").value("操作成功"));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-75.00"));
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_refundAndDeductCreatesRecords() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(35);
PlayOrderInfoEntity order = persistOrder("RVK", "refundDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("300.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", true);
payload.put("refundAmount", new BigDecimal("80.00"));
payload.put("refundReason", "API撤销-退款扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayOrderRefundInfoEntity refundInfo = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(order.getId()); PlayOrderRefundInfoEntity refundInfo = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(order.getId());
assertThat(refundInfo).isNotNull(); assertThat(refundInfo).isNotNull();
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("60.00")); assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("80.00"));
refundIdsToCleanup.add(refundInfo.getId()); refundIdsToCleanup.add(refundInfo.getId());
List<EarningsLineEntity> lines = earningsService.lambdaQuery() List<EarningsLineEntity> lines = earningsService.lambdaQuery()
@@ -406,10 +514,155 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.findFirst() .findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行")); .orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00")); assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00"));
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId);
earningsLineIdsToCleanup.add(counterLine.getId()); earningsLineIdsToCleanup.add(counterLine.getId());
} }
@Test
void revokeCompletedOrder_deductLineRespectsFutureUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(3).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().plusHours(12).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "futureUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("220.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "frozen", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-锁定排期");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("frozen");
assertThat(counterLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductLineUnlocksImmediatelyWhenAlreadyAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(5).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().minusHours(1).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "pastUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("180.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("90.00"), "available", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-立即扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("45.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("available");
assertThat(counterLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductFailsWhenNoEarningLineExists() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(4);
PlayOrderInfoEntity order = persistOrder("RVK", "noLine", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("150.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-无收益扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("30.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("本单店员收益已全部扣回"));
}
@Test
void revokeCompletedOrder_rejectsDeductAmountBeyondAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(30);
PlayOrderInfoEntity order = persistOrder("RVK", "overDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("40.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-超额扣");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("扣回金额不能超过本单收益40.00"));
}
@Test @Test
void revokeCompletedOrder_blocksNonNormalOrders() throws Exception { void revokeCompletedOrder_blocksNonNormalOrders() throws Exception {
ensureTenantContext(); ensureTenantContext();
@@ -424,7 +677,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
payload.put("orderId", giftOrder.getId()); payload.put("orderId", giftOrder.getId());
payload.put("refundToCustomer", false); payload.put("refundToCustomer", false);
payload.put("refundReason", "gift revoke"); payload.put("refundReason", "gift revoke");
payload.put("earningsStrategy", "NONE"); payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted") mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -436,6 +689,30 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单")); .andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
} }
@Test
void getRevocationLimits_returnsRemainingValues() throws Exception {
ensureTenantContext();
PlayOrderInfoEntity order = persistOrder("RVK", "limits", LocalDateTime.now().minusHours(3), entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("45.50"), "available");
earningsLineIdsToCleanup.add(earningId);
mockMvc.perform(get("/order/order/" + order.getId() + "/revocationLimits")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.maxRefundAmount").value(188.00))
.andExpect(jsonPath("$.data.maxDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.defaultDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.deductible").value(true));
}
private PlayOrderInfoEntity persistOrder( private PlayOrderInfoEntity persistOrder(
String marker, String marker,
String token, String token,
@@ -525,7 +802,11 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
return array; return array;
} }
private String seedLockedEarningLine(String orderId, BigDecimal amount, String status) { private String seedEarningLine(String orderId, BigDecimal amount, String status) {
return seedEarningLine(orderId, amount, status, LocalDateTime.now().minusHours(2).withNano(0));
}
private String seedEarningLine(String orderId, BigDecimal amount, String status, LocalDateTime unlockAt) {
EarningsLineEntity entity = new EarningsLineEntity(); EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-revoke-" + IdUtils.getUuid(); String id = "earn-revoke-" + IdUtils.getUuid();
entity.setId(id); entity.setId(id);
@@ -535,14 +816,17 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setAmount(amount); entity.setAmount(amount);
entity.setStatus(status); entity.setStatus(status);
entity.setEarningType(EarningsType.ORDER); entity.setEarningType(EarningsType.ORDER);
entity.setUnlockTime(LocalDateTime.now().minusHours(2)); entity.setUnlockTime(unlockAt);
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid()); if ("withdrawn".equals(status) || "withdrawing".equals(status)) {
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
}
Date nowDate = toDate(LocalDateTime.now()); Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate); entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate); entity.setUpdatedTime(nowDate);
entity.setDeleted(false); entity.setDeleted(false);
ensureTenantContext();
earningsService.save(entity); earningsService.save(entity);
earningsLineIdsToCleanup.add(id); earningsLineIdsToCleanup.add(id);
return id; return id;

View File

@@ -85,7 +85,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
String orderId = createdOrder.getId(); String orderId = createdOrder.getId();
ensureTenantContext(); ensureTenantContext();
playOrderInfoService.lambdaUpdate() playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.GIFT.getCode()) .set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.NORMAL.getCode())
.eq(PlayOrderInfoEntity::getId, orderId) .eq(PlayOrderInfoEntity::getId, orderId)
.update(); .update();
ensureTenantContext(); ensureTenantContext();
@@ -112,7 +112,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
"\"orderId\":\"" + orderId + "\"," + "\"orderId\":\"" + orderId + "\"," +
"\"refundToCustomer\":false," + "\"refundToCustomer\":false," +
"\"refundReason\":\"" + revokeReason + "\"," + "\"refundReason\":\"" + revokeReason + "\"," +
"\"earningsStrategy\":\"NONE\"" + "\"deductClerkEarnings\":false" +
"}"; "}";
mockMvc.perform(post("/order/order/revokeCompleted") mockMvc.perform(post("/order/order/revokeCompleted")
@@ -157,6 +157,71 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
} }
} }
@Test
void revokeCompletedOrderRejectsNonNormalOrderTypes() 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 = "non-normal-" + 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).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);
ObjectNode revokePayload = objectMapper.createObjectNode();
revokePayload.put("orderId", orderId);
revokePayload.put("refundToCustomer", false);
revokePayload.put("refundReason", "non-normal-type");
revokePayload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(revokePayload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test @Test
void queryByPageSkipsRechargeOrdersByDefaultButAllowsExplicitFilter() throws Exception { void queryByPageSkipsRechargeOrdersByDefaultButAllowsExplicitFilter() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);

View File

@@ -562,7 +562,8 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours); LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours);
Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock); Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock);
Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5)); Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5));
Assertions.assertThat(earningsLine.getEarningType()).isEqualTo(EarningsType.ORDER); Assertions.assertThat(earningsLine.getEarningType())
.isIn(EarningsType.ORDER, EarningsType.ADJUSTMENT);
OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd); OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd);
Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount) Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount)

View File

@@ -61,6 +61,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private final List<String> earningsToCleanup = new ArrayList<>(); private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> withdrawalsToCleanup = new ArrayList<>(); private final List<String> withdrawalsToCleanup = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private String clerkToken; private String clerkToken;
private ClerkPayeeProfileEntity payeeProfile; private ClerkPayeeProfileEntity payeeProfile;
@@ -140,7 +141,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200))
.andReturn(); .andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode data = root.get("data"); JsonNode data = root.get("data");
assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50"); assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50");
assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40"); assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40");
@@ -191,7 +192,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data.amount").value(amount.doubleValue())) .andExpect(jsonPath("$.data.amount").value(amount.doubleValue()))
.andReturn(); .andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText(); String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank(); assertThat(withdrawalId).isNotBlank();
@@ -211,6 +212,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId); assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId);
} }
@Test
void createWithdrawHandlesMixedPositiveAndNegativeLines() throws Exception {
ensureTenantContext();
LocalDateTime base = LocalDateTime.now().minusHours(4);
BigDecimal[] amounts = {
new BigDecimal("-30"),
new BigDecimal("20"),
new BigDecimal("50"),
new BigDecimal("-10"),
new BigDecimal("40"),
new BigDecimal("60"),
new BigDecimal("15"),
new BigDecimal("25"),
new BigDecimal("-5"),
new BigDecimal("100")};
String[] lineIds = new String[amounts.length];
for (int i = 0; i < amounts.length; i++) {
BigDecimal amount = amounts[i];
EarningsType type = amount.compareTo(BigDecimal.ZERO) < 0 ? EarningsType.ADJUSTMENT : EarningsType.ORDER;
String id = insertEarningsLine(
"mix-" + i,
amount,
EarningsStatus.AVAILABLE,
base.plusMinutes(i),
type);
lineIds[i] = id;
earningsToCleanup.add(id);
}
refreshPayeeConfirmation();
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
refreshPayeeConfirmation();
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
refreshPayeeConfirmation();
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
ensureTenantContext();
BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
assertThat(remaining).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test @Test
void earningsEndpointFiltersByStatus() throws Exception { void earningsEndpointFiltersByStatus() throws Exception {
ensureTenantContext(); ensureTenantContext();
@@ -250,6 +297,15 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private String insertEarningsLine( private String insertEarningsLine(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) { String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
}
private String insertEarningsLine(
String suffix,
BigDecimal amount,
EarningsStatus status,
LocalDateTime unlockAt,
EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity(); EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-" + suffix + "-" + IdUtils.getUuid(); String id = "earn-" + suffix + "-" + IdUtils.getUuid();
entity.setId(id); entity.setId(id);
@@ -260,7 +316,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
entity.setAmount(amount); entity.setAmount(amount);
entity.setStatus(status.getCode()); entity.setStatus(status.getCode());
entity.setUnlockTime(unlockAt); entity.setUnlockTime(unlockAt);
entity.setEarningType(EarningsType.ORDER); entity.setEarningType(earningType);
Date now = toDate(LocalDateTime.now()); Date now = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(now); entity.setCreatedTime(now);
@@ -274,6 +330,39 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
} }
private void refreshPayeeConfirmation() {
if (payeeProfile != null) {
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
}
}
private String createWithdraw(BigDecimal amount) throws Exception {
MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"amount\":" + amount.toPlainString() + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank();
withdrawalsToCleanup.add(withdrawalId);
return withdrawalId;
}
private void assertLinesLocked(String withdrawalId, String... lineIds) {
ensureTenantContext();
for (String id : lineIds) {
EarningsLineEntity line = earningsService.getById(id);
assertThat(line.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode());
assertThat(line.getWithdrawalId()).isEqualTo(withdrawalId);
}
}
private Date toDate(LocalDateTime value) { private Date toDate(LocalDateTime value) {
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
} }

View File

@@ -4,7 +4,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext; 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.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent; import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -29,46 +28,49 @@ class OrderRevocationEarningsListenerTest {
} }
@Test @Test
void handle_counterStrategyFallsBackToEstimatedRevenueWhenRefundAmountZero() { void handle_deductCreatesCounterLineUsingOrderClerk() {
OrderRevocationContext context = new OrderRevocationContext(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-counter-1"); context.setOrderId("order-reverse-2");
context.setOperatorId("admin-op"); context.setOperatorId("admin-reviewer");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI); context.setDeductClerkEarnings(true);
context.setRefundAmount(BigDecimal.ZERO); context.setEarningsAdjustAmount(BigDecimal.valueOf(25));
context.setCounterClerkId("ops-clerk");
PlayOrderInfoEntity order = new PlayOrderInfoEntity(); PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-counter-1"); order.setId("order-reverse-2");
order.setTenantId("tenant-77"); order.setTenantId("tenant-x");
order.setEstimatedRevenue(BigDecimal.valueOf(68)); order.setAcceptBy("clerk-special");
listener.handle(new OrderRevocationEvent(context, order)); listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService) verify(earningsService)
.createCounterLine(order.getId(), order.getTenantId(), "ops-clerk", BigDecimal.valueOf(68), "admin-op"); .createCounterLine("order-reverse-2", "tenant-x", "clerk-special", BigDecimal.valueOf(25), "admin-reviewer");
} }
@Test @Test
void handle_reverseStrategyRevertsAvailableLines() { void handle_deductFallsBackToEstimatedWhenAmountMissing() {
OrderRevocationContext context = new OrderRevocationContext(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-2"); context.setOrderId("order-reverse-3");
context.setOperatorId("admin-reviewer"); context.setOperatorId("admin-reviewer");
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); context.setDeductClerkEarnings(true);
PlayOrderInfoEntity order = new PlayOrderInfoEntity(); PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-reverse-2"); order.setId("order-reverse-3");
order.setTenantId("tenant-y");
order.setAcceptBy("clerk-owner");
order.setEstimatedRevenue(BigDecimal.valueOf(52));
listener.handle(new OrderRevocationEvent(context, order)); listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService).reverseByOrder("order-reverse-2", "admin-reviewer"); verify(earningsService)
.createCounterLine("order-reverse-3", "tenant-y", "clerk-owner", BigDecimal.valueOf(52), "admin-reviewer");
} }
@Test @Test
void handle_noneStrategyIsNoOp() { void handle_disabledDeductIsNoOp() {
OrderRevocationContext context = new OrderRevocationContext(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-none-3"); context.setOrderId("order-none-3");
context.setOperatorId("admin-noop"); context.setOperatorId("admin-noop");
context.setEarningsStrategy(EarningsAdjustStrategy.NONE); context.setDeductClerkEarnings(false);
PlayOrderInfoEntity order = new PlayOrderInfoEntity(); PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-none-3"); order.setId("order-none-3");

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.order.service.impl;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
@@ -43,7 +44,6 @@ 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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; 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;
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.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; 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.PlayOrderInfoEntity;
@@ -132,8 +132,12 @@ class OrderLifecycleServiceImplTest {
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked); when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1); lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.TEN);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId)) when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
.thenReturn(true); .thenReturn(true);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.valueOf(60));
OrderRevocationContext context = new OrderRevocationContext(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId); context.setOrderId(orderId);
@@ -142,7 +146,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(88)); context.setRefundAmount(BigDecimal.valueOf(88));
context.setRefundReason("客户投诉"); context.setRefundReason("客户投诉");
context.setRefundToCustomer(true); context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); context.setDeductClerkEarnings(true);
lifecycleService.revokeCompletedOrder(context); lifecycleService.revokeCompletedOrder(context);
@@ -152,8 +156,9 @@ class OrderLifecycleServiceImplTest {
verify(applicationEventPublisher).publishEvent(captor.capture()); verify(applicationEventPublisher).publishEvent(captor.capture());
OrderRevocationEvent event = captor.getValue(); OrderRevocationEvent event = captor.getValue();
assertEquals(orderId, event.getContext().getOrderId()); assertEquals(orderId, event.getContext().getOrderId());
assertEquals(EarningsAdjustStrategy.REVERSE_CLERK, event.getContext().getEarningsStrategy()); assertTrue(event.getContext().isDeductClerkEarnings());
assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount()); assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount());
assertEquals(BigDecimal.valueOf(60), event.getContext().getEarningsAdjustAmount());
} }
@Test @Test
@@ -177,7 +182,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(108)); context.setRefundAmount(BigDecimal.valueOf(108));
context.setRefundReason("质量问题"); context.setRefundReason("质量问题");
context.setRefundToCustomer(true); context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.NONE); context.setDeductClerkEarnings(false);
lifecycleService.revokeCompletedOrder(context); lifecycleService.revokeCompletedOrder(context);
@@ -186,52 +191,74 @@ class OrderLifecycleServiceImplTest {
} }
@Test @Test
void revokeCompletedOrder_blocksWhenEarningsLocked() { void revokeCompletedOrder_reverseStrategyDefaultsCounterClerkWhenMissing() {
String orderId = UUID.randomUUID().toString(); String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed); 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(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId); context.setOrderId(orderId);
context.setOperatorId("admin-locked"); context.setOperatorId("admin-locked");
context.setOperatorType(OperatorType.ADMIN.getCode()); context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false); context.setRefundToCustomer(false);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); context.setDeductClerkEarnings(true);
assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context)); context.setEarningsAdjustAmount(BigDecimal.valueOf(10));
verify(orderInfoMapper, never()).update(isNull(), any());
verify(applicationEventPublisher, never()).publishEvent(any()); assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
} }
@Test @Test
void revokeCompletedOrder_counterStrategyAllowedAfterWithdrawal() { void revokeCompletedOrder_throwsWhenDeductExceedsAvailable() {
String orderId = UUID.randomUUID().toString(); String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
completed.setEstimatedRevenue(BigDecimal.valueOf(120));
completed.setFinalAmount(BigDecimal.valueOf(200));
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode()); PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
revoked.setFinalAmount(BigDecimal.valueOf(200));
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked); when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1); when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.valueOf(40));
OrderRevocationContext context = new OrderRevocationContext(); OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId); context.setOrderId(orderId);
context.setOperatorId("admin-counter"); context.setOperatorId("admin-counter");
context.setOperatorType(OperatorType.ADMIN.getCode()); context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false); context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.valueOf(50)); context.setRefundAmount(BigDecimal.ZERO);
context.setRefundReason("撤销并转待扣"); context.setDeductClerkEarnings(true);
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI); context.setEarningsAdjustAmount(BigDecimal.valueOf(50));
context.setCounterClerkId("clerk-negative");
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context)); CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("扣回金额不能超过本单收益40", ex.getMessage());
}
verify(orderInfoMapper).update(isNull(), any()); @Test
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class)); 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 @Test
@@ -246,7 +273,7 @@ class OrderLifecycleServiceImplTest {
context.setOrderId(orderId); context.setOrderId(orderId);
context.setOperatorId("admin-block"); context.setOperatorId("admin-block");
context.setOperatorType(OperatorType.ADMIN.getCode()); context.setOperatorType(OperatorType.ADMIN.getCode());
context.setEarningsStrategy(EarningsAdjustStrategy.NONE); context.setDeductClerkEarnings(false);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context)); CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("仅支持撤销普通服务订单", ex.getMessage()); assertEquals("仅支持撤销普通服务订单", ex.getMessage());
@@ -1359,6 +1386,7 @@ class OrderLifecycleServiceImplTest {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
entity.setOrderStatus(status); entity.setOrderStatus(status);
entity.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
entity.setAcceptBy("clerk-1"); entity.setAcceptBy("clerk-1");
entity.setPurchaserBy("customer-1"); entity.setPurchaserBy("customer-1");
entity.setTenantId("tenant-1"); entity.setTenantId("tenant-1");

View File

@@ -1,6 +1,8 @@
package com.starry.admin.modules.withdraw.service.impl; package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.any;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -14,6 +16,7 @@ import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -83,8 +86,21 @@ class EarningsServiceImplTest {
verify(baseMapper, never()).insert(any()); verify(baseMapper, never()).insert(any());
} }
@Test
void createCounterLine_throwsWhenNoReferencePresent() {
when(baseMapper.selectList(any())).thenReturn(Collections.emptyList());
assertThrows(IllegalStateException.class, () ->
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.TEN, "admin"));
}
@Test @Test
void createCounterLine_persistsNegativeAvailableLine() { void createCounterLine_persistsNegativeAvailableLine() {
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("88.00"));
reference.setUnlockTime(LocalDateTime.now().minusHours(1));
reference.setStatus("available");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1); when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin"); earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin");
@@ -97,6 +113,46 @@ class EarningsServiceImplTest {
assertEquals("available", saved.getStatus()); assertEquals("available", saved.getStatus());
} }
@Test
void createCounterLine_inheritsFrozenUnlockScheduleFromReference() {
LocalDateTime unlockAt = LocalDateTime.now().plusDays(1).withNano(0);
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("150.00"));
reference.setUnlockTime(unlockAt);
reference.setStatus("frozen");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(40), "admin");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals("frozen", saved.getStatus());
assertEquals(unlockAt, saved.getUnlockTime());
}
@Test
void createCounterLine_unlockedReferenceProducesAvailableCounter() {
LocalDateTime unlockAt = LocalDateTime.now().minusHours(3).withNano(0);
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("95.00"));
reference.setUnlockTime(unlockAt);
reference.setStatus("available");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(55), "admin");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals("available", saved.getStatus());
LocalDateTime now = LocalDateTime.now();
assertTrue(saved.getUnlockTime().isAfter(unlockAt));
assertTrue(!saved.getUnlockTime().isAfter(now.plusSeconds(1)));
}
@Test @Test
void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() { void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -125,6 +181,34 @@ class EarningsServiceImplTest {
assertEquals("second", picked.get(2).getId()); assertEquals("second", picked.get(2).getId());
} }
@Test
void findWithdrawable_handlesMixedPositiveAndNegativeSequences() {
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg-30", new BigDecimal("-30")),
line("pos-20", new BigDecimal("20")),
line("pos-50", new BigDecimal("50")),
line("neg-10", new BigDecimal("-10")),
line("pos-40", new BigDecimal("40")),
line("pos-60", new BigDecimal("60")),
line("pos-15", new BigDecimal("15")),
line("pos-25", new BigDecimal("25")),
line("neg-5", new BigDecimal("-5")),
line("pos-100", new BigDecimal("100")));
when(baseMapper.selectWithdrawableLines("clerk-mix", now)).thenReturn(lines, lines, lines);
List<EarningsLineEntity> partial = earningsService.findWithdrawable("clerk-mix", new BigDecimal("35"), now);
assertEquals(Arrays.asList("neg-30", "pos-20", "pos-50"), ids(partial));
List<EarningsLineEntity> mid = earningsService.findWithdrawable("clerk-mix", new BigDecimal("90"), now);
assertEquals(Arrays.asList("neg-30", "pos-20", "pos-50", "neg-10", "pos-40", "pos-60"), ids(mid));
List<EarningsLineEntity> full = earningsService.findWithdrawable("clerk-mix", new BigDecimal("265"), now);
assertEquals(lines.size(), full.size());
assertEquals("pos-100", full.get(full.size() - 1).getId());
}
private EarningsLineEntity line(String id, BigDecimal amount) { private EarningsLineEntity line(String id, BigDecimal amount) {
EarningsLineEntity entity = new EarningsLineEntity(); EarningsLineEntity entity = new EarningsLineEntity();
entity.setId(id); entity.setId(id);
@@ -132,4 +216,8 @@ class EarningsServiceImplTest {
entity.setStatus("available"); entity.setStatus("available");
return entity; return entity;
} }
private List<String> ids(List<EarningsLineEntity> entities) {
return entities.stream().map(EarningsLineEntity::getId).collect(java.util.stream.Collectors.toList());
}
} }