This commit is contained in:
2026-09-03 21:25:53 +08:00
parent 171f450e59
commit dd1682a83b
24 changed files with 937 additions and 58 deletions
@@ -32,12 +32,17 @@ class PlatformSmartLoadingControllerContractTest {
yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:"));
assertThat(contract).contains("name: channelGroupCodes")
.contains("name: warehouseId")
.contains("name: createdStart")
.contains("name: createdEnd")
.contains("SmartLoadingInboundCandidatePageEnvelope")
.doesNotContain("name: expectedArrivalStart")
.doesNotContain("name: expectedArrivalEnd")
.doesNotContain("name: bookingId");
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class);
authority("assignPendingReview","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignPendingReviewRequest.class);
authority("assignGroup","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignGroupRequest.class);
authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
@@ -55,7 +60,10 @@ class PlatformSmartLoadingControllerContractTest {
assertThat(yaml)
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:")
.contains("SmartLoadingInboundAssignRequest:")
.contains("SmartLoadingPendingReviewAssignRequest:")
.contains("订单审核状态不作为清单占位门禁")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
@@ -47,7 +47,7 @@ class LoadingModelBridgeServiceTest {
@Test
void bindsEveryAllocatedInboundBeforeLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
@@ -69,7 +69,7 @@ class LoadingModelBridgeServiceTest {
var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9);
when(repository.loadingPlan(40)).thenReturn(plan);
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
Link first = link("PENDING", 3, "EDITING", 9, 8);
@@ -88,7 +88,7 @@ class LoadingModelBridgeServiceTest {
void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(null);
when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5));
@@ -115,7 +115,7 @@ class LoadingModelBridgeServiceTest {
@Test
void refusesPostHocBindingAfterLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.hasMessageContaining("禁止在LOADING后补关联");
@@ -126,7 +126,7 @@ class LoadingModelBridgeServiceTest {
@Test
void loadingReplayOnlyReadsAlreadyCompleteLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity();
when(repository.byPair(10, 20, 40)).thenReturn(existing);
Link linked = link("PENDING", 3, "LOADING", 4, 8);
@@ -171,7 +171,7 @@ class LoadingModelBridgeServiceTest {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
when(repository.byLoadingPlan(40)).thenReturn(List.of(
link("PENDING", 2, "EDITING", 3, 8)));
@@ -179,6 +179,19 @@ class LoadingModelBridgeServiceTest {
.hasMessageContaining("关联不完整");
}
@Test
void confirmationGateRejectsProvisionalPendingReviewAllocation() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8, "PREPARING")));
assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40))
.hasMessageContaining("未完成审核和测量");
verify(repository, never()).byLoadingPlan(anyLong());
}
@Test
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
when(repository.byInbound(10, 20)).thenReturn(List.of(
@@ -86,35 +86,34 @@ class SmartLoadingCandidateMapperContractTest {
}
@Test
void globalCandidateDateFilterUsesTheSameRepresentativeArrivalAsTheReturnedUnit() throws Exception {
void globalCandidateDateFilterUsesTheRepresentativeOrderCreationDate() throws Exception {
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
Page.class, InboundCandidateQuery.class, Set.class);
String sql = String.join(" ", units.getAnnotation(Select.class).value());
assertThat(sql)
.contains("MIN(eligible.expectedArrivalAt) sortArrivalAt")
.contains("MIN(eligible.expectedArrivalAt) IS NOT NULL")
.contains("DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}")
.contains("DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}")
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
.contains("o.created_at createdAt")
.contains("DATE(MIN(eligible.createdAt))>=#{query.createdStart}")
.contains("DATE(MIN(eligible.createdAt))<=#{query.createdEnd}")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd");
}
@Test
void representativeArrivalControlsWhetherTheWholeGroupMatchesTheClosedDateRange() {
List<LocalDateTime> groupArrivals = List.of(
void representativeCreationDateControlsWhetherTheWholeGroupMatchesTheClosedDateRange() {
List<LocalDateTime> groupCreatedTimes = List.of(
LocalDateTime.parse("2026-09-01T09:00:00"),
LocalDateTime.parse("2026-09-10T09:00:00"));
LocalDate representative = groupArrivals.stream().min(LocalDateTime::compareTo)
LocalDate representative = groupCreatedTimes.stream().min(LocalDateTime::compareTo)
.orElseThrow().toLocalDate();
assertThat(matchesClosedRange(representative,
LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10")))
.as("another member matching must not admit a group whose MIN arrival is outside")
.as("another member matching must not admit a group whose MIN creation date is outside")
.isFalse();
assertThat(matchesClosedRange(representative,
LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01")))
.as("a matching MIN arrival admits the whole group")
.as("a matching MIN creation date admits the whole group")
.isTrue();
}
@@ -131,9 +130,9 @@ class SmartLoadingCandidateMapperContractTest {
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
assertThat(renderedUnits)
.contains("o.channel_code IN", "MIN(eligible.expectedArrivalAt) IS NOT NULL")
.contains("DATE(MIN(eligible.expectedArrivalAt))>=", "DATE(MIN(eligible.expectedArrivalAt))<=")
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
.contains("o.channel_code IN")
.contains("DATE(MIN(eligible.createdAt))>=", "DATE(MIN(eligible.createdAt))<=")
.doesNotContain("DATE(MIN(eligible.expectedArrivalAt))");
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class);
@@ -158,8 +157,9 @@ class SmartLoadingCandidateMapperContractTest {
.contains("fl_loading_plan_item filter_item")
.contains("fl_inbound_order filter_inbound")
.contains("filter_item.inbound_code LIKE")
.contains("filter_inbound.expected_arrival_at")
.contains("filter_inbound.created_at")
.contains("filter_inbound.process_status='LOADING'")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd")
.doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
}
@@ -5,7 +5,9 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
@@ -68,6 +70,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
@@ -81,6 +84,8 @@ import static org.mockito.Mockito.times;
class SmartLoadingServiceTest {
@MockBean private SmartLoadingRepository repository;
@MockBean private OrderCargoClient cargoClient;
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@@ -207,6 +212,97 @@ class SmartLoadingServiceTest {
verify(repository, never()).booking(anyLong());
}
@Test
void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361",
LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"),
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361",
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
"PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
isNull(), eq("EXXHK2609020361"), eq(LocalDate.parse("2026-09-02")),
eq(LocalDate.parse("2026-09-02")), eq(1L), eq(20L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
var result = service.inboundCandidates(query);
assertThat(result.records()).singleElement().satisfies(candidate -> {
assertThat(candidate.orderId()).isEqualTo(2095052002489335810L);
assertThat(candidate.inboundId()).isNull();
assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361");
assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW");
assertThat(candidate.blockReason()).isNull();
assertThat(candidate.availablePackageCount()).isEqualTo(100);
});
verify(repository, never()).globalCandidateUnits(any(), anySet());
verify(repository, never()).globalCandidatesByUnits(anyList());
}
@Test
void assignsPendingReviewOrderAsAProvisionalContainerAllocation() {
long customerId = 2092797167699816450L;
long orderId = 2095052002489335810L;
String inboundCode = "EXXHK2609020361";
var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode,
customerId, "客户", 100L, "FBA", "FBA渠道", 5,
LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId),
eq(inboundCode), isNull(), isNull(), eq(1L), eq(2L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1)));
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(10L);
when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(List.of(label)));
when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L)))
.thenReturn(ApiResponse.success(cargo));
when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1));
when(repository.booking(99L)).thenReturn(booking());
InboundOrderEntity lockedInbound = new InboundOrderEntity();
lockedInbound.setProcessStatus("PREPARING");
when(repository.inboundForUpdate(customerId, 10L)).thenReturn(lockedInbound);
when(repository.items(1L)).thenReturn(List.of());
when(repository.touch(1L, 0, 7L, "u")).thenReturn(1);
Plan result = service.assignPendingReview(1L,
new AssignPendingReviewRequest(customerId, orderId, inboundCode, 5, 0, "pending-1"),
7L, "u");
assertThat(result.version()).isEqualTo(1);
ArgumentCaptor<LoadingPlanItemEntity> item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(item.capture());
assertThat(item.getValue().getInboundId()).isEqualTo(10L);
assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L);
assertThat(item.getValue().getPackageCount()).isEqualTo(5);
assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50");
verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u");
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1");
}
@Test
void failsClosedWhenPendingReviewOrderSourceIsUnavailable() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null,
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
assertThatThrownBy(() -> service.inboundCandidates(query))
.isInstanceOf(BizException.class)
.hasMessage("待审核订单服务暂时不可用");
}
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
@@ -69,6 +69,20 @@ class SplitPreloadServiceTest {
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立");
}
@Test
void pendingReviewPreAllocationCreatesAProvisionalPreloadLifecycle() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "PREPARING", 0));
when(repository.findPlan(10, 20)).thenReturn(null);
service.ensurePendingForPreAllocation(10, 20, 7, "admin");
org.mockito.ArgumentCaptor<PreloadPlanEntity> captor =
org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class);
verify(repository).createPlan(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo("PENDING");
assertThat(captor.getValue().getRemark()).contains("审核前");
}
@Test
void loadingAllocationDoesNotOverwriteConfirmedPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));