feat: 完成撤销收益扣回與限額改動
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user