This commit is contained in:
2026-09-01 21:09:33 +08:00
parent 47723c7c0f
commit e82cefe493
25 changed files with 712 additions and 40 deletions
@@ -0,0 +1,52 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos;
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.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class OrderDeclarationUpdateContractTest {
@Test
void endpointUsesDedicatedPermissionAndVersionedDto() throws Exception {
var method = PlatformUnifiedOrderController.class.getDeclaredMethod("updateDeclaration",
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
OrderDeclarationDtos.UpdateRequest.class);
assertThat(method.getAnnotation(PutMapping.class).value()).containsExactly("/{orderId}/declaration");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:order:declaration:edit')");
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
"totalQuantity", "lengthCm", "widthCm", "heightCm", "grossWeightKg", "volumeCbm")
.doesNotContain("id", "orderId", "actualGrossWeightKg", "actualVolumeCbm",
"measurementReviewStatus", "feeReviewStatus");
}
@Test
void openApiSeparatesDeclaredAndMeasuredFacts() throws Exception {
String source = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
assertThat(map(document.get("paths")))
.containsKey("/api/platform/v1/orders/{orderId}/declaration");
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
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");
}
@SuppressWarnings("unchecked")
private Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
}
@@ -52,6 +52,7 @@ class DomesticWarehouseOrderExportProviderServiceTest {
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId",
"receivingWarehouseId", "destinationWarehouseId");
assertThat(response.columns()).extracting(column -> column.key())
.contains("expectedVolumeCbm", "expectedArrivalAt", "channelChanged")
.doesNotContain("orderId", "tenantId", "customerId", "inboundId");
verify(orders, never()).platformPage(any(), any());
}
@@ -0,0 +1,148 @@
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;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderDeclarationUpdateService.class)
class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders;
@MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service;
@Test
void updatesCompleteCustomerDeclarationByItemNoAndKeepsInboundCode() {
OrderEntity order = order(OrderStatus.SUBMITTED, 3);
OrderCargoEntity cargo = cargo();
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo));
when(repository.updateDeclarationHeader(eq(100L), eq(200L), eq(3), eq(700L),
any(), any(), anyString(), anyString(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1);
var request = request(3, "NEW", "新渠道", "新备注");
var response = service.update(100L, 200L, 700L, "平台操作员", request);
assertThat(response.orderNo()).isEqualTo("ORD-200");
assertThat(response.inboundCode()).isEqualTo("RK-LOCKED");
assertThat(response.version()).isEqualTo(4);
assertThat(response.channelChanged()).isTrue();
assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field)
.contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt",
"cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg");
verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA");
verify(repository).insertHistory(any());
verify(repository, never()).insertOutbox(any());
assertThat(cargo.getPackageCount()).isEqualTo(20);
assertThat(cargo.getTotalQuantity()).isEqualTo(200);
assertThat(cargo.getLengthCm()).isEqualByComparingTo("60");
}
@Test
void rejectsStaleVersionBeforeAnyWrite() {
when(repository.findOwned(100L, 200L)).thenReturn(order(OrderStatus.SUBMITTED, 4));
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 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 {
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", "旧渠道", "新备注"));
verify(repository).insertOutbox(any());
}
private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
order.setInboundCode("RK-LOCKED"); order.setStatus(status); order.setVersion(version);
order.setReceivingWarehouseId(31L); order.setDestinationType("FBA");
order.setChannelCode("OLD"); order.setChannelName("旧渠道"); order.setChannelChanged(false);
order.setCustomerRemark("旧备注");
return order;
}
private OrderCargoEntity cargo() {
OrderCargoEntity value = new OrderCargoEntity();
value.setId(300L); value.setTenantId(100L); value.setOrderId(200L); value.setItemNo(1); value.setVersion(2);
value.setCargoName("旧品名"); value.setCargoNameEn("Old"); value.setSkuNo("SKU-OLD"); value.setModel("M1");
value.setPackageCount(10); value.setTotalQuantity(100); value.setLengthCm(new BigDecimal("50"));
value.setWidthCm(new BigDecimal("40")); value.setHeightCm(new BigDecimal("30"));
value.setGrossWeightKg(new BigDecimal("100")); value.setVolumeCbm(new BigDecimal("1.0"));
return value;
}
private OrderDeclarationDtos.UpdateRequest request(int version, String channelCode,
String channelName, String remark) {
var cargo = new OrderDeclarationDtos.Cargo(1, "新品名", "New", "SKU-NEW", "M2",
20, 200, new BigDecimal("60"), new BigDecimal("50"), new BigDecimal("40"),
new BigDecimal("120"), new BigDecimal("1.2"), new BigDecimal("110"),
"8518", "851821", 10, new BigDecimal("5"), new BigDecimal("1000"),
"USD", "塑料", "音响", "Brand", "新备注");
return new OrderDeclarationDtos.UpdateRequest(version, remark,
LocalDateTime.of(2026, 9, 15, 10, 30), channelCode, channelName, List.of(cargo));
}
}
@@ -129,6 +129,22 @@ class OrderInboundOutboxPublisherTest {
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
}
@Test
void declarationUpdateEventIsDeliveredByVersionedContract() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],\"changes\":[]}";
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any());
}
private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION");
}
@@ -98,9 +98,10 @@ class UnifiedOrderQueryServiceTest {
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.totalPackageCount()).isEqualTo(100);
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5");
assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2");
assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.actualGrossWeightKg()).isNull();
assertThat(row.totalNetWeightKg()).isNull();
assertThat(row.actualVolumeCbm()).isNull();
});
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
}