package com.starry.admin.api; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.function.Consumer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; class PlayOrderInfoControllerApiTest extends AbstractApiTest { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final BigDecimal DEFAULT_AMOUNT = new BigDecimal("188.00"); @Autowired private IPlayOrderInfoService orderInfoService; @Autowired private IEarningsService earningsService; @Autowired private IPlayOrderRefundInfoService orderRefundInfoService; private final ObjectMapper objectMapper = new ObjectMapper(); private final List orderIdsToCleanup = new ArrayList<>(); private final List earningsLineIdsToCleanup = new ArrayList<>(); private final List refundIdsToCleanup = new ArrayList<>(); @AfterEach void tearDown() { ensureTenantContext(); if (!earningsLineIdsToCleanup.isEmpty()) { earningsService.removeByIds(earningsLineIdsToCleanup); earningsLineIdsToCleanup.clear(); } if (!refundIdsToCleanup.isEmpty()) { orderRefundInfoService.removeByIds(refundIdsToCleanup); refundIdsToCleanup.clear(); } if (!orderIdsToCleanup.isEmpty()) { orderInfoService.removeByIds(orderIdsToCleanup); orderIdsToCleanup.clear(); } CustomSecurityContextHolder.remove(); } @Test void listByPage_honorsAllSupportedFilters() throws Exception { ensureTenantContext(); String marker = ("FT" + IdUtils.getUuid().replace("-", "").substring(0, 6)).toUpperCase(); LocalDateTime reference = LocalDateTime.now().minusHours(6).withNano(0); PlayOrderInfoEntity matching = persistOrder(marker, "match", reference, order -> { order.setOrderStatus("3"); order.setPlaceType("2"); order.setPayMethod("2"); order.setUseCoupon("1"); order.setBackendEntry("1"); order.setFirstOrder("0"); order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID); order.setSex("2"); order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID); order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); }); persistOrder(marker, "noise", reference.minusDays(3), order -> { order.setOrderStatus("0"); order.setPlaceType("0"); order.setPayMethod("0"); order.setUseCoupon("0"); order.setBackendEntry("0"); order.setFirstOrder("1"); order.setGroupId(marker + "-grp"); order.setSex("1"); order.setAcceptBy(null); order.setPurchaserBy("customer-" + marker); }); // orderNo filter (exact) ObjectNode orderNoPayload = baseQuery(); orderNoPayload.put("orderNo", matching.getOrderNo()); assertFilterMatches(orderNoPayload, matching.getId()); // acceptBy filter ObjectNode acceptPayload = queryWithMarker(marker); acceptPayload.put("acceptBy", matching.getAcceptBy()); assertFilterMatches(acceptPayload, matching.getId()); // purchaserBy filter ObjectNode purchaserPayload = queryWithMarker(marker); purchaserPayload.put("purchaserBy", matching.getPurchaserBy()); assertFilterMatches(purchaserPayload, matching.getId()); // orderStatus filter ObjectNode statusPayload = queryWithMarker(marker); statusPayload.put("orderStatus", matching.getOrderStatus()); assertFilterMatches(statusPayload, matching.getId()); // placeType filter ObjectNode placePayload = queryWithMarker(marker); placePayload.put("placeType", matching.getPlaceType()); assertFilterMatches(placePayload, matching.getId()); // payMethod filter ObjectNode payPayload = queryWithMarker(marker); payPayload.put("payMethod", matching.getPayMethod()); assertFilterMatches(payPayload, matching.getId()); // useCoupon filter ObjectNode couponPayload = queryWithMarker(marker); couponPayload.put("useCoupon", matching.getUseCoupon()); assertFilterMatches(couponPayload, matching.getId()); // backendEntry filter ObjectNode backendPayload = queryWithMarker(marker); backendPayload.put("backendEntry", matching.getBackendEntry()); assertFilterMatches(backendPayload, matching.getId()); // firstOrder filter ObjectNode firstOrderPayload = queryWithMarker(marker); firstOrderPayload.put("firstOrder", matching.getFirstOrder()); assertFilterMatches(firstOrderPayload, matching.getId()); // groupId filter ObjectNode groupPayload = queryWithMarker(marker); groupPayload.put("groupId", matching.getGroupId()); assertFilterMatches(groupPayload, matching.getId()); // sex filter ObjectNode sexPayload = queryWithMarker(marker); sexPayload.put("sex", matching.getSex()); assertFilterMatches(sexPayload, matching.getId()); // purchaserTime range filter ObjectNode purchaserTimePayload = queryWithMarker(marker); purchaserTimePayload.set("purchaserTime", range( reference.minusMinutes(30), reference.plusMinutes(30))); assertFilterMatches(purchaserTimePayload, matching.getId()); // acceptTime range filter ObjectNode acceptTimePayload = queryWithMarker(marker); acceptTimePayload.set("acceptTime", range( matching.getAcceptTime().minusMinutes(15), matching.getAcceptTime().plusMinutes(15))); assertFilterMatches(acceptTimePayload, matching.getId()); // endOrderTime range filter ObjectNode endTimePayload = queryWithMarker(marker); endTimePayload.set("endOrderTime", range( matching.getOrderEndTime().minusMinutes(15), matching.getOrderEndTime().plusMinutes(15))); assertFilterMatches(endTimePayload, matching.getId()); // Combined filters to verify logical AND behaviour ObjectNode combinedPayload = queryWithMarker(marker); combinedPayload.put("acceptBy", matching.getAcceptBy()); combinedPayload.put("purchaserBy", matching.getPurchaserBy()); combinedPayload.put("orderStatus", matching.getOrderStatus()); combinedPayload.put("placeType", matching.getPlaceType()); combinedPayload.put("payMethod", matching.getPayMethod()); combinedPayload.put("useCoupon", matching.getUseCoupon()); combinedPayload.put("backendEntry", matching.getBackendEntry()); combinedPayload.put("firstOrder", matching.getFirstOrder()); combinedPayload.put("groupId", matching.getGroupId()); combinedPayload.put("sex", matching.getSex()); combinedPayload.set("purchaserTime", range( reference.minusMinutes(5), reference.plusMinutes(5))); combinedPayload.set("acceptTime", range( matching.getAcceptTime().minusMinutes(5), matching.getAcceptTime().plusMinutes(5))); combinedPayload.set("endOrderTime", range( matching.getOrderEndTime().minusMinutes(5), matching.getOrderEndTime().plusMinutes(5))); assertFilterMatches(combinedPayload, matching.getId()); } @Test void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception { ensureTenantContext(); String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase(); LocalDateTime reference = LocalDateTime.now().plusHours(2); PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> { order.setOrderStatus(OrderStatus.COMPLETED.getCode()); }); PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> { order.setOrderStatus(OrderStatus.COMPLETED.getCode()); }); ObjectNode orderNoPayload = baseQuery(); orderNoPayload.put("keyword", orderByNo.getOrderNo()); assertFilterMatches(orderNoPayload, orderByNo.getId()); ObjectNode clerkKeywordPayload = baseQuery(); clerkKeywordPayload.put("keyword", "小测官"); clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15))); clerkKeywordPayload.put("placeType", "1"); RecordsResponse clerkResponse = executeList(clerkKeywordPayload); JsonNode clerkRecords = clerkResponse.records; assertThat(clerkRecords.size()).isGreaterThan(0); List ids = new ArrayList<>(); clerkRecords.forEach(node -> ids.add(node.path("id").asText())); assertThat(ids).contains(orderByClerk.getId()); } @Test void listByPage_keywordRespectsAdditionalFilters() throws Exception { ensureTenantContext(); String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase(); LocalDateTime reference = LocalDateTime.now().plusHours(3); PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> { order.setOrderStatus("3"); order.setPlaceType("0"); }); persistOrder(marker, "random", reference.minusMinutes(20), order -> { order.setOrderStatus("3"); order.setPlaceType("1"); }); ObjectNode keywordAndFilterPayload = baseQuery(); keywordAndFilterPayload.put("keyword", "小测官"); keywordAndFilterPayload.put("placeType", "0"); keywordAndFilterPayload.set("purchaserTime", range(reference.minusMinutes(2), reference.plusMinutes(2))); RecordsResponse filteredResponse = executeList(keywordAndFilterPayload); JsonNode records = filteredResponse.records; assertThat(records.size()).isEqualTo(1); assertThat(records.get(0).path("id").asText()).isEqualTo(assignedOrder.getId()); } @Test void revokeCompletedOrder_keepEarningsIgnoresLockedLines() throws Exception { ensureTenantContext(); LocalDateTime reference = LocalDateTime.now().minusHours(1); PlayOrderInfoEntity order = persistOrder("RVK", "keep", reference, entity -> { entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setFinalAmount(new BigDecimal("166.00")); }); 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("deductClerkEarnings", false); 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()) .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_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")); }); 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("deductClerkEarnings", true); payload.put("deductAmount", new BigDecimal("20.00")); 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()) .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(); ensureTenantContext(); List lines = earningsService.lambdaQuery() .eq(EarningsLineEntity::getOrderId, order.getId()) .list(); assertThat(lines).hasSize(2); EarningsLineEntity negativeLine = lines.stream() .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0) .findFirst() .orElseThrow(() -> new AssertionError("未生成负收益行")); assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00")); assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy()); earningsLineIdsToCleanup.add(negativeLine.getId()); } @Test void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception { ensureTenantContext(); LocalDateTime reference = LocalDateTime.now().minusHours(1); PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> { entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setFinalAmount(new BigDecimal("166.00")); }); 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", 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) .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.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("80.00")); refundIdsToCleanup.add(refundInfo.getId()); 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("-60.00")); 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(); LocalDateTime reference = LocalDateTime.now().minusMinutes(10); PlayOrderInfoEntity giftOrder = persistOrder("RVK", "gift", reference, entity -> { entity.setOrderStatus(OrderStatus.COMPLETED.getCode()); entity.setOrderType(OrderConstant.OrderType.GIFT.getCode()); entity.setPlaceType(OrderConstant.PlaceType.REWARD.getCode()); }); ObjectNode payload = objectMapper.createObjectNode(); payload.put("orderId", giftOrder.getId()); payload.put("refundToCustomer", false); payload.put("refundReason", "gift revoke"); payload.put("deductClerkEarnings", false); 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 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, LocalDateTime purchaserTime, Consumer customizer) { PlayOrderInfoEntity order = buildBaselineOrder(marker, token, purchaserTime); customizer.accept(order); assertThat(orderInfoService.save(order)) .withFailMessage("Failed to persist order %s", order.getOrderNo()) .isTrue(); orderIdsToCleanup.add(order.getId()); return order; } private PlayOrderInfoEntity buildBaselineOrder(String marker, String token, LocalDateTime purchaserTime) { PlayOrderInfoEntity order = new PlayOrderInfoEntity(); order.setId("order-" + token + "-" + IdUtils.getUuid().substring(0, 8)); String tokenFragment = token.length() >= 2 ? token.substring(0, 2) : token; order.setOrderNo(marker + tokenFragment.toUpperCase() + IdUtils.getUuid().substring(0, 4)); order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); order.setOrderType("2"); order.setPlaceType("1"); order.setRewardType("0"); order.setFirstOrder("0"); order.setRefundType("0"); order.setRefundAmount(BigDecimal.ZERO); order.setRefundReason(null); order.setOrderMoney(DEFAULT_AMOUNT); order.setDiscountAmount(BigDecimal.ZERO); order.setFinalAmount(DEFAULT_AMOUNT); order.setEstimatedRevenue(new BigDecimal("88.00")); order.setEstimatedRevenueRatio(50); order.setLabels(Collections.singletonList("label-" + marker)); order.setUseCoupon("1"); order.setCouponIds(Collections.singletonList("coupon-" + marker)); order.setBackendEntry("1"); order.setPaymentSource("balance"); order.setPayMethod("2"); order.setPayState("2"); order.setWeiChatCode("wx-" + marker); order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); order.setPurchaserTime(purchaserTime); order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID); order.setAcceptTime(purchaserTime.plusMinutes(20)); order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID); order.setOrderStartTime(purchaserTime.plusMinutes(30)); order.setOrderEndTime(purchaserTime.plusHours(2)); order.setOrderSettlementState("0"); order.setOrdersExpiredState("0"); order.setSex("2"); order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID); order.setCommodityType("1"); order.setCommodityPrice(DEFAULT_AMOUNT); order.setCommodityName("API Filter Commodity"); order.setServiceDuration("60min"); order.setCommodityNumber("1"); order.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); order.setExcludeHistory("0"); order.setRemark("marker-" + marker); order.setBackendRemark("backend-" + marker); order.setProfitSharingAmount(BigDecimal.ZERO); order.setDeleted(Boolean.FALSE); order.setCreatedBy("apitest"); order.setUpdatedBy("apitest"); order.setCreatedTime(toDate(purchaserTime)); order.setUpdatedTime(toDate(purchaserTime.plusMinutes(45))); return order; } private ObjectNode baseQuery() { ObjectNode node = objectMapper.createObjectNode(); node.put("pageNum", 1); node.put("pageSize", 20); return node; } private ObjectNode queryWithMarker(String marker) { ObjectNode node = baseQuery(); node.put("orderNo", marker); return node; } private ArrayNode range(LocalDateTime start, LocalDateTime end) { ArrayNode array = objectMapper.createArrayNode(); array.add(DATE_TIME_FORMATTER.format(start)); array.add(DATE_TIME_FORMATTER.format(end)); return array; } 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); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); entity.setOrderId(orderId); entity.setAmount(amount); entity.setStatus(status); entity.setEarningType(EarningsType.ORDER); 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; } private void assertFilterMatches(ObjectNode payload, String expectedOrderId) throws Exception { RecordsResponse response = executeList(payload); JsonNode records = response.records; assertThat(records.isArray()) .withFailMessage("Records payload is not an array for body=%s | response=%s", payload, response.rawResponse) .isTrue(); assertThat(records.size()) .withFailMessage("Unexpected record count for body=%s | response=%s", payload, response.rawResponse) .isEqualTo(1); assertThat(records.get(0).path("id").asText()) .withFailMessage("Unexpected order id for body=%s | response=%s", payload, response.rawResponse) .isEqualTo(expectedOrderId); } private RecordsResponse executeList(ObjectNode payload) throws Exception { MvcResult result = mockMvc.perform(post("/order/order/listByPage") .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)) .andReturn(); String responseBody = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(responseBody); JsonNode data = root.path("data"); JsonNode records = data.isArray() ? data : data.path("records"); return new RecordsResponse(records, responseBody); } private Date toDate(LocalDateTime time) { return Date.from(time.atZone(ZoneId.systemDefault()).toInstant()); } private void ensureTenantContext() { SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); } private static class RecordsResponse { private final JsonNode records; private final String rawResponse; private RecordsResponse(JsonNode records, String rawResponse) { this.records = records; this.rawResponse = rawResponse; } } }