优化
This commit is contained in:
@@ -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<Long> excludedOrderIds,
|
||||
@RequestParam long page,
|
||||
@RequestParam long size);
|
||||
|
||||
|
||||
@@ -28,6 +28,27 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
""")
|
||||
List<PlanColoadMemberRow> planColoadMembers(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT item.order_id
|
||||
FROM fl_loading_plan_item item
|
||||
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
|
||||
JOIN fl_inbound_order inbound
|
||||
ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id
|
||||
WHERE item.deleted=0 AND plan.plan_status IN ('EDITING','LOADING')
|
||||
AND item.order_id IS NOT NULL
|
||||
AND inbound.channel_code IN
|
||||
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
|
||||
<if test="query.warehouseId != null">AND inbound.warehouse_id=#{query.warehouseId}</if>
|
||||
<if test="query.customerId != null">AND item.account_id=#{query.customerId}</if>
|
||||
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
|
||||
AND item.inbound_code=#{query.inboundCode}
|
||||
</if>
|
||||
</script>
|
||||
""")
|
||||
List<Long> occupiedOrderIds(@Param("query") InboundCandidateQuery query,
|
||||
@Param("actualChannels") Set<String> 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
|
||||
|
||||
@@ -59,6 +59,9 @@ public class SmartLoadingRepository {
|
||||
public List<PlanColoadMemberRow> planColoadMembers(long planId) {
|
||||
return candidates.planColoadMembers(planId);
|
||||
}
|
||||
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) {
|
||||
return candidates.occupiedOrderIds(query, actualChannels);
|
||||
}
|
||||
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
|
||||
return candidates.candidate(planId, warehouseId, inboundId, detailId);
|
||||
}
|
||||
|
||||
@@ -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("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
|
||||
|
||||
@@ -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<InboundCandidate> pendingCount = pendingReviewCandidates(query, actualChannels, 1, 1);
|
||||
PageResult<InboundCandidate> firstlegCount = firstlegCandidates(firstlegQuery, actualChannels);
|
||||
long total = pendingCount.total() + firstlegCount.total();
|
||||
long offset = Math.multiplyExact(query.page() - 1, query.size());
|
||||
PageResult<InboundCandidate> pendingPage = pendingReviewCandidates(query, actualChannels,
|
||||
query.page(), query.size());
|
||||
List<InboundCandidate> 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<InboundCandidate> 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<InboundCandidate> pendingReviewRange(InboundCandidateQuery query, Set<String> channels,
|
||||
long offset, long limit) {
|
||||
return pageRange(offset, limit, (page, size) -> pendingReviewCandidates(query, channels, page, size));
|
||||
}
|
||||
|
||||
private List<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
|
||||
List<CandidateOrderStatus> statuses,
|
||||
long offset, long limit) {
|
||||
return pageRange(offset, limit, (page, size) -> firstlegCandidates(
|
||||
withStatuses(query, statuses, page, size), channels));
|
||||
}
|
||||
|
||||
private static List<InboundCandidate> pageRange(long offset, long limit,
|
||||
java.util.function.BiFunction<Long, Long, PageResult<InboundCandidate>> source) {
|
||||
if (limit <= 0) return List.of();
|
||||
private PageResult<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
|
||||
List<CandidateOrderStatus> 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<InboundCandidate> result = new ArrayList<>();
|
||||
long total = 0;
|
||||
while (result.size() < limit) {
|
||||
List<InboundCandidate> rows = source.apply(page++, chunk).records();
|
||||
PageResult<InboundCandidate> sourcePage = firstlegCandidates(
|
||||
withStatuses(query, statuses, page++, chunk), channels);
|
||||
total = sourcePage.total();
|
||||
List<InboundCandidate> 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<PageResult<PendingReviewOrderClient.Row>> 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())
|
||||
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<InboundCandidateQuery> 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)
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -215,6 +215,7 @@ public class OrderRepository {
|
||||
Long customerId, String inboundCode,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdBefore,
|
||||
List<Long> excludedOrderIds,
|
||||
long page, long size) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.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));
|
||||
|
||||
@@ -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<Long> orderIds = source.records().stream().map(order -> order.getId()).toList();
|
||||
Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream()
|
||||
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-1
@@ -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<PendingReviewOrderDtos.Row>(List.of(), 1, 20, 0, 0);
|
||||
when(service.page(query)).thenReturn(page);
|
||||
|
||||
|
||||
+1
@@ -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]*$'}")
|
||||
|
||||
+3
-1
@@ -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<OrderEntity>> page = ArgumentCaptor.forClass(Page.class);
|
||||
ArgumentCaptor<Wrapper<OrderEntity>> 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)");
|
||||
}
|
||||
|
||||
+8
-7
@@ -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<List<String>> 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) {
|
||||
|
||||
+3
-1
@@ -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 仅在当前轮明确授权测试环境验收后执行。
|
||||
|
||||
## 固定客户待审核订单
|
||||
|
||||
@@ -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 入库费用确认入口'
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
@@ -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 <path>] [--timeout-ms <ms>]')
|
||||
console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir <path>] [--timeout-ms <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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user