Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
+1
-1
@@ -180,7 +180,7 @@ class LoadingListControllerContractTest {
|
||||
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
|
||||
"destinationWarehouse", "productQuantity", "frozenAt");
|
||||
assertThat(properties(schemas, "OperationHistory")).containsKeys(
|
||||
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
|
||||
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
|
||||
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
|
||||
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
|
||||
"containerNo", "voyageNo", "expectedVersion", "operationReason");
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest {
|
||||
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
|
||||
.contains("Task detail is served only from the ACTIVE container booking")
|
||||
.contains("Enforced by the server-side pending-task query")
|
||||
.contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW")
|
||||
.contains("completion acceptance is returned immediately as PROCESSING")
|
||||
.contains("taskStatus=COMPLETING且workflowStatus=PROCESSING")
|
||||
.contains("CompleteLoadingRequest");
|
||||
|
||||
String taskLookup = yaml.substring(
|
||||
|
||||
+93
-1
@@ -25,6 +25,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -195,6 +196,65 @@ class LoadingDeliveryServiceTest {
|
||||
verify(repository).findInboundForUpdate(10, 20);
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
||||
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() {
|
||||
readyForPdaFinalizeFacts();
|
||||
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
|
||||
"LOADING", 7, "LOADING", 7);
|
||||
when(repository.completionTarget(10, 20)).thenReturn(settled);
|
||||
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
|
||||
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
|
||||
AtomicReference<String> hash = new AtomicReference<>();
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-resume"), anyString())).thenAnswer(invocation -> {
|
||||
hash.set(invocation.getArgument(5));
|
||||
return false;
|
||||
});
|
||||
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume"))
|
||||
.thenAnswer(invocation -> processingOperation(hash.get()));
|
||||
|
||||
LoadingActionResponse result = service.completeAfterPdaSettlement(
|
||||
10, 20, 7, "admin", "pda-resume", "完成");
|
||||
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
||||
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
|
||||
eq("COMPLETE_LOADING"), eq("pda-resume"), anyString());
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of());
|
||||
|
||||
LoadingActionResponse result = service.completeAfterPdaSettlement(
|
||||
10, 20, 7, "admin", "pda-already-loaded", "完成");
|
||||
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
|
||||
eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeEntryPointsAreTransactional() throws Exception {
|
||||
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
|
||||
long.class, long.class, long.class, String.class, String.class, String.class)
|
||||
.getAnnotation(Transactional.class)).isNotNull();
|
||||
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
|
||||
long.class, long.class, long.class, long.class, String.class, String.class, String.class)
|
||||
.getAnnotation(Transactional.class)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -213,6 +273,8 @@ class LoadingDeliveryServiceTest {
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(repository).findInboundForUpdate(10, 21);
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 2);
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -350,6 +412,24 @@ class LoadingDeliveryServiceTest {
|
||||
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001");
|
||||
existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE");
|
||||
when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing);
|
||||
when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(existing));
|
||||
|
||||
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo",
|
||||
new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null));
|
||||
|
||||
assertThat(result.evidences().evidences()).hasSize(1);
|
||||
verify(repository, never()).createEvidence(any());
|
||||
verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidsPhotoWithReasonAndKeepsTheRecord() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
@@ -418,11 +498,16 @@ class LoadingDeliveryServiceTest {
|
||||
private void readyForPdaFinalize() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
readyForPdaFinalizeFacts();
|
||||
}
|
||||
|
||||
private void readyForPdaFinalizeFacts() {
|
||||
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
|
||||
when(progress.getExpectedCount()).thenReturn(5);
|
||||
when(progress.getPickedCount()).thenReturn(5);
|
||||
when(progress.getTaskCount()).thenReturn(1);
|
||||
when(progress.getCompletedTaskCount()).thenReturn(1);
|
||||
// completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet.
|
||||
when(progress.getCompletedTaskCount()).thenReturn(0);
|
||||
when(progress.getUnresolvedCount()).thenReturn(0);
|
||||
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||
@@ -444,4 +529,11 @@ class LoadingDeliveryServiceTest {
|
||||
value.setResultJson(resultJson);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static LoadingOperationEntity processingOperation(String requestHash) {
|
||||
LoadingOperationEntity value = new LoadingOperationEntity();
|
||||
value.setRequestHash(requestHash);
|
||||
value.setStatus("PROCESSING");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingCompletionSqlContractTest {
|
||||
@Test
|
||||
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
|
||||
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
|
||||
"phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8));
|
||||
assertContract(Files.readString(Path.of("..", "deploy", "sql",
|
||||
"头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void assertContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("fl_loading_completion_command")
|
||||
.contains("fl_loading_completion_checkpoint")
|
||||
.contains("uk_loading_completion_operation")
|
||||
.contains("idx_loading_completion_dispatch")
|
||||
.contains("PROCESSING","RETRYABLE","FAILED","COMPLETED")
|
||||
.contains("COMPLETING","COMPLETION_FAILED")
|
||||
.contains("READY","SETTLED","INBOUND_COMPLETED")
|
||||
.contains("lease_owner","lease_until","next_retry_at")
|
||||
.contains("failure_stage","failure_code","failure_request_id","failure_trace_id");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest {
|
||||
.contains("plan_item.loading_plan_id = #{planId}")
|
||||
.contains("plan_item.deleted = 0")
|
||||
.contains("pick_task.loading_plan_id = #{planId}")
|
||||
.contains("pick_task.task_status = 'LOADING'")
|
||||
.doesNotContain("pick_task.task_status")
|
||||
.contains("pick_item.item_status = 'PICKED'")
|
||||
.contains("pick_item.picked_package_count = plan_item.package_count")
|
||||
.contains("pick_item.item_status = 'PARTIAL'")
|
||||
|
||||
+3
-1
@@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest {
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(sql)
|
||||
.contains("task.task_status = 'LOADING'")
|
||||
.contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')")
|
||||
.contains("booking.record_status = 'ACTIVE'")
|
||||
.contains("booking.source_type AS sourceType")
|
||||
.contains("booking.record_status AS recordStatus")
|
||||
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
|
||||
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
|
||||
.contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'")
|
||||
.contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'")
|
||||
.contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
|
||||
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
|
||||
.contains("THEN 'AWAITING_REVIEW'")
|
||||
|
||||
+8
-3
@@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
private static void assertLoadingCompletionTrigger(String sql) {
|
||||
assertThat(sql)
|
||||
int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update");
|
||||
int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart);
|
||||
assertThat(triggerStart).isGreaterThanOrEqualTo(0);
|
||||
assertThat(triggerEnd).isGreaterThan(triggerStart);
|
||||
String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd);
|
||||
assertThat(beforeUpdateTrigger)
|
||||
.contains("AND NOT EXISTS (")
|
||||
.contains("pick_task.loading_plan_id = OLD.id")
|
||||
.contains("pick_task.task_status = 'LOADING'")
|
||||
.doesNotContain("pick_task.task_status")
|
||||
.contains("pick_item.item_status = 'PICKED'")
|
||||
.contains("pick_item.item_status = 'PARTIAL'")
|
||||
.contains("pick_item.picked_package_count > 0")
|
||||
@@ -308,7 +313,7 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("container_no VARCHAR(64) NULL")
|
||||
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
||||
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
||||
|
||||
+78
-3
@@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest {
|
||||
|
||||
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of(
|
||||
pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"),
|
||||
pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"),
|
||||
pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102")));
|
||||
service.publish(90L);
|
||||
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
@@ -59,6 +63,15 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
||||
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
||||
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
|
||||
assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11");
|
||||
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
|
||||
assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2);
|
||||
assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20);
|
||||
assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2");
|
||||
assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4");
|
||||
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
|
||||
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||
assertThat(first.eventVersion()).isEqualTo(2);
|
||||
}
|
||||
@@ -72,6 +85,22 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.hasMessageContaining("缺少 SKU");
|
||||
}
|
||||
|
||||
@Test void rejectsMissingTrustedBookingCustomerName() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setCustomerName(" ");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
|
||||
}
|
||||
|
||||
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setBookingCustomerId(22L);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
|
||||
}
|
||||
|
||||
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setDestinationType("FBA");
|
||||
@@ -118,6 +147,41 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
||||
}
|
||||
|
||||
@Test void immutableCustomerNameParticipatesInPayloadHash() {
|
||||
Row value = row(11, 100, 101);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
service.publish(90L);
|
||||
value.setCustomerName("客户甲-更名快照");
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getPayloadHash())
|
||||
.isNotEqualTo(captor.getAllValues().get(1).getPayloadHash());
|
||||
}
|
||||
|
||||
@Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null);
|
||||
value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null);
|
||||
value.setEstimatedWarehouseArrivalAt(null);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of());
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captor.capture());
|
||||
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue();
|
||||
}
|
||||
|
||||
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
|
||||
Row first = row(11, 100, 101); first.setPackageCount(1);
|
||||
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
|
||||
@@ -144,7 +208,11 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
|
||||
.contains("o.process_status = 'LOADED'");
|
||||
.contains("o.process_status = 'LOADED'")
|
||||
.contains("b.customer_id AS booking_customer_id")
|
||||
.contains("b.customer_name")
|
||||
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
|
||||
.contains("b.estimated_warehouse_arrival_at");
|
||||
for (String sql : List.of(rowsSql, palletsSql)) {
|
||||
assertThat(sql)
|
||||
.contains("actual_pick.loading_plan_item_id = i.id")
|
||||
@@ -158,6 +226,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.doesNotContain("'PICKING'");
|
||||
}
|
||||
assertThat(palletsSql)
|
||||
.contains("a.pallet_id", "a.reservation_id")
|
||||
.contains("label.tenant_id = i.account_id")
|
||||
.contains("label.inbound_detail_id = i.inbound_detail_id")
|
||||
.contains("label.batch_status = 'ACTIVE'")
|
||||
@@ -219,20 +288,26 @@ class LoadingCompletedOutboxServiceTest {
|
||||
private Row row(long account,long inbound,long detail) {
|
||||
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
|
||||
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
|
||||
r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
|
||||
r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
|
||||
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
|
||||
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
|
||||
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
|
||||
r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
|
||||
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
|
||||
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
||||
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
||||
r.setDestinationType("OVERSEAS");
|
||||
r.setFulfillmentType("STANDARD");
|
||||
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
|
||||
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
|
||||
r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2"));
|
||||
r.setVolumeCbm(BigDecimal.ONE); return r;
|
||||
}
|
||||
|
||||
|
||||
private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated,
|
||||
long detail,String sku,int packages,int quantity,String boxCode) {
|
||||
PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound);
|
||||
row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation);
|
||||
row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
|
||||
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
|
||||
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(LoadingCompletionWorker.class)
|
||||
class LoadingCompletionWorkerTest {
|
||||
@MockBean private LoadingCompletionCommandRepository commands;
|
||||
@MockBean private LoadingPickRepository picks;
|
||||
@MockBean private LoadingPickService loadingPicks;
|
||||
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
|
||||
|
||||
@Test
|
||||
void transientFailureIsReleasedForBackoffRetry() {
|
||||
LoadingCompletionCommandEntity command = command(1);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT",
|
||||
new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout")))
|
||||
.when(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
|
||||
when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
|
||||
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)))
|
||||
.thenReturn(true);
|
||||
|
||||
worker.dispatch();
|
||||
|
||||
verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
|
||||
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fifthFailureBecomesTerminalAndMarksTaskFailed() {
|
||||
LoadingCompletionCommandEntity command = command(4);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE",
|
||||
new BizException(ResultCode.CONFLICT, "plan conflict")))
|
||||
.when(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
|
||||
when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
|
||||
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true);
|
||||
|
||||
worker.dispatch();
|
||||
|
||||
verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
|
||||
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null));
|
||||
verify(picks).failCompletion(20L);
|
||||
}
|
||||
|
||||
private static LoadingCompletionCommandEntity command(int attemptCount) {
|
||||
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
|
||||
command.setId(900L);
|
||||
command.setPickTaskId(20L);
|
||||
command.setOperationId("complete-1");
|
||||
command.setAttemptCount(attemptCount);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
@@ -34,6 +36,7 @@ import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@@ -44,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
@@ -53,6 +57,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringJUnitConfig(LoadingPickService.class)
|
||||
@@ -60,6 +65,8 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private LoadingPickRepository repository;
|
||||
@MockBean
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@MockBean
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -85,9 +92,13 @@ class LoadingPickServiceTest {
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setContainerNo("CSLU1234567");
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setSourceType("SIMULATION");
|
||||
booking.setRecordStatus("ACTIVE");
|
||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
||||
when(completionCommands.insert(any())).thenReturn(1);
|
||||
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
|
||||
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -232,6 +243,7 @@ class LoadingPickServiceTest {
|
||||
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
|
||||
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
|
||||
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
|
||||
@@ -414,6 +426,37 @@ class LoadingPickServiceTest {
|
||||
assertThat(result.sourceType()).isEqualTo("SIMULATION");
|
||||
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(result.mergedIntoContainerBookingId()).isNull();
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
|
||||
when(repository.cartonLabels(20L)).thenReturn(List.of());
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of());
|
||||
doThrow(new IllegalStateException("completion command schema unavailable"))
|
||||
.when(completionCommands).latest(20L);
|
||||
|
||||
var result = service.task(20L, 101L);
|
||||
|
||||
assertThat(result.taskStatus()).isEqualTo("LOADING");
|
||||
assertThat(result.completionCommand()).isNull();
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
|
||||
assertThatThrownBy(() -> service.task(20L, 999L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
|
||||
|
||||
verify(repository, never()).items(20L, 999L);
|
||||
verify(repository, never()).cartonLabels(20L);
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -488,6 +531,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
@@ -538,6 +582,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
@@ -568,6 +613,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
@@ -592,6 +638,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
@@ -613,6 +660,199 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
task.setVersion(1);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
|
||||
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
|
||||
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"释放库位资源时发生并发变更"))
|
||||
.when(settlementService).settle(20L, 9L, "operator");
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("释放库位资源时发生并发变更");
|
||||
|
||||
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyLong(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
task.setVersion(1);
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1);
|
||||
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
|
||||
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
|
||||
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
|
||||
|
||||
Task result = service.complete(20L, new CompleteLoadingRequest(1,
|
||||
"complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c",
|
||||
"PDA确认装柜完成"), 9L, "operator");
|
||||
|
||||
assertThat(result.id()).isEqualTo(20L);
|
||||
verify(settlementService).settle(20L, 9L, "operator");
|
||||
verify(repository).insert(org.mockito.ArgumentMatchers.argThat(
|
||||
(LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType())));
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() {
|
||||
LoadingPickTaskEntity loading = task();
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
completing.setVersion(1);
|
||||
AtomicReference<com.baiyue.firstleg.entity.LoadingCompletionCommandEntity> accepted = new AtomicReference<>();
|
||||
when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get());
|
||||
doAnswer(invocation -> {
|
||||
accepted.set(invocation.getArgument(0));
|
||||
return 1;
|
||||
}).when(completionCommands).insert(any());
|
||||
when(repository.taskForUpdate(20L)).thenReturn(loading);
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.items(20L)).thenReturn(List.of(item()));
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
|
||||
Task first = service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator");
|
||||
verify(repository).taskForUpdate(20L);
|
||||
clearInvocations(repository);
|
||||
Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () ->
|
||||
service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"));
|
||||
|
||||
assertThat(first.taskStatus()).isEqualTo("COMPLETING");
|
||||
assertThat(first.workflowStatus()).isEqualTo("PROCESSING");
|
||||
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
|
||||
verify(repository, never()).taskForUpdate(20L);
|
||||
verify(completionCommands, times(1)).insert(any());
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
LoadingPickTaskEntity loading = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(loading);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
|
||||
active.setOperationId("already-processing");
|
||||
when(completionCommands.active(20L)).thenReturn(active);
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令");
|
||||
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() {
|
||||
LoadingPickTaskEntity failed = task();
|
||||
failed.setTaskStatus("COMPLETION_FAILED");
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
var command = completionCommand(902L, "resume-same-operation");
|
||||
command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070");
|
||||
command.setCommandStatus("FAILED");
|
||||
command.setExpectedTaskVersion(0);
|
||||
when(completionCommands.operation("resume-same-operation")).thenReturn(command);
|
||||
when(completionCommands.resume(902L)).thenReturn(true);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(failed);
|
||||
when(repository.resumeCompletion(20L)).thenReturn(1);
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.items(20L)).thenReturn(List.of(item()));
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
|
||||
Task result = service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator");
|
||||
|
||||
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
|
||||
verify(completionCommands).resume(902L);
|
||||
verify(repository).resumeCompletion(20L);
|
||||
verify(completionCommands, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
List<LoadingPickItemMapper.InboundOwner> owners = java.util.stream.IntStream.range(0, 15)
|
||||
.mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList();
|
||||
when(repository.inboundOwners(20L)).thenReturn(owners);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.review(20L)).thenReturn(null);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
|
||||
when(completionCommands.complete(900L, "worker-1")).thenReturn(true);
|
||||
var command = completionCommand(900L, "async-15");
|
||||
|
||||
service.processCompletion(command, "worker-1");
|
||||
|
||||
ArgumentCaptor<Long> inboundIds = ArgumentCaptor.forClass(Long.class);
|
||||
verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator"));
|
||||
assertThat(inboundIds.getAllValues()).containsExactly(
|
||||
1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L,
|
||||
1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L);
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(repository).completeAccepted(20L, 9L, "operator");
|
||||
verify(completionCommands).complete(900L, "worker-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 50L),
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 60L)));
|
||||
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
|
||||
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED");
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
|
||||
when(completionCommands.complete(901L, "worker-2")).thenReturn(true);
|
||||
|
||||
service.processCompletion(completionCommand(901L, "async-resume"), "worker-2");
|
||||
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
|
||||
anyLong(), anyString(), anyString(), anyString());
|
||||
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
|
||||
"PDA-async-resume-60", "checked");
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(
|
||||
operation -> "COMPLETE_LOADING".equals(operation.getActionType())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
@@ -814,6 +1054,21 @@ class LoadingPickServiceTest {
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
}
|
||||
|
||||
private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand(
|
||||
long id, String operationId) {
|
||||
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
|
||||
command.setId(id);
|
||||
command.setPickTaskId(20L);
|
||||
command.setOperationId(operationId);
|
||||
command.setRequestHash("hash");
|
||||
command.setCommandStatus("PROCESSING");
|
||||
command.setOperatorId(9L);
|
||||
command.setOperatorName("operator");
|
||||
command.setRemark("checked");
|
||||
command.setAttemptCount(1);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static LoadingPickTaskEntity task() {
|
||||
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
|
||||
task.setId(20L);
|
||||
|
||||
+6
-3
@@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest {
|
||||
when(splits.lockParent(31L)).thenReturn(parent(20));
|
||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||
when(allocations.lockConfirmed(10001L, 31L))
|
||||
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
||||
InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10);
|
||||
InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10);
|
||||
first.setPalletId(801L);
|
||||
second.setPalletId(801L);
|
||||
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
|
||||
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
|
||||
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
|
||||
List.of())));
|
||||
writes(0, 20);
|
||||
|
||||
|
||||
+32
-8
@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
|
||||
@@ -50,12 +51,29 @@ class TaillegRealProducerAcceptanceTest {
|
||||
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
|
||||
eventKey));
|
||||
assertEquals("真实验收客户", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertEquals("2026-09-30T12:00", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertNull(jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertEquals(1, jdbc.queryForObject(
|
||||
"SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey));
|
||||
assertEquals("35.0", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
}
|
||||
|
||||
private void seedCompletedLoadingPlan() {
|
||||
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
|
||||
"warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
|
||||
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
|
||||
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
|
||||
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
|
||||
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
|
||||
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
|
||||
@@ -66,12 +84,8 @@ class TaillegRealProducerAcceptanceTest {
|
||||
MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3);
|
||||
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
|
||||
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
|
||||
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
|
||||
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
|
||||
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
|
||||
"reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
|
||||
990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D",
|
||||
1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES");
|
||||
seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F");
|
||||
seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," +
|
||||
"container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')",
|
||||
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
|
||||
@@ -80,6 +94,16 @@ class TaillegRealProducerAcceptanceTest {
|
||||
jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
|
||||
}
|
||||
|
||||
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
|
||||
int packages, String suffix) {
|
||||
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
|
||||
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
|
||||
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
|
||||
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
|
||||
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
|
||||
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
|
||||
}
|
||||
|
||||
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {
|
||||
jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," +
|
||||
"inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",
|
||||
|
||||
Reference in New Issue
Block a user