diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java index 866d48c..a03cb3f 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java @@ -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); + } + /** * 管理后台强制取消进行中订单 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java index 277de43..60c0b30 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java @@ -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()); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java index 8c07e42..6441fdc 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java @@ -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; - } - } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationLimitsVo.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationLimitsVo.java new file mode 100644 index 0000000..6c6d42e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationLimitsVo.java @@ -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; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java index 714e091..a7fd3c4 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java @@ -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; } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java index fd8483c..b084392 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java @@ -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); diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsType.java index 92e9e81..0003396 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsType.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsType.java @@ -8,7 +8,8 @@ import com.fasterxml.jackson.annotation.JsonValue; */ public enum EarningsType { ORDER("ORDER"), - COMMISSION("COMMISSION"); + COMMISSION("COMMISSION"), + ADJUSTMENT("ADJUSTMENT"); @EnumValue @JsonValue diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java index 428980a..ed32528 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java @@ -18,9 +18,7 @@ public interface IEarningsService extends IService { List 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); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java index 4abfb65..3b15fcb 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java @@ -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 references = this.baseMapper.selectList(new LambdaQueryWrapper() + .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 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; } + } diff --git a/play-admin/src/test/java/com/starry/admin/api/PlayOrderInfoControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/PlayOrderInfoControllerApiTest.java index 7eb6a08..0c086cf 100644 --- a/play-admin/src/test/java/com/starry/admin/api/PlayOrderInfoControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/PlayOrderInfoControllerApiTest.java @@ -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 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 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 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 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 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; diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java index 1fcbd29..5baf4b3 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java @@ -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); diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java index f444ab9..9ba865f 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java @@ -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) diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java index d0f0246..5eaae14 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java @@ -61,6 +61,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest { private final List earningsToCleanup = new ArrayList<>(); private final List 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()); } diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java index df1e693..67eaf89 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java @@ -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"); diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java index bcf7bb3..58d3dd0 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java @@ -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"); diff --git a/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java index a00a286..fba86bd 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java @@ -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 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 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 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 partial = earningsService.findWithdrawable("clerk-mix", new BigDecimal("35"), now); + assertEquals(Arrays.asList("neg-30", "pos-20", "pos-50"), ids(partial)); + + List 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 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 ids(List entities) { + return entities.stream().map(EarningsLineEntity::getId).collect(java.util.stream.Collectors.toList()); + } }