diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java index cc453bef8..8e1f4f6ae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java @@ -23,6 +23,7 @@ public interface PendingReviewOrderClient { @RequestParam(required = false) String inboundCode, @RequestParam(required = false) String createdStart, @RequestParam(required = false) String createdEnd, + @RequestParam(required = false) List excludedOrderIds, @RequestParam long page, @RequestParam long size); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index adecf0137..2896f01d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -28,6 +28,27 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba """) List planColoadMembers(@Param("planId") long planId); + @Select(""" + + """) + List occupiedOrderIds(@Param("query") InboundCandidateQuery query, + @Param("actualChannels") Set actualChannels); + @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, @@ -113,6 +134,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED', 'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING') ) + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_plan_item active_item + WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id + AND active_item.deleted=0 + ) ) eligible LEFT JOIN fl_coload_group_member current_member ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 8cdaf8ba6..84e1167c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -59,6 +59,9 @@ public class SmartLoadingRepository { public List planColoadMembers(long planId) { return candidates.planColoadMembers(planId); } + public List occupiedOrderIds(InboundCandidateQuery query, Set actualChannels) { + return candidates.occupiedOrderIds(query, actualChannels); + } public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { return candidates.candidate(planId, warehouseId, inboundId, detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 3ce02627b..8036e2801 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -253,6 +253,8 @@ public class LoadingPickService { Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); if (existing == null) { if (request.expectedAssignmentVersion() != 0 @@ -282,6 +284,8 @@ public class LoadingPickService { Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); int changed = existing == null ? request.expectedAssignmentVersion() == 0 @@ -310,6 +314,12 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + requireNoTaskOperation(operationId); + replay = repository.history(operationId); + if (replay != null) { + assertReplay(replay, taskId, itemId, requestHash); + return view(task, request.warehouseId()); + } LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); @@ -347,6 +357,12 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + requireNoTaskOperation(operationId); + replay = repository.history(operationId); + if (replay != null) { + assertReplay(replay, taskId, itemId, requestHash); + return view(task, request.warehouseId()); + } requireWarehouseItem(taskId, itemId, request.warehouseId()); if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(), request.version(), request.warehouseId()) != 1) { @@ -372,6 +388,12 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); + requireNoTaskOperation(operationId); + replay = repository.history(operationId); + if (replay != null) { + assertReplay(replay, taskId, itemId, requestHash); + return view(task, request.warehouseId()); + } LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index d80f70ffe..3bce6ddee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -125,24 +125,28 @@ public class SmartLoadingService { ? EnumSet.allOf(CandidateOrderStatus.class) : EnumSet.copyOf(query.orderStatuses()); boolean includePending = selected.remove(CandidateOrderStatus.PENDING_REVIEW); - InboundCandidateQuery firstlegQuery = withStatuses(query, List.copyOf(selected), 1, 1); if (!includePending) return firstlegCandidates(withStatuses(query, List.copyOf(selected), query.page(), query.size()), actualChannels); if (selected.isEmpty()) return pendingReviewCandidates(query, actualChannels, query.page(), query.size()); - PageResult pendingCount = pendingReviewCandidates(query, actualChannels, 1, 1); - PageResult firstlegCount = firstlegCandidates(firstlegQuery, actualChannels); - long total = pendingCount.total() + firstlegCount.total(); long offset = Math.multiplyExact(query.page() - 1, query.size()); + PageResult pendingPage = pendingReviewCandidates(query, actualChannels, + query.page(), query.size()); List records = new ArrayList<>(); - if (offset < pendingCount.total()) { - records.addAll(pendingReviewRange(query, actualChannels, offset, query.size())); - } - long firstlegOffset = Math.max(0, offset - pendingCount.total()); - int remaining = Math.toIntExact(query.size() - records.size()); - if (remaining > 0 && firstlegOffset < firstlegCount.total()) { - records.addAll(firstlegRange(query, actualChannels, List.copyOf(selected), firstlegOffset, remaining)); + PageResult firstlegPage; + if (offset < pendingPage.total()) { + records.addAll(pendingPage.records()); + int remaining = Math.toIntExact(query.size() - records.size()); + firstlegPage = firstlegCandidates(withStatuses(query, List.copyOf(selected), 1, + Math.max(1, remaining)), actualChannels); + if (remaining > 0) records.addAll(firstlegPage.records()); + } else { + long firstlegOffset = offset - pendingPage.total(); + firstlegPage = firstlegRange(query, actualChannels, List.copyOf(selected), + firstlegOffset, query.size()); + records.addAll(firstlegPage.records()); } + long total = pendingPage.total() + firstlegPage.total(); long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); return new PageResult<>(records, query.page(), query.size(), total, pages); } @@ -184,7 +188,7 @@ public class SmartLoadingService { try { response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(), query.customerId(), query.inboundCode(), isoDate(query.createdStart()), isoDate(query.createdEnd()), - page, size); + repository.occupiedOrderIds(query, actualChannels), page, size); } catch (RuntimeException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用"); } @@ -205,27 +209,20 @@ public class SmartLoadingService { return value == null ? null : value.toString(); } - private List pendingReviewRange(InboundCandidateQuery query, Set channels, - long offset, long limit) { - return pageRange(offset, limit, (page, size) -> pendingReviewCandidates(query, channels, page, size)); - } - - private List firstlegRange(InboundCandidateQuery query, Set channels, - List statuses, - long offset, long limit) { - return pageRange(offset, limit, (page, size) -> firstlegCandidates( - withStatuses(query, statuses, page, size), channels)); - } - - private static List pageRange(long offset, long limit, - java.util.function.BiFunction> source) { - if (limit <= 0) return List.of(); + private PageResult firstlegRange(InboundCandidateQuery query, Set channels, + List statuses, + long offset, long limit) { + if (limit <= 0) return new PageResult<>(List.of(), 1, 1, 0, 0); final long chunk = 200; long page = offset / chunk + 1; int skip = Math.toIntExact(offset % chunk); List result = new ArrayList<>(); + long total = 0; while (result.size() < limit) { - List rows = source.apply(page++, chunk).records(); + PageResult sourcePage = firstlegCandidates( + withStatuses(query, statuses, page++, chunk), channels); + total = sourcePage.total(); + List rows = sourcePage.records(); if (rows.isEmpty()) break; int from = Math.min(skip, rows.size()); int take = Math.min(Math.toIntExact(limit - result.size()), rows.size() - from); @@ -233,7 +230,8 @@ public class SmartLoadingService { skip = 0; if (rows.size() < chunk) break; } - return List.copyOf(result); + return new PageResult<>(List.copyOf(result), query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); } private static InboundCandidateQuery withStatuses(InboundCandidateQuery source, @@ -1268,7 +1266,8 @@ public class SmartLoadingService { private PendingReviewOrderClient.Row requirePendingReviewOrder(AssignPendingReviewRequest request) { try { ApiResponse> response = pendingReviewOrders.page( - internalToken, null, null, request.customerId(), request.inboundCode(), null, null, 1, 2); + internalToken, null, null, request.customerId(), request.inboundCode(), null, null, + null, 1, 2); if (response == null || !response.successful() || response.data() == null) throw unavailable(); return response.data().records().stream() .filter(row -> Objects.equals(row.orderId(), request.orderId()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java index 8ee5e9b2e..a5abbd653 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java @@ -134,9 +134,42 @@ class CabinetLoadingEvidenceServiceTest { verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), any()); } + @Test + void multipartReplayRejectsChangedContentBeforeUploadingAnotherFile() { + var first = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 3}); + var changed = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 4}); + CabinetLoadingEvidenceEntity existing = evidence("PHOTO_STEP_1"); + existing.setSourceTaskId(20L); + existing.setOperationKey("same-key"); + existing.setRequestHash(uploadHashForTest(8L, 0, "PHOTO_STEP_1", null, "remark", first)); + when(mapper.byOperation(20L, "same-key")).thenReturn(existing); + + assertThatThrownBy(() -> service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1", + null, "remark", changed, 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("请求内容不一致"); + + verify(files, never()).upload(anyString(), anyLong(), anyLong(), anyString(), any()); + } + private static CabinetLoadingEvidenceEntity evidence(String type) { CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L); return value; } + + private static String uploadHashForTest(long warehouseId, int expectedVersion, String evidenceType, + String sealNo, String remark, MockMultipartFile multipart) { + try { + var digest = java.security.MessageDigest.getInstance("SHA-256"); + String metadata = warehouseId + "|" + expectedVersion + "|" + evidenceType + "|" + + java.util.Objects.toString(sealNo, "") + "|" + java.util.Objects.toString(remark, "") + "|" + + java.util.Objects.toString(multipart.getOriginalFilename(), "") + "|" + + java.util.Objects.toString(multipart.getContentType(), "") + "|" + multipart.getSize() + "|"; + digest.update(metadata.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + digest.update(multipart.getBytes()); + return java.util.HexFormat.of().formatHex(digest.digest()); + } catch (java.io.IOException | java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 447ee11f6..aed9f79b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -75,6 +75,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("status.name() == 'WAIT_MEASURE'") .contains("status.name() == 'WAIT_SPLIT'") .contains("status.name() == 'LOADING'") + .contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0") .contains("current_group.id groupId") .contains("group_member_count.group_id=current_group.id") .doesNotContain("fl_container_booking") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 206ad7295..6373316f9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.client.WarehousePalletLocationClient; @@ -513,6 +514,99 @@ class LoadingPickServiceTest { verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED"); } + @Test + void claimRechecksSameOperationAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM"); + when(repository.operation("claim-race")).thenReturn(null, completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of()); + + Task replay = service.claim(20L, new ClaimRequest(0, "claim-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString()); + verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString()); + } + + @Test + void assignRechecksSameOperationAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN"); + when(repository.operation("assign-race")).thenReturn(null, completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of()); + + Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"), + 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString()); + verify(repository, never()).reassign(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString(), anyInt()); + } + + @Test + void pickRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK", + "20|30|101|2|MANUAL|[]"); + when(repository.history("pick-race")).thenReturn(null, completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.pick(20L, 30L, + new PickRequest(101L, 2, List.of(), 0, "pick-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).pick(anyLong(), anyInt(), anyString(), anyString(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void partialRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL", + "PARTIAL|20|30|101|1|MANUAL|[]"); + when(repository.history("partial-race")).thenReturn(null, completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.partial(20L, 30L, + new PartialPickRequest(101L, 1, List.of(), 0, "partial-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).partial(anyLong(), anyInt(), anyString(), anyString(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void skipRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP", + "SKIP|20|30|101|damaged"); + when(repository.history("skip-race")).thenReturn(null, completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.skip(20L, 30L, + new SkipRequest(101L, 0, "skip-race", "damaged"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).skip(anyLong(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + @Test void keepsPartialPickLockedUntilManualCompletion() { LoadingPickTaskEntity task = task(); @@ -1289,6 +1383,41 @@ class LoadingPickServiceTest { return assignment; } + private static LoadingPickOperationEntity taskOperation(String operationId, String action) { + LoadingPickOperationEntity operation = new LoadingPickOperationEntity(); + operation.setPickTaskId(20L); + operation.setOperationId(operationId); + operation.setRequestHash(hashForTest(action + "|20|0")); + if ("ASSIGN".equals(action)) { + operation.setRequestHash(hashForTest("ASSIGN|20|11|picker|0")); + } + operation.setActionType(action); + operation.setOperatorId(9L); + operation.setOperatorName("operator"); + return operation; + } + + private static LoadingPickHistoryEntity itemHistory(String operationId, String action, String canonical) { + LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); + history.setPickTaskId(20L); + history.setPickItemId(30L); + history.setOperationId(operationId); + history.setRequestHash(hashForTest(canonical)); + history.setActionType(action); + history.setOperatorId(9L); + history.setOperatorName("operator"); + return history; + } + + private static String hashForTest(String value) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) { GroupSnapshot group = mock(GroupSnapshot.class); when(group.groupId()).thenReturn(700L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 45a136ee9..d96b3e9b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -81,7 +81,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; -import static org.mockito.Mockito.atLeastOnce; @SpringJUnitConfig(SmartLoadingServiceTest.Config.class) class SmartLoadingServiceTest { @@ -108,6 +107,7 @@ class SmartLoadingServiceTest { when(documentNumbers.next("LP")).thenReturn("LP-260818-0001"); when(loadingPickService.mutationSnapshot(anyLong())).thenReturn( new LoadingPickService.MutationSnapshot(null, null, null, Map.of())); + when(repository.occupiedOrderIds(any(), anySet())).thenReturn(List.of()); when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); @@ -224,9 +224,9 @@ class SmartLoadingServiceTest { 100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"), "PENDING_REVIEW"); when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), - isNull(), isNull(), anyLong(), anyLong())).thenAnswer(invocation -> { - long page = invocation.getArgument(7); - long size = invocation.getArgument(8); + isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> { + long page = invocation.getArgument(8); + long size = invocation.getArgument(9); return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(), page, size, 1, 1)); }); @@ -248,11 +248,15 @@ class SmartLoadingServiceTest { .containsExactly(true, true); ArgumentCaptor firstlegQueries = ArgumentCaptor.forClass(InboundCandidateQuery.class); - verify(repository, atLeastOnce()).globalCandidateUnits(firstlegQueries.capture(), anySet()); + verify(repository).globalCandidateUnits(firstlegQueries.capture(), anySet()); + assertThat(firstlegQueries.getValue().page()).isEqualTo(1); + assertThat(firstlegQueries.getValue().size()).isEqualTo(19); assertThat(firstlegQueries.getAllValues()).allSatisfy(requested -> assertThat(requested.orderStatuses()).containsExactly( CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT, CandidateOrderStatus.LOADING)); + verify(pendingReviewOrders).page(anyString(), anyList(), isNull(), isNull(), isNull(), + isNull(), isNull(), anyList(), eq(1L), eq(20L)); } @Test @@ -311,9 +315,10 @@ class SmartLoadingServiceTest { "EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L, "EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW"); + when(repository.occupiedOrderIds(eq(query), anySet())).thenReturn(List.of(998L)); when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L), isNull(), eq("EXXHK2609020361"), eq("2026-09-02"), - eq("2026-09-02"), eq(1L), eq(20L))) + eq("2026-09-02"), eq(List.of(998L)), eq(1L), eq(20L))) .thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1))); var result = service.inboundCandidates(query); @@ -329,6 +334,7 @@ class SmartLoadingServiceTest { }); verify(repository, never()).globalCandidateUnits(any(), anySet()); verify(repository, never()).globalCandidatesByUnits(anyList()); + verify(repository).occupiedOrderIds(eq(query), anySet()); } @Test @@ -348,7 +354,7 @@ class SmartLoadingServiceTest { 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))) + eq(inboundCode), isNull(), 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)); @@ -430,7 +436,7 @@ class SmartLoadingServiceTest { List.of("EXX"), null, null, null, null, null, List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20); when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), - isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline")); + isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline")); assertThatThrownBy(() -> service.inboundCandidates(query)) .isInstanceOf(BizException.class) diff --git a/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java index 33a1394c1..b6daee25c 100644 --- a/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java +++ b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java @@ -23,6 +23,7 @@ public final class PendingReviewOrderDtos { @Size(max = 40) String inboundCode, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdStart, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdEnd, + List<@Min(1) Long> excludedOrderIds, @Min(1) long page, @Min(1) @Max(200) long size ) { diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index eb793e6d3..18f80073c 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -215,6 +215,7 @@ public class OrderRepository { Long customerId, String inboundCode, LocalDateTime createdFrom, LocalDateTime createdBefore, + List excludedOrderIds, long page, long size) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(OrderEntity::getDeleted, false) @@ -225,6 +226,8 @@ public class OrderRepository { .eq(StringUtils.hasText(inboundCode), OrderEntity::getInboundCode, inboundCode) .ge(createdFrom != null, OrderEntity::getCreatedAt, createdFrom) .lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore) + .notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId, + excludedOrderIds) .orderByDesc(OrderEntity::getCreatedAt) .orderByDesc(OrderEntity::getId); return PageResult.from(orderMapper.selectPage(new Page<>(page, size), wrapper)); diff --git a/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java index 768a11a49..dadf998cd 100644 --- a/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java @@ -34,7 +34,8 @@ public class PendingReviewOrderQueryService { LocalDateTime createdBefore = query.createdEnd() == null ? null : query.createdEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime(); var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(), - normalize(query.inboundCode()), createdFrom, createdBefore, query.page(), query.size()); + normalize(query.inboundCode()), createdFrom, createdBefore, query.excludedOrderIds(), + query.page(), query.size()); List orderIds = source.records().stream().map(order -> order.getId()).toList(); Map packageCounts = orders.findCargoByOrderIds(null, orderIds).stream() .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId, diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 86871d9d1..ac03e9b7b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -14,6 +14,7 @@ paths: 仅接受内部服务令牌。只返回 ord_order 中未删除且 status=SUBMITTED 的头程订单, mainStatus 固定为 PENDING_REVIEW。createdStart/createdEnd 按 Asia/Shanghai 自然日闭区间过滤 ord_order.created_at,稳定按 created_at DESC、id DESC 分页;不读取或修复 Firstleg 投影。 + excludedOrderIds 由 Firstleg 根据当前有效装柜计划提供,用于在数据库分页前排除已经占柜的订单。 parameters: - name: X-Internal-Service-Token in: header @@ -25,6 +26,7 @@ paths: - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} - {name: createdStart, in: query, required: false, schema: {type: string, format: date}} - {name: createdEnd, in: query, required: false, schema: {type: string, format: date}} + - {name: excludedOrderIds, in: query, required: false, style: form, explode: true, schema: {type: array, items: {type: string, pattern: '^[1-9][0-9]*$'}}} - {name: page, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: size, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, maximum: 200}} responses: diff --git a/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java index 26f2fd718..f3cc3019d 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java @@ -25,7 +25,8 @@ class InternalPendingReviewOrderControllerTest { @Test void acceptsOnlyTheConfiguredInternalServiceToken() { - var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null, 1, 20); + var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null, + List.of(101L), 1, 20); var page = new PageResult(List.of(), 1, 20, 0, 0); when(service.page(query)).thenReturn(page); diff --git a/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java index 977176308..158216b7f 100644 --- a/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java @@ -16,6 +16,7 @@ class PendingReviewOrderOpenApiContractTest { .contains("channelCodes") .contains("createdStart") .contains("createdEnd") + .contains("excludedOrderIds") .contains("PendingReviewOrderRow:") .contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}") .contains("orderId: {type: string, pattern: '^[1-9][0-9]*$'}") diff --git a/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java index 90e4f246c..6c743b27f 100644 --- a/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java @@ -38,7 +38,8 @@ class PendingReviewOrderRepositoryTest { void buildsSubmittedOnlyStablePageWithEverySupportedFilter() throws Exception { when(orderMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>()); repository.pendingReviewPage(List.of("SEA-US", "AIR-US"), 11L, 22L, "RK-1", - LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0), 3, 40); + LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0), + List.of(101L, 102L), 3, 40); ArgumentCaptor> page = ArgumentCaptor.forClass(Page.class); ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); @@ -53,6 +54,7 @@ class PendingReviewOrderRepositoryTest { .contains(".eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)") .contains(".in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)") .contains(".lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)") + .contains(".notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId,") .contains(".orderByDesc(OrderEntity::getCreatedAt)") .contains(".orderByDesc(OrderEntity::getId)"); } diff --git a/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java index c3636222f..3b12f447b 100644 --- a/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java @@ -36,16 +36,16 @@ class PendingReviewOrderQueryServiceTest { @Test void convertsShanghaiNaturalDatesToClosedDayRangeAndNormalizesFilters() { - when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong())) + when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong())) .thenReturn(new PageResult<>(List.of(), 2, 20, 0, 0)); service.page(new PendingReviewOrderDtos.Query(List.of(" SEA-US ", "SEA-US"), 11L, 22L, - " RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), 2, 20)); + " RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), List.of(101L), 2, 20)); @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(List.class); verify(repository).pendingReviewPage(channels.capture(), eq(11L), eq(22L), eq("RK-1"), eq(LocalDateTime.of(2026, 9, 1, 0, 0)), - eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(2L), eq(20L)); + eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(List.of(101L)), eq(2L), eq(20L)); assertThat(channels.getValue()).containsExactly("SEA-US"); } @@ -58,11 +58,12 @@ class PendingReviewOrderQueryServiceTest { order.setCreatedAt(LocalDateTime.of(2026, 9, 3, 10, 30)); OrderCargoEntity first = cargo(101L, 2); OrderCargoEntity second = cargo(101L, 3); - when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong())) + when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong())) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(repository.findCargoByOrderIds(null, List.of(101L))).thenReturn(List.of(first, second)); - var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null, 1, 20)); + var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null, + List.of(), 1, 20)); assertThat(result.records()).singleElement().satisfies(row -> { assertThat(row.orderId()).isEqualTo(101L); @@ -75,9 +76,9 @@ class PendingReviewOrderQueryServiceTest { @Test void rejectsReversedDatesAndBlankChannelValues() { assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null, - LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), 1, 20))).isInstanceOf(BizException.class); + LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), List.of(), 1, 20))).isInstanceOf(BizException.class); assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(List.of(" "), null, null, - null, null, null, 1, 20))).isInstanceOf(BizException.class); + null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class); } private static OrderCargoEntity cargo(long orderId, int packageCount) { diff --git a/scripts/README.md b/scripts/README.md index ee21e221b..5058291d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -102,7 +102,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | -| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、海外仓、私人地址、逐行失败、详情归属与幂等重放;只创建草稿,不提交、不审核 | +| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 | | 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | | Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | | 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` | @@ -119,6 +119,8 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/client-quick-order-test-env-acceptance.mjs ``` +需要额外随机创建多张草稿做压力回归时增加 `--random-count 20`。随机批次在三种目的仓类型间轮换,具体仓库/地址、每单产品数和箱数随机;逐张回查客户归属、草稿状态、目的类型和产品数量,并对整批执行幂等重放。取值范围 `1-50`,会在基础矩阵之外额外保留对应数量的草稿。 + 报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 ## 固定客户待审核订单 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 7e4d192d6..0bba7c2d9 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -205,7 +205,8 @@ Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admi Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI' Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI' Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI' -Assert-Contains $pendingReviewOrderService 'OrderStatus.SUBMITTED' 'Order 待审核候选状态事实' +Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询' +Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射' Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口' Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认' Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口' diff --git a/scripts/client-quick-order-test-env-acceptance.contract.test.mjs b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs index 3e4ccfb3e..f4cf19672 100644 --- a/scripts/client-quick-order-test-env-acceptance.contract.test.mjs +++ b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs @@ -6,18 +6,28 @@ const source = await readFile(new URL('./client-quick-order-test-env-acceptance. test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => { assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/) - assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0/) + assert.match(source, /cargoes: Array\.from\(\{ length: cargoCount \}/) + assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: index \+ 1, grossWeightKg: 0, volumeCbm: 0/) assert.match(source, /destinationType: 'FBA'/) assert.match(source, /destinationType: 'OVERSEAS'/) assert.match(source, /destinationType: 'PRIVATE'/) assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/) - assert.match(source, /privateDestinationAddressId: String\(privateAddress\.id\)/) + assert.match(source, /privateDestinationAddressId: String\(address\.id\)/) }) -test('quick-order HTTP acceptance verifies partial failure, detail ownership, and idempotent replay', () => { - assert.match(source, /expected succeeded=3/) - assert.match(source, /invalid channel row must fail independently/) +test('quick-order HTTP acceptance verifies expanded row, cargo, isolation, retry, and idempotency behavior', () => { + assert.match(source, /expected succeeded=5/) + assert.match(source, /cargo count mismatch/) + assert.match(source, /unowned private address must fail closed with A0404/) + assert.match(source, /duplicate clientRowId must fail independently with A0409/) assert.match(source, /draft must belong to the authenticated enterprise/) assert.match(source, /same idempotency key replays the same quick-order drafts/) assert.match(source, /idempotent replay returned different order IDs/) + assert.match(source, /same row id and idempotency key reject a changed payload/) + assert.match(source, /a corrected failed row succeeds under a fresh batch key/) + assert.match(source, /--random-count <1-50>/) + assert.match(source, /create \$\{randomCount\} randomized quick-order drafts/) + assert.match(source, /verify all \$\{randomCount\} randomized drafts/) + assert.match(source, /replay all \$\{randomCount\} randomized drafts idempotently/) + assert.match(source, /randomInt\(1, 6\)/) }) diff --git a/scripts/client-quick-order-test-env-acceptance.mjs b/scripts/client-quick-order-test-env-acceptance.mjs index 77923366b..1c53dbe6a 100644 --- a/scripts/client-quick-order-test-env-acceptance.mjs +++ b/scripts/client-quick-order-test-env-acceptance.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { randomUUID } from 'node:crypto' +import { randomInt, randomUUID } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' const args = parseArgs(process.argv.slice(2)) if (args.help) { - console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir ] [--timeout-ms ]') + console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir ] [--timeout-ms ] [--random-count <1-50>]') process.exit(0) } @@ -14,6 +14,7 @@ const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY) const username = process.env.MERCHANT_TEST_USERNAME const password = process.env.MERCHANT_TEST_PASSWORD const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) +const randomCount = Number(args['random-count'] ?? 0) const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}` const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`) @@ -23,7 +24,7 @@ const report = { completedAt: null, success: false, fatal: null, - resources: { batchKey: null, orderNos: [] }, + resources: { batchKey: null, randomBatchKey: null, orderNos: [] }, steps: [], } @@ -35,6 +36,10 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { console.error('--timeout-ms must be at least 1000') process.exit(2) } +if (!Number.isInteger(randomCount) || randomCount < 0 || randomCount > 50) { + console.error('--random-count must be an integer between 1 and 50') + process.exit(2) +} let token try { @@ -69,12 +74,13 @@ try { assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`) return response.data }) - const privateAddresses = await step('load customer-owned private destination addresses', async () => { + let privateAddresses = [] + await step('load customer-owned private destination addresses', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION') requireSuccess(response) - const addresses = arrayData(response.data) - assert(addresses.length > 0, 'at least one customer-owned private destination address is required') - return addresses + privateAddresses = arrayData(response.data) + assert(privateAddresses.length > 0, 'at least one customer-owned private destination address is required') + return { count: privateAddresses.length } }) const domestic = await loadWarehouses('DOMESTIC') const fba = await loadWarehouses('FBA') @@ -93,48 +99,70 @@ try { } assert(receiving && channel, 'no domestic warehouse has an active pricing channel') - const privateAddress = privateAddresses[0] - const privateDestination = { - destinationType: 'PRIVATE', destinationCountryCode: privateAddress.countryCode, - destinationWarehouseName: privateAddress.addressName, privateDestinationAddressId: String(privateAddress.id), - } + const fbaDestination = (warehouse) => ({ + destinationType: 'FBA', destinationCountryCode: warehouse.countryCode, destinationWarehouseId: warehouse.id, + destinationWarehouseName: warehouse.warehouseName, amazonWarehouseCode: warehouse.warehouseCode, + }) + const overseasDestination = (warehouse) => ({ + destinationType: 'OVERSEAS', destinationCountryCode: warehouse.countryCode, + destinationWarehouseId: warehouse.id, destinationWarehouseName: warehouse.warehouseName, + }) + const privateDestination = (address) => ({ + destinationType: 'PRIVATE', destinationCountryCode: address.countryCode, + destinationWarehouseName: address.addressName, privateDestinationAddressId: String(address.id), + }) + const invalidChannelRowId = `quick-order-invalid-channel-${marker}` + const invalidPrivateRowId = `quick-order-invalid-private-${marker}` const rows = [ - { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { - destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, - destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, - }, 'FBA') }, - { clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { - destinationType: 'OVERSEAS', destinationCountryCode: overseas[0].countryCode, - destinationWarehouseId: overseas[0].id, destinationWarehouseName: overseas[0].warehouseName, - }, 'OVERSEAS') }, - { clientRowId: `quick-order-private-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, - privateDestination, 'PRIVATE') }, - { clientRowId: `quick-order-invalid-channel-${marker}`, draft: { - ...quickDraft(profile.tenantName, policy, receiving, channel, { - destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, - destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, - }, 'INVALID'), channelCode: `INVALID-${marker}`, channelName: '不存在的渠道', + { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[0]), 'FBA') }, + { clientRowId: `quick-order-fba-multi-cargo-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[1] ?? fba[0]), 'FBA-MULTI', 3) }, + { clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + overseasDestination(overseas[1] ?? overseas[0]), 'OVERSEAS') }, + { clientRowId: `quick-order-private-a-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + privateDestination(privateAddresses[0]), 'PRIVATE-A') }, + { clientRowId: `quick-order-private-b-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + privateDestination(privateAddresses[1] ?? privateAddresses[0]), 'PRIVATE-B') }, + { clientRowId: invalidChannelRowId, draft: { + ...quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'INVALID'), + channelCode: `INVALID-${marker}`, channelName: '不存在的渠道', } }, + { clientRowId: invalidPrivateRowId, draft: quickDraft(profile.tenantName, policy, receiving, channel, { + destinationType: 'PRIVATE', destinationCountryCode: 'US', destinationWarehouseName: '越权私人仓', + privateDestinationAddressId: '9223372036854775807', + }, 'INVALID-PRIVATE') }, + { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[0]), 'DUPLICATE') }, ] + const expectedCargoCounts = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.cargoes.length])) + const expectedDestinationTypes = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.destinationType])) const batchKey = `quick-order-http-${randomUUID()}` report.resources.batchKey = batchKey - const created = await step('create FBA, overseas and private quick-order drafts with one invalid row', async () => { + const created = await step('create five varied quick-order drafts while isolating three invalid rows', async () => { const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, }) requireSuccess(response) - assert(response.data?.total === 4, `expected total=4, got ${response.data?.total}`) + assert(response.data?.total === 8, `expected total=8, got ${response.data?.total}`) const rowEvidence = arrayData(response.data?.items).map((item) => ({ clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo, errorCode: item.errorCode, errorMessage: item.errorMessage, })) - assert(response.data?.succeeded === 3, - `expected succeeded=3, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`) - assert(response.data?.failed === 1, - `expected failed=1, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`) - const failed = response.data.items.find((item) => item.clientRowId === rows[3].clientRowId) - assert(failed && !failed.success && failed.errorCode, 'invalid channel row must fail independently') + assert(response.data?.succeeded === 5, + `expected succeeded=5, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`) + assert(response.data?.failed === 3, + `expected failed=3, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`) + const invalidChannel = response.data.items.find((item) => item.clientRowId === invalidChannelRowId) + const invalidPrivate = response.data.items.find((item) => item.clientRowId === invalidPrivateRowId) + const duplicate = response.data.items[7] + assert(invalidChannel && !invalidChannel.success && invalidChannel.errorCode === 'A0409', + 'invalid channel row must fail independently with A0409') + assert(invalidPrivate && !invalidPrivate.success && invalidPrivate.errorCode === 'A0404', + `unowned private address must fail closed with A0404; actual=${JSON.stringify(invalidPrivate)}`) + assert(duplicate && !duplicate.success && duplicate.errorCode === 'A0409', + 'duplicate clientRowId must fail independently with A0409') return { ...response.data, items: rowEvidence } }) @@ -147,8 +175,13 @@ try { requireSuccess(response) assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`) assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise') + assert(response.data?.order?.destinationType === expectedDestinationTypes.get(item.clientRowId), + `destination type mismatch for ${item.clientRowId}`) + assert(arrayData(response.data?.cargoes).length === expectedCargoCounts.get(item.clientRowId), + `cargo count mismatch for ${item.clientRowId}`) report.resources.orderNos.push(item.orderNo) - return { orderNo: item.orderNo, status: response.data.order.status, customerName: response.data.order.customerName } + return { orderNo: item.orderNo, status: response.data.order.status, + destinationType: response.data.order.destinationType, cargoCount: response.data.cargoes.length } }) } @@ -163,6 +196,107 @@ try { return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) } }) + await step('same row id and idempotency key reject a changed payload without overwriting the draft', async () => { + const changed = structuredClone(rows[0]) + changed.draft.cargoes[0].packageCount = 99 + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [changed], headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + assert(response.data?.succeeded === 0 && response.data?.failed === 1, + 'changed payload with the same idempotency key must fail') + assert(response.data?.items?.[0]?.errorCode === 'A0409', + `expected idempotency conflict A0409, got ${response.data?.items?.[0]?.errorCode}`) + return { errorCode: response.data.items[0].errorCode, errorMessage: response.data.items[0].errorMessage } + }) + + await step('a corrected failed row succeeds under a fresh batch key', async () => { + const corrected = { + clientRowId: invalidChannelRowId, + draft: quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'RETRY'), + } + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [corrected], headers: { 'Idempotency-Key': `quick-order-retry-${randomUUID()}` }, + }) + requireSuccess(response) + const item = response.data?.items?.[0] + assert(response.data?.succeeded === 1 && item?.success && item?.orderId != null, + `corrected row did not succeed: ${JSON.stringify(response.data)}`) + const detail = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(detail) + assert(detail.data?.order?.status === 'DRAFT', 'corrected row must remain a draft') + assert(detail.data?.order?.customerName === profile.tenantName, + 'corrected draft must belong to the authenticated enterprise') + report.resources.orderNos.push(item.orderNo) + return { orderNo: item.orderNo, status: detail.data.order.status } + }) + + if (randomCount > 0) { + const destinationBuilders = [ + () => fbaDestination(fba[randomInt(fba.length)]), + () => overseasDestination(overseas[randomInt(overseas.length)]), + () => privateDestination(privateAddresses[randomInt(privateAddresses.length)]), + ] + const randomRows = Array.from({ length: randomCount }, (_, index) => { + const destination = destinationBuilders[index % destinationBuilders.length]() + const cargoCount = randomInt(1, 6) + const draft = quickDraft(profile.tenantName, policy, receiving, channel, destination, + `RANDOM-${index + 1}`, cargoCount) + draft.cargoes.forEach((cargo) => { + cargo.packageCount = randomInt(1, 31) + cargo.totalQuantity = cargo.packageCount * randomInt(1, 11) + cargo.unitsPerPackage = Math.max(1, Math.floor(cargo.totalQuantity / cargo.packageCount)) + }) + return { clientRowId: `quick-order-random-${index + 1}-${marker}`, draft } + }) + const randomBatchKey = `quick-order-random-http-${randomUUID()}` + report.resources.randomBatchKey = randomBatchKey + const randomCreated = await step(`create ${randomCount} randomized quick-order drafts`, async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey }, + }) + requireSuccess(response) + assert(response.data?.total === randomCount, `expected random total=${randomCount}, got ${response.data?.total}`) + assert(response.data?.succeeded === randomCount && response.data?.failed === 0, + `random batch failures: ${JSON.stringify(response.data?.items?.filter((item) => !item.success))}`) + return response.data.items.map((item, index) => ({ + clientRowId: item.clientRowId, orderId: item.orderId, orderNo: item.orderNo, + destinationType: randomRows[index].draft.destinationType, + cargoCount: randomRows[index].draft.cargoes.length, + })) + }) + + await step(`verify all ${randomCount} randomized drafts`, async () => { + const evidence = [] + for (const item of randomCreated) { + const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(response) + assert(response.data?.order?.status === 'DRAFT', `random draft ${item.orderNo} is not DRAFT`) + assert(response.data?.order?.customerName === profile.tenantName, + `random draft ${item.orderNo} is outside the authenticated enterprise`) + assert(response.data?.order?.destinationType === item.destinationType, + `random draft ${item.orderNo} destination type mismatch`) + assert(arrayData(response.data?.cargoes).length === item.cargoCount, + `random draft ${item.orderNo} cargo count mismatch`) + report.resources.orderNos.push(item.orderNo) + evidence.push({ orderNo: item.orderNo, destinationType: item.destinationType, cargoCount: item.cargoCount }) + } + return evidence + }) + + await step(`replay all ${randomCount} randomized drafts idempotently`, async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey }, + }) + requireSuccess(response) + const originalIds = randomCreated.map((item) => String(item.orderId)) + const replayIds = response.data?.items?.map((item) => String(item.orderId)) + assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), + 'randomized idempotent replay returned different order IDs') + return { replayed: replayIds.length } + }) + } + report.success = report.steps.every((item) => item.passed) } catch (error) { report.fatal = sanitizeError(error) @@ -176,7 +310,7 @@ try { process.exitCode = report.success ? 0 : 1 -function quickDraft(customerName, policy, receiving, channel, destination, suffix) { +function quickDraft(customerName, policy, receiving, channel, destination, suffix, cargoCount = 1) { const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined return { customerName, @@ -185,11 +319,11 @@ function quickDraft(customerName, policy, receiving, channel, destination, suffi channelCode: channel.channelCode, channelName: channel.channelName, pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER', customsInspection: null, inboundCode, ...destination, - cargoes: [{ - cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0, - currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${marker}`, - totalQuantity: 2, unitsPerPackage: 1, - }], + cargoes: Array.from({ length: cargoCount }, (_, index) => ({ + cargoName: '', packageType: 'CARTON', packageCount: index + 1, grossWeightKg: 0, volumeCbm: 0, + currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${index + 1}-${marker}`, + totalQuantity: index + 1, unitsPerPackage: 1, + })), } }