Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	order/AGENTS.md
#	scripts/agents-index-contract.ps1
This commit is contained in:
2026-09-02 12:08:08 +08:00
18 changed files with 689 additions and 3 deletions
@@ -6,7 +6,9 @@ import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.service.OrderDeclarationSyncService;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.common.core.api.PageResult;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -15,6 +17,7 @@ import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -31,6 +34,8 @@ class InternalInboundOrderReferenceControllerTest {
private InboundCartonLabelService cartonLabels;
@MockBean
private UnifiedInboundQueryService unifiedQueryService;
@MockBean
private OrderDeclarationSyncService declarationSync;
@Resource
private InternalInboundOrderController controller;
@@ -85,6 +90,26 @@ class InternalInboundOrderReferenceControllerTest {
verify(cartonLabels).getOrCreate(10001L, 88L);
}
@Test
void exposesExactOrderChannelValidationContract() {
var request = new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "MATSON_NEW");
var result = new OrderDeclarationSync.ChannelChangeValidationResponse(77L, 4L, "MATSON_NEW", "WAIT_SPLIT");
when(declarationSync.validateChannelChange(request)).thenReturn(result);
assertThat(controller.validateOrderChannelChange("internal-token", request).data()).isEqualTo(result);
verify(declarationSync).validateChannelChange(request);
}
@Test
void exposesExactDeclarationEventConsumerContract() {
var event = new OrderDeclarationSync.UpdatedEvent(1, "event-4", 10001L, 77L, "FL-77", "RK-77",
4, 9L, LocalDateTime.of(2026, 9, 2, 10, 0), null, null, "MATSON_NEW", "新渠道",
true, List.of(), List.of());
assertThat(controller.updateOrderDeclaration("internal-token", event).successful()).isTrue();
verify(declarationSync).consume(event);
}
private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) {
return new OrderApprovedInboundCommand(
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
@@ -119,6 +119,9 @@ class PriceBookContractTest {
String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(firstlegOpenapi)
.contains("/internal/firstleg/v1/inbounds/order-channel-changes/validate:")
.contains("/internal/firstleg/v1/inbounds/order-declaration-updated:")
.contains("同版本异事件返回 A0409")
.contains("/internal/firstleg/v1/pricing/channels/validate:")
.contains("/internal/firstleg/v1/pricing/order-channel-changes/validate:")
.contains("/internal/firstleg/v1/pricing/order-channel-changes/apply:")
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OrderDeclarationSyncMapperContractTest {
@Test
void mapperIsExplicitAndProjectionUpdateCannotOverwriteOperationalFacts() throws Exception {
assertThat(OrderDeclarationSyncMapper.class).hasAnnotation(Mapper.class);
assertThat(OrderDeclarationSyncMapper.class
.getDeclaredMethod("lockInbound", long.class, long.class).getReturnType())
.isEqualTo(OrderDeclarationSyncMapper.InboundRow.class);
assertThat(OrderDeclarationSyncMapper.InboundRow.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("inboundId", "orderId", "orderNo", "inboundCode", "processStatus", "channelCode");
String sql = String.join(" ", OrderDeclarationSyncMapper.class
.getDeclaredMethod("updateAllowedSnapshot", long.class, long.class, long.class, String.class,
String.class, String.class, java.time.LocalDateTime.class, String.class, String.class, long.class)
.getAnnotation(Update.class).value());
assertThat(sql)
.contains("public_remark", "expected_arrival_at", "channel_code", "channel_name")
.doesNotContain("actual_net_weight", "actual_volume", "fee_status", "SET inbound_code",
"pallet", "box_code", "process_status");
}
@Test
void sqlBaselineKeepsCurrentSnapshotAndPermanentEventLedgerSeparate() throws Exception {
String sql = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot")
.contains("CREATE TABLE IF NOT EXISTS fl_order_declaration_event")
.contains("PRIMARY KEY (tenant_id, event_id)")
.contains("不覆盖实测事实");
}
}
@@ -96,6 +96,22 @@ class OrderChannelChangePricingServiceTest {
verify(repository, never()).applyOrderChannelChange(any(), any(), any());
}
@Test
void sameOrderVersionRejectsDifferentChannelNamePayload() {
InboundPricingEntity inbound = inbound("UNPRICED");
InboundPricingIdempotencyEntity token = token(4L, "COMPLETED");
token.setRequestHash(requestHash(request(4)));
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token);
Request changedName = new Request(10001L, 77L, 4L, 9L, "MATSON_NEW", "伪造名称",
"FBA", 501L, "ONT8", "TAX_INCLUDED");
assertThatThrownBy(() -> service.apply(changedName))
.isInstanceOf(BizException.class)
.hasMessageContaining("同一订单版本已用于不同渠道变更");
verify(estimator, never()).estimate(any());
}
@Test
void rejectsOlderOrderVersionAfterNewerVersionWasApplied() {
InboundPricingEntity inbound = inbound("UNPRICED");
@@ -0,0 +1,137 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.firstleg.mapper.OrderDeclarationSyncMapper;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
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.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({OrderDeclarationSyncService.class, OrderDeclarationSyncServiceTest.JsonConfig.class})
class OrderDeclarationSyncServiceTest {
@Configuration
static class JsonConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@MockBean private OrderDeclarationSyncMapper mapper;
@Resource private OrderDeclarationSyncService service;
@Resource private ObjectMapper json;
@Test
void allowsOnlyCompatibleUnallocatedFulfillment() {
when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn(
row("WAIT_SPLIT", 0, 0, 0, 0));
var response = service.validateChannelChange(
new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "matson_new"));
assertThat(response.channelCode()).isEqualTo("MATSON_NEW");
assertThat(response.mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
void rejectsPdaFactWithoutMutatingAnything() {
when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn(
row("WAIT_SPLIT", 0, 0, 1, 1));
assertThatThrownBy(() -> service.validateChannelChange(
new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "MATSON_NEW")))
.isInstanceOf(BizException.class)
.hasMessageContaining("PDA装柜任务");
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
}
@Test
void persistsVersionedDeclarationAndUpdatesOnlyAllowedInboundProjection() {
var event = event(4, "event-4");
when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE"));
when(mapper.insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(), any(),
anyString(), anyString(), anyString(), anyString(), any())).thenReturn(1);
when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).thenReturn(1);
when(mapper.updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注",
event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L)).thenReturn(1);
service.consume(event);
verify(mapper).updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注",
event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L);
}
@Test
void ignoresOlderVersionWithoutOverwritingCurrentProjection() {
var event = event(4, "event-4");
when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE"));
when(mapper.lockSnapshot(10001L, 77L)).thenReturn(
new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-5", 5, "hash"));
when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).thenReturn(1);
service.consume(event);
verify(mapper, never()).insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(),
any(), anyString(), anyString(), anyString(), anyString(), any());
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
verify(mapper).insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString());
}
@Test
void sameEventAndPayloadIsIdempotent() throws Exception {
var event = event(4, "event-4");
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json.writeValueAsBytes(event)));
when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE"));
when(mapper.byEvent(10001L, "event-4")).thenReturn(
new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-4", 4, hash));
service.consume(event);
verify(mapper, never()).lockSnapshot(anyLong(), anyLong());
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
}
private static OrderDeclarationSyncMapper.GateRow row(String status, Integer group, Integer split,
Integer preload, Integer pda) {
return new OrderDeclarationSyncMapper.GateRow(88L, 77L, "FL-77", "RK-77", status,
"MATSON_OLD", group, split, preload, pda);
}
private static OrderDeclarationSyncMapper.InboundRow inboundRow(String status) {
return new OrderDeclarationSyncMapper.InboundRow(
88L, 77L, "FL-77", "RK-77", status, "MATSON_OLD");
}
private static OrderDeclarationSync.UpdatedEvent event(int version, String eventId) {
var cargo = new OrderDeclarationSync.Cargo(1, "衣服", "clothes", "SKU-1", null, 2, 20,
new BigDecimal("10"), new BigDecimal("20"), new BigDecimal("30"), new BigDecimal("37"),
new BigDecimal("0.102"), null, null, null, null, null, null, "CNY",
null, null, null, null);
return new OrderDeclarationSync.UpdatedEvent(1, eventId, 10001L, 77L, "FL-77", "RK-77",
version, 9L, LocalDateTime.of(2026, 9, 2, 10, 0), "客户新备注",
LocalDateTime.of(2026, 9, 3, 10, 0), "MATSON_NEW", "新渠道", true,
List.of(cargo), List.of(new OrderDeclarationSync.FieldChange(null, "channelCode",
"MATSON_OLD", "MATSON_NEW")));
}
}