diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 64f19c923..10332299f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -40,6 +40,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; @@ -785,6 +787,7 @@ class LoadingPickServiceTest { @Test void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() { + assertNoTruckCoupling(); LoadingPickTaskEntity failed = task(); failed.setTaskStatus("COMPLETION_FAILED"); LoadingPickTaskEntity completing = task(); @@ -871,6 +874,35 @@ class LoadingPickServiceTest { verify(completionCommands).complete(900L, "worker-1"); } + @ParameterizedTest(name = "truck state {0} never participates in PDA completion") + @ValueSource(strings = {"NO_APPOINTMENT", "DRAFT", "IN_PROGRESS", "COMPLETED"}) + void completionWorkerIsIndependentOfEveryTruckAppointmentState(String truckState) { + assertNoTruckCoupling(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + 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); + long commandId = 920L + Math.abs(truckState.hashCode() % 20); + when(completionCommands.complete(commandId, "worker-truck-independent")).thenReturn(true); + + service.processCompletion(completionCommand(commandId, + "async-truck-independent-" + truckState.toLowerCase()), "worker-truck-independent"); + + verify(cabinetEvidenceService).requireComplete(99L); + verify(settlementService).settleOwner(20L, 50L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-async-truck-independent-" + truckState.toLowerCase() + "-50", "checked"); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).completeAccepted(20L, 9L, "operator"); + verify(completionCommands).complete(commandId, "worker-truck-independent"); + } + @Test void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() { LoadingPickTaskEntity completing = task(); @@ -911,6 +943,7 @@ class LoadingPickServiceTest { @Test void secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted() { + assertNoTruckCoupling(); LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); @@ -939,6 +972,7 @@ class LoadingPickServiceTest { @Test void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() { + assertNoTruckCoupling(); LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); @@ -1180,6 +1214,14 @@ class LoadingPickServiceTest { verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); } + private static void assertNoTruckCoupling() { + assertThat(LoadingPickService.class.getDeclaredFields()) + .noneMatch(field -> field.getType().equals(TruckAppointmentService.class)); + assertThat(LoadingPickService.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("completeTruckAfterCommit") + || method.getName().equals("completeLoadingFromPda")); + } + private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) { when(repository.history(operationId)).thenReturn(null); when(repository.task(20L)).thenReturn(task); diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index e71b8e220..10db522e8 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -120,7 +120,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) -test('downstream finalizer uses the frozen truck-coupled complete contract', () => { +test('downstream finalizer uses the frozen loading complete contract without truck coupling', () => { assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) @@ -136,6 +136,8 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', () assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) + assert.doesNotMatch(loadingPickService, + /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) }) test('PDA complete OpenAPI freezes reliable async acceptance and checkpoint recovery', () => { @@ -215,11 +217,12 @@ test('worker final Firstleg convergence is one short transaction after remote ow 'settlementService.settleOwner(', 'loadingDeliveryService.completeAfterPdaSettlement(', 'completionTransaction.executeWithoutResult', - 'completeTruckAfterCommit(', ]) const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'), loadingPickService.indexOf('private void requireColoadConservation')) for (const token of ['COMPLETE_LOADING', 'finishFromPda', 'syncInboundProjections', 'publishCompletedEvent', 'completeAccepted', 'completionCommands.complete']) assert.match(finalizer, new RegExp(token)) - assert.doesNotMatch(finalizer, /settlementService|completeTruckAfterCommit/) + assert.doesNotMatch(finalizer, /settlementService/) + assert.doesNotMatch(loadingPickService, + /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2d4bec66f..f7e3e7bf0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -917,6 +917,9 @@ export async function run(context) { acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200' && ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus), `${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '独立柜未创建拖车单也不阻塞 PDA 装柜完成命令', acceptedComplete, + acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200', + `containerBookingId=${containerBookingId}, oceanBookingId=${bookingId}, ${responseEvidence(acceptedComplete)}`, OPTIONS) let terminalTask = acceptedComplete.body?.data let terminalResponse = acceptedComplete diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index de5bff40e..d5b1bbbda 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -140,6 +140,22 @@ test('late container gate mode proves the final gate without requiring evidence assert.match(value, /nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409'/) assert.match(value, /!nextMessage\.includes\('集装箱号'\)/) assert.match(value, /context\.createdResources\.phase3LateContainerGate/) + assert.match(value, /独立柜未创建拖车单也不阻塞 PDA 装柜完成命令/) +}) + +test('PDA completion production and HTTP chain never call or gate on truck appointments', () => { + const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java') + const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java') + const loading = source('./phase3-loading.mjs') + assert.doesNotMatch(service, /TruckAppointmentService|completeLoadingFromPda|completeTruckAfterCommit/) + assert.match(serviceTest, /completionWorkerIsIndependentOfEveryTruckAppointmentState/) + for (const state of ['NO_APPOINTMENT', 'DRAFT', 'IN_PROGRESS', 'COMPLETED']) { + assert.match(serviceTest, new RegExp(state)) + } + assert.match(serviceTest, /failedSameOperationIdCanResumeWithoutCreatingAnotherCommand[\s\S]*assertNoTruckCoupling/) + assert.match(serviceTest, /secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted[\s\S]*assertNoTruckCoupling/) + assert.match(serviceTest, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements[\s\S]*assertNoTruckCoupling/) + assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) }) test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => {