feat: 完成撤销收益扣回與限額改動

This commit is contained in:
irving
2025-11-14 00:55:05 -05:00
parent 4cd2950051
commit cec5e965f6
16 changed files with 824 additions and 203 deletions

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.service.IPlayCommodityInfoService;
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.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder;
@@ -28,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -59,6 +61,9 @@ public class PlayOrderInfoController {
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/**
* 分页查询订单列表
*/
@@ -115,11 +120,8 @@ public class PlayOrderInfoController {
context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason());
OrderRevocationContext.EarningsAdjustStrategy strategy = vo.getEarningsStrategy() != null
? vo.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
context.setEarningsStrategy(strategy);
context.setCounterClerkId(vo.getCounterClerkId());
context.setDeductClerkEarnings(vo.isDeductClerkEarnings());
context.setEarningsAdjustAmount(vo.getDeductAmount());
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
@@ -127,6 +129,29 @@ public class PlayOrderInfoController {
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;
}
OrderRevocationContext context = event.getContext();
switch (context.getEarningsStrategy()) {
case NONE:
return;
case REVERSE_CLERK:
earningsService.reverseByOrder(event.getOrderSnapshot().getId(), context.getOperatorId());
return;
case COUNTER_TO_PEIPEI:
createCounterLine(event);
return;
default:
throw new CustomException("未知的收益处理策略");
if (!context.isDeductClerkEarnings()) {
return;
}
createCounterLine(event);
}
private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext();
if (context == null) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = context.getCounterClerkId();
if (targetClerkId == null) {
String targetClerkId = order.getAcceptBy();
if (targetClerkId == null || targetClerkId.trim().isEmpty()) {
throw new CustomException("需要指定收益冲销目标账号");
}
BigDecimal amount = context.getRefundAmount();
BigDecimal amount = context.getEarningsAdjustAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
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());
}

View File

@@ -26,31 +26,15 @@ public class OrderRevocationContext {
private boolean refundToCustomer;
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE;
@Nullable
private String counterClerkId;
private boolean deductClerkEarnings;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
@Nullable
private BigDecimal earningsAdjustAmount;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource;
return this;
}
public enum EarningsAdjustStrategy {
NONE("NO_ADJUST"),
REVERSE_CLERK("REV_CLERK"),
COUNTER_TO_PEIPEI("CTR_PEIPEI");
private final String logCode;
EarningsAdjustStrategy(String logCode) {
this.logCode = logCode;
}
public String getLogCode() {
return logCode;
}
}
}

View File

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

View File

@@ -632,18 +632,32 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
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();
context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
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 refundAmount = context.getRefundAmount();
@@ -708,9 +722,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
strategy != null
? strategy.getLogCode()
: OrderRevocationContext.EarningsAdjustStrategy.NONE.getLogCode());
context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);

View File

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

View File

@@ -18,9 +18,7 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
void reverseByOrder(String orderId, String operatorId);
void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
boolean hasLockedLines(String orderId);
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 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.constant.OrderConstant;
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.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -98,17 +96,6 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
return picked;
}
@Override
public void reverseByOrder(String orderId, String operatorId) {
if (StrUtil.isBlank(orderId)) {
return;
}
this.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("available", "frozen"))
.set(EarningsLineEntity::getStatus, "reversed"));
}
@Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
@@ -119,27 +106,63 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
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();
line.setId(IdUtils.getUuid());
line.setOrderId(orderId);
line.setTenantId(tenantId);
line.setClerkId(targetClerkId);
line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ORDER);
line.setStatus("available");
line.setUnlockTime(LocalDateTime.now());
line.setEarningType(EarningsType.ADJUSTMENT);
line.setStatus(resolvedStatus);
line.setUnlockTime(resolvedUnlock);
this.save(line);
}
@Override
public boolean hasLockedLines(String orderId) {
if (StrUtil.isBlank(orderId)) {
return false;
public BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId) {
if (StrUtil.hasBlank(orderId, clerkId)) {
return BigDecimal.ZERO;
}
Long count = this.lambdaQuery()
List<EarningsLineEntity> lines = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("withdrawing", "withdrawn"))
.count();
return count != null && count > 0;
.eq(EarningsLineEntity::getClerkId, clerkId)
.eq(EarningsLineEntity::getDeleted, false)
.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

@@ -1,6 +1,7 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -210,12 +211,12 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
ensureTenantContext();
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 -> {
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());
});
@@ -225,7 +226,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
ObjectNode clerkKeywordPayload = baseQuery();
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");
RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
JsonNode clerkRecords = clerkResponse.records;
@@ -239,7 +240,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordRespectsAdditionalFilters() throws Exception {
ensureTenantContext();
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 -> {
order.setOrderStatus("3");
@@ -270,119 +271,183 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
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();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
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)
.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("操作成功"));
.andReturn();
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_reverseClerkBlockedWhenLocked() throws Exception {
void revokeCompletedOrder_reverseClerkCreatesNegativeLineEvenWhenLocked() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
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();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("20.00"));
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)
.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("收益已提现或处理中,无法撤销"));
}
.andReturn();
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();
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()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
assertThat(lines).hasSize(2);
EarningsLineEntity counterLine = lines.stream()
EarningsLineEntity negativeLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-50.00"));
assertThat(counterLine.getStatus()).isEqualTo("available");
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId);
earningsLineIdsToCleanup.add(counterLine.getId());
assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_refundAndCounterCreatesRecords() throws Exception {
void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(45);
PlayOrderInfoEntity order = persistOrder("RVK", "refundCounter", reference, entity -> {
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("320.00"));
entity.setEstimatedRevenue(new BigDecimal("180.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("110.00"), "withdrawn");
String counterClerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID + "-refund-counter";
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
LocalDateTime unlockAt = LocalDateTime.now().plusHours(3).withNano(0);
seedEarningLine(order.getId(), new BigDecimal("90.00"), "frozen", unlockAt);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", true);
payload.put("refundAmount", new BigDecimal("60.00"));
payload.put("refundReason", "API撤销-退款并待扣");
payload.put("earningsStrategy", "COUNTER_TO_PEIPEI");
payload.put("counterClerkId", counterClerkId);
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-冻结扣回");
payload.put("deductClerkEarnings", true);
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")
.contentType(MediaType.APPLICATION_JSON)
@@ -390,12 +455,55 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
.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.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());
assertThat(refundInfo).isNotNull();
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("60.00"));
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("80.00"));
refundIdsToCleanup.add(refundInfo.getId());
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
@@ -406,10 +514,155 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00"));
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId);
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
void revokeCompletedOrder_blocksNonNormalOrders() throws Exception {
ensureTenantContext();
@@ -424,7 +677,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
payload.put("orderId", giftOrder.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "gift revoke");
payload.put("earningsStrategy", "NONE");
payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
@@ -436,6 +689,30 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.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(
String marker,
String token,
@@ -525,7 +802,11 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
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();
String id = "earn-revoke-" + IdUtils.getUuid();
entity.setId(id);
@@ -535,14 +816,17 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setAmount(amount);
entity.setStatus(status);
entity.setEarningType(EarningsType.ORDER);
entity.setUnlockTime(LocalDateTime.now().minusHours(2));
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
entity.setUnlockTime(unlockAt);
if ("withdrawn".equals(status) || "withdrawing".equals(status)) {
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
}
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
ensureTenantContext();
earningsService.save(entity);
earningsLineIdsToCleanup.add(id);
return id;

View File

@@ -85,7 +85,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
String orderId = createdOrder.getId();
ensureTenantContext();
playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.GIFT.getCode())
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.NORMAL.getCode())
.eq(PlayOrderInfoEntity::getId, orderId)
.update();
ensureTenantContext();
@@ -112,7 +112,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
"\"orderId\":\"" + orderId + "\"," +
"\"refundToCustomer\":false," +
"\"refundReason\":\"" + revokeReason + "\"," +
"\"earningsStrategy\":\"NONE\"" +
"\"deductClerkEarnings\":false" +
"}";
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
void queryByPageSkipsRechargeOrdersByDefaultButAllowsExplicitFilter() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);

View File

@@ -562,7 +562,8 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours);
Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock);
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);
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> withdrawalsToCleanup = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private String clerkToken;
private ClerkPayeeProfileEntity payeeProfile;
@@ -140,7 +141,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString());
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode data = root.get("data");
assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50");
assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40");
@@ -191,7 +192,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data.amount").value(amount.doubleValue()))
.andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString());
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank();
@@ -211,6 +212,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
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
void earningsEndpointFiltersByStatus() throws Exception {
ensureTenantContext();
@@ -250,6 +297,15 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private String insertEarningsLine(
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();
String id = "earn-" + suffix + "-" + IdUtils.getUuid();
entity.setId(id);
@@ -260,7 +316,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
entity.setAmount(amount);
entity.setStatus(status.getCode());
entity.setUnlockTime(unlockAt);
entity.setEarningType(EarningsType.ORDER);
entity.setEarningType(earningType);
Date now = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(now);
@@ -274,6 +330,39 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
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) {
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 com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -29,46 +28,49 @@ class OrderRevocationEarningsListenerTest {
}
@Test
void handle_counterStrategyFallsBackToEstimatedRevenueWhenRefundAmountZero() {
void handle_deductCreatesCounterLineUsingOrderClerk() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-counter-1");
context.setOperatorId("admin-op");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI);
context.setRefundAmount(BigDecimal.ZERO);
context.setCounterClerkId("ops-clerk");
context.setOrderId("order-reverse-2");
context.setOperatorId("admin-reviewer");
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(25));
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-counter-1");
order.setTenantId("tenant-77");
order.setEstimatedRevenue(BigDecimal.valueOf(68));
order.setId("order-reverse-2");
order.setTenantId("tenant-x");
order.setAcceptBy("clerk-special");
listener.handle(new OrderRevocationEvent(context, order));
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
void handle_reverseStrategyRevertsAvailableLines() {
void handle_deductFallsBackToEstimatedWhenAmountMissing() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-2");
context.setOrderId("order-reverse-3");
context.setOperatorId("admin-reviewer");
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
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));
verify(earningsService).reverseByOrder("order-reverse-2", "admin-reviewer");
verify(earningsService)
.createCounterLine("order-reverse-3", "tenant-y", "clerk-owner", BigDecimal.valueOf(52), "admin-reviewer");
}
@Test
void handle_noneStrategyIsNoOp() {
void handle_disabledDeductIsNoOp() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-none-3");
context.setOperatorId("admin-noop");
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
@@ -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.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -132,8 +132,12 @@ class OrderLifecycleServiceImplTest {
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.TEN);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
.thenReturn(true);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.valueOf(60));
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
@@ -142,7 +146,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(88));
context.setRefundReason("客户投诉");
context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
lifecycleService.revokeCompletedOrder(context);
@@ -152,8 +156,9 @@ class OrderLifecycleServiceImplTest {
verify(applicationEventPublisher).publishEvent(captor.capture());
OrderRevocationEvent event = captor.getValue();
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(60), event.getContext().getEarningsAdjustAmount());
}
@Test
@@ -177,7 +182,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(108));
context.setRefundReason("质量问题");
context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
lifecycleService.revokeCompletedOrder(context);
@@ -186,52 +191,74 @@ class OrderLifecycleServiceImplTest {
}
@Test
void revokeCompletedOrder_blocksWhenEarningsLocked() {
void revokeCompletedOrder_reverseStrategyDefaultsCounterClerkWhenMissing() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed);
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.TEN);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-locked");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper, never()).update(isNull(), any());
verify(applicationEventPublisher, never()).publishEvent(any());
context.setEarningsAdjustAmount(BigDecimal.valueOf(10));
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
}
@Test
void revokeCompletedOrder_counterStrategyAllowedAfterWithdrawal() {
void revokeCompletedOrder_throwsWhenDeductExceedsAvailable() {
String orderId = UUID.randomUUID().toString();
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());
revoked.setFinalAmount(BigDecimal.valueOf(200));
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();
context.setOrderId(orderId);
context.setOperatorId("admin-counter");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.valueOf(50));
context.setRefundReason("撤销并转待扣");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI);
context.setCounterClerkId("clerk-negative");
context.setRefundAmount(BigDecimal.ZERO);
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(50));
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("扣回金额不能超过本单收益40", ex.getMessage());
}
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
@Test
void revokeCompletedOrder_throwsWhenNoEarningsToDeduct() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.ZERO);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-empty");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.ZERO);
context.setDeductClerkEarnings(true);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("本单店员收益已全部扣回", ex.getMessage());
}
@Test
@@ -246,7 +273,7 @@ class OrderLifecycleServiceImplTest {
context.setOrderId(orderId);
context.setOperatorId("admin-block");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("仅支持撤销普通服务订单", ex.getMessage());
@@ -1359,6 +1386,7 @@ class OrderLifecycleServiceImplTest {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId);
entity.setOrderStatus(status);
entity.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
entity.setAcceptBy("clerk-1");
entity.setPurchaserBy("customer-1");
entity.setTenantId("tenant-1");

View File

@@ -1,6 +1,7 @@
package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -14,6 +15,7 @@ import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -83,8 +85,21 @@ class EarningsServiceImplTest {
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
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);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin");
@@ -97,6 +112,44 @@ class EarningsServiceImplTest {
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());
assertEquals(unlockAt, saved.getUnlockTime());
}
@Test
void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() {
LocalDateTime now = LocalDateTime.now();
@@ -125,6 +178,34 @@ class EarningsServiceImplTest {
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) {
EarningsLineEntity entity = new EarningsLineEntity();
entity.setId(id);
@@ -132,4 +213,8 @@ class EarningsServiceImplTest {
entity.setStatus("available");
return entity;
}
private List<String> ids(List<EarningsLineEntity> entities) {
return entities.stream().map(EarningsLineEntity::getId).collect(java.util.stream.Collectors.toList());
}
}