This commit is contained in:
2026-09-02 10:34:02 +08:00
44 changed files with 1003 additions and 172 deletions
@@ -1,10 +1,13 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.client.FirstlegPricingClient;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
@@ -26,7 +29,9 @@ class OrderDeclarationUpdateContractTest {
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes");
"channelCode", "channelName", "cargoes")
.doesNotContain("receivingWarehouseId", "receivingWarehouseName",
"destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
@@ -46,7 +51,26 @@ class OrderDeclarationUpdateContractTest {
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409");
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409",
"/internal/firstleg/v1/inbounds/order-channel-changes/validate",
"/internal/firstleg/v1/pricing/order-channel-changes/validate",
"/internal/firstleg/v1/pricing/order-channel-changes/apply");
}
@Test
void approvedChannelChangeUsesFormalFulfillmentAndPricingValidationContracts() throws Exception {
var fulfillment = FirstlegInboundClient.class.getDeclaredMethod("validateOrderChannelChange",
String.class, FirstlegInboundClient.OrderChannelChangeValidationRequest.class);
var pricing = FirstlegPricingClient.class.getDeclaredMethod("validateOrderChannelChange",
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
var applyPricing = FirstlegPricingClient.class.getDeclaredMethod("applyOrderChannelChange",
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
assertThat(fulfillment.getAnnotation(PostMapping.class).value())
.containsExactly("/order-channel-changes/validate");
assertThat(pricing.getAnnotation(PostMapping.class).value())
.containsExactly("/order-channel-changes/validate");
assertThat(applyPricing.getAnnotation(PostMapping.class).value())
.containsExactly("/order-channel-changes/apply");
}
@Test
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
@@ -33,7 +32,7 @@ import static org.mockito.Mockito.when;
class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders;
@MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service;
@@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest {
}
@Test
void approvedOrderWithMeasurementFactIsAtomicallyRejected() {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0));
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "备注")))
.isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实");
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
@Test
void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception {
void approvedOrderCanRefreshCustomerDeclarationWithoutTouchingMeasurementFacts() throws Exception {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.feeReviewStatus()).thenReturn("UNRECORDED");
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注"));
var response = service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "测量后更新申报"));
assertThat(response.version()).isEqualTo(4);
verify(pricingChannelGuard, never()).requireAvailable(anyLong(), anyLong(), anyString(), anyString());
verify(repository).insertOutbox(any());
}
@Test
void approvedChannelChangePassesBothGatesBeforeVersionedOutboxSnapshot() throws Exception {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
service.update(100L, 200L, 700L, "平台操作员", request(3, "NEW", "新渠道", "新备注"));
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
verify(pricingChannelGuard).requireOrderChannelChangeEligible(order, 4L, "NEW", "新渠道");
verify(repository).insertOutbox(any());
}
@Test
void approvedChannelChangeRunsBothRemoteGatesBeforeAnyWrite() {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"装柜已开始,渠道不可变更"))
.when(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "NEW", "新渠道", "备注")))
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
verify(pricingChannelGuard, never()).requireOrderChannelChangeEligible(any(), anyLong(), anyString(), anyString());
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
@@ -0,0 +1,52 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OrderEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderFulfillmentChannelChangeGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class OrderFulfillmentChannelChangeGuardTest {
@MockBean private FirstlegInboundClient client;
@Resource private OrderFulfillmentChannelChangeGuard guard;
@Test
void validatesNextOrderVersionAgainstFirstlegFulfillmentFacts() {
OrderEntity order = order();
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
new FirstlegInboundClient.OrderChannelChangeValidationResponse(21L, 5L, "MATSON_TRUCK", "WAIT_SPLIT")));
guard.requireEligible(order, 5L, "MATSON_TRUCK");
verify(client).validateOrderChannelChange("token", request);
}
@Test
void keepsIrreversibleLoadingBusinessErrorAndFailsBeforeOrderWrite() {
OrderEntity order = order();
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
when(client.validateOrderChannelChange("token", request)).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "装柜已开始,渠道不可变更", null, null));
assertThatThrownBy(() -> guard.requireEligible(order, 5L, "MATSON_TRUCK"))
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
}
private OrderEntity order() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setTenantId(11L);
return order;
}
}
@@ -2,6 +2,7 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -130,7 +131,7 @@ class OrderInboundOutboxPublisherTest {
}
@Test
void declarationUpdateEventIsDeliveredByVersionedContract() {
void ordinaryDeclarationUpdateDoesNotInvalidatePricing() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
@@ -140,9 +141,53 @@ class OrderInboundOutboxPublisherTest {
fixture.publisher.publishApprovedOrders();
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
verify(fixture.client).updateDeclaration(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(command ->
command.version() == 4 && command.channelChanged()
&& "SEA".equals(command.channelCode()) && "RK-21".equals(command.inboundCode())));
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any());
verify(fixture.pricingClient, never()).applyOrderChannelChange(any(), any());
}
@Test
void channelChangeAppliesPricingInvalidationBySameOrderVersionBeforeDelivery() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-2\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
+ "\"changes\":[{\"field\":\"channelCode\",\"beforeValue\":\"OLD\",\"afterValue\":\"SEA\"}]}";
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegPricingClient.OrderChannelChangeResponse(
51L, 21L, 4L, "SEA", 61L, "UNPRICED", true, 71L)));
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
fixture.publisher.publishApprovedOrders();
verify(fixture.pricingClient).applyOrderChannelChange(eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(command -> command.orderVersion() == 4L
&& command.orderId() == 21L && "SEA".equals(command.channelCode())));
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
}
@Test
void channelPricingApplyFailureKeepsOutboxPendingAndDoesNotAdvanceFulfillmentSnapshot() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-3\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
+ "\"changes\":[{\"field\":\"channelName\",\"beforeValue\":\"旧渠道\",\"afterValue\":\"海运\"}]}";
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
"费用已确认或已发布,渠道不可变更", null, null));
fixture.publisher.publishApprovedOrders();
verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).updateDeclaration(any(), any());
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
}
private Fixture fixture(OutboxEventEntity event) {
@@ -152,6 +197,7 @@ class OrderInboundOutboxPublisherTest {
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
FirstlegPricingClient pricingClient = mock(FirstlegPricingClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
if (event.getTenantId() != null && event.getAggregateId() != null) {
var order = new com.baiyue.order.entity.OrderEntity();
@@ -163,6 +209,8 @@ class OrderInboundOutboxPublisherTest {
order.setReceivingWarehouseId(31L);
order.setReceivingWarehouseName("测试仓");
order.setDestinationType("OVERSEAS");
order.setDestinationWarehouseId(41L);
order.setAmazonWarehouseCode("ONT8");
order.setTransportMode("SEA");
order.setTaxMode(authoritativeTaxMode);
order.setPricingTaxMode("TAX_EXCLUDED");
@@ -177,9 +225,10 @@ class OrderInboundOutboxPublisherTest {
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
ReflectionTestUtils.setField(publisher, "repository", repository);
ReflectionTestUtils.setField(publisher, "client", client);
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
return new Fixture(publisher, repository, client);
return new Fixture(publisher, repository, client, pricingClient);
}
private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) {
@@ -207,6 +256,6 @@ class OrderInboundOutboxPublisherTest {
}
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
FirstlegInboundClient client) {
FirstlegInboundClient client, FirstlegPricingClient pricingClient) {
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OrderEntity;
import feign.FeignException;
import feign.Request;
import feign.Response;
@@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest {
.isEqualTo(ResultCode.CONFLICT);
}
@Test
void approvedChannelChangeUsesPricingAndConfirmedFeeGate() {
OrderEntity order = new OrderEntity();
order.setId(21L); order.setTenantId(11L); order.setReceivingWarehouseId(31L);
order.setDestinationType("FBA"); order.setDestinationWarehouseId(41L);
order.setAmazonWarehouseCode("ONT8"); order.setPricingTaxMode("TAX_INCLUDED");
var request = new FirstlegPricingClient.OrderChannelChangeRequest(
11L, 21L, 5L, 31L, "MATSON_TRUCK", "美森海卡",
"FBA", 41L, "ONT8", "TAX_INCLUDED");
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
new FirstlegPricingClient.OrderChannelChangeResponse(
51L, 21L, 5L, "MATSON_TRUCK", 61L, "CALCULATED", true, null)));
guard.requireOrderChannelChangeEligible(order, 5L, "MATSON_TRUCK", "美森海卡");
verify(client).validateOrderChannelChange("token", request);
}
@Test
void nullResponseMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);