diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 5ce20ed4c..e29fbfcfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -165,6 +165,21 @@ public interface LoadingPickItemMapper extends BaseMapper """) java.util.List selectInboundOwners(@Param("taskId") long taskId); + /** Only physical cargo owns completion checkpoints; retained SKIPPED rows are plan history. */ + @Select(""" + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id + AND plan_item.loading_plan_id=task.loading_plan_id AND plan_item.deleted=0 + WHERE pick_item.pick_task_id=#{taskId} + AND pick_item.item_status IN ('PICKED','PARTIAL') + AND pick_item.picked_package_count>0 + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY plan_item.account_id,plan_item.inbound_id + """) + java.util.List selectCompletionOwners(@Param("taskId") long taskId); + record InboundOwner(Long tenantId, Long inboundId) {} @Select(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 51260d8c9..8e9422623 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -246,6 +246,10 @@ public class LoadingPickRepository { return items.selectInboundOwners(taskId); } + public List completionOwners(long taskId) { + return items.selectCompletionOwners(taskId); + } + public LoadingInboundMapper.Snapshot inbound(long tenantId, long inboundId) { return inbounds.find(tenantId, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index c4c5d2571..e123514c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -576,7 +576,7 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); - List discoveredOwners = repository.inboundOwners(taskId).stream() + List discoveredOwners = repository.completionOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .toList(); if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); @@ -618,7 +618,7 @@ public class LoadingPickService { long taskId = command.getPickTaskId(); inboundHoldGuard.requireTaskNotHeld(taskId); LoadingPickTaskEntity task = requiredTask(taskId); - List owners = repository.inboundOwners(taskId).stream() + List owners = repository.completionOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java new file mode 100644 index 000000000..072f61b37 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java @@ -0,0 +1,95 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Execute actual owner and checkpoint Mapper SQL against persisted resolved pick facts. */ +class LoadingCompletionOwnerPersistenceTest { + private JdbcTemplate jdbc; + private LoadingPickItemMapper owners; + private LoadingCompletionCommandMapper checkpoints; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:completion-owners-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)"); + row(1, 101, 50, 3, "PARTIAL", 2, 10, 0); + row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history + row(3, 202, 60, 1, "PICKED", 1, 10, 0); + row(4, 303, 70, 1, "SKIPPED", 0, 10, 0); // whole skipped customer + row(5, 404, 80, 1, "PICKED", 0, 10, 0); // nonphysical count + row(6, 505, 90, 1, "PICKED", 1, 10, 1); // historical deleted cargo + row(7, 606, 100, 1, "PICKED", 1, 999, 0); // wrong plan cannot prove loading + row(8, 707, 110, 1, "PENDING", 1, 10, 0); // unresolved status + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LoadingPickItemMapper.class); + config.addMapper(LoadingCompletionCommandMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var session = new SqlSessionTemplate(factory.getObject()); + owners = session.getMapper(LoadingPickItemMapper.class); + checkpoints = session.getMapper(LoadingCompletionCommandMapper.class); + } + + @Test + void unreconciledPartialAndFullCargoOwnCompletionButSkippedAndInvalidScopeDoNot() { + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L), new InboundOwner(202L, 60L)); + // Planned owner lookup still serves claims, holds and plan operations; its scope is not weakened. + assertThat(owners.selectInboundOwners(20)).contains(new InboundOwner(303L, 70L)); + } + + @Test + void partialSettlementAndSkippedHistoryRemovalCannotChangeActualOwnerSequence() { + List before = owners.selectCompletionOwners(20); + jdbc.update("UPDATE fl_loading_plan_item SET package_count=2 WHERE id=1"); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id IN (2,4)"); + assertThat(owners.selectCompletionOwners(20)).isEqualTo(before); + } + + @Test + void allActualRowsMayBePartialAndAnEntirelySkippedTaskHasNoCompletionOwner() { + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE id=3"); + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L)); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE id=1"); + assertThat(owners.selectCompletionOwners(20)).isEmpty(); + } + + @Test + void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() { + checkpoints.checkpointCompleted(900, 101, 50, 1, "READY"); + checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED"); + checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, 303, 70, 3, "READY"); + assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED"); + assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY"); + assertThat(owners.selectCompletionOwners(20)).containsExactly(new InboundOwner(101L, 50L), new InboundOwner(202L, 60L)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isEqualTo(3); + } + + private void row(long id, long tenant, long inbound, int planned, String status, int picked, long plan, int deleted) { + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?)", id, plan, tenant, inbound, deleted, planned); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,20,?,?,?)", id, id, status, picked); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java index 1ae756746..87b01651f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedPreflightDatabaseTest.java @@ -123,7 +123,7 @@ class LoadingCompletedPreflightDatabaseTest { var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity(); task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L); when(repository.task(92L)).thenReturn(task); - when(repository.inboundOwners(92L)).thenReturn(List.of( + when(repository.completionOwners(92L)).thenReturn(List.of( new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L), new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L))); var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); 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 448c86838..04795248e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -157,6 +157,8 @@ class LoadingPickServiceTest { when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); when(repository.inboundOwners(anyLong())).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.completionOwners(anyLong())).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation -> new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1)); when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of( @@ -1033,6 +1035,92 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void workerNeverSettlesAWholeSkippedOwnerAlongsideActualPartialAndFullCargo() { + 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(202L, 60L), + new LoadingPickItemMapper.InboundOwner(303L, 70L))); + when(repository.completionOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(202L, 60L))); + 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(930L, "worker-actual-only")).thenReturn(true); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "PDA任务不包含指定入库单")) + .when(settlementService).settleOwner(20L, 70L, 9L, "operator"); + + service.processCompletion(completionCommand(930L, "actual-only"), "worker-actual-only"); + + verify(loadingDeliveryService, never()).requirePdaCompletionReady(303L, 70L); + verify(settlementService, never()).settleOwner(20L, 70L, 9L, "operator"); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(303L), eq(70L), anyLong(), + anyString(), anyString(), anyString()); + verify(loadingListService).publishCompletedEvent(10L); + verify(completionCommands).complete(930L, "worker-actual-only"); + } + + @Test + void completedActualOwnerCheckpointsBypassReadyAndSettlementWithoutTouchingSkippedReadyCheckpoint() { + 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(202L, 60L), + new LoadingPickItemMapper.InboundOwner(303L, 70L))); + when(repository.completionOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 60L))); + when(completionCommands.checkpoint(931L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(931L, 202L, 60L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(931L, 303L, 70L)).thenReturn("READY"); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "实际来源已经LOADED")) + .when(loadingDeliveryService).requirePdaCompletionReady(anyLong(), anyLong()); + 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(931L, "worker-completed-checkpoints")).thenReturn(true); + + service.processCompletion(completionCommand(931L, "completed-checkpoints"), "worker-completed-checkpoints"); + + verify(loadingDeliveryService, never()).requirePdaCompletionReady(anyLong(), anyLong()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); + verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L)); + verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString()); + verify(loadingListService).publishCompletedEvent(10L); + verify(completionCommands).complete(931L, "worker-completed-checkpoints"); + } + + @Test + void actualLoadedOwnerWithoutCompletedCheckpointStillFailsItsExistingReadyGate() { + LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.completionOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "实际来源已经LOADED且无完成检查点")) + .when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + + assertThatThrownBy(() -> service.processCompletion(completionCommand(932L, "missing-checkpoint"), "worker-missing-checkpoint")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()).isEqualTo("OWNER_READY")); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + void anEntirelySkippedTaskCannotCreateACompletionCommand() { + when(repository.taskForUpdate(20L)).thenReturn(task()); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.completionOwners(20L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.complete(20L, new CompleteLoadingRequest(0, "all-skipped", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("未关联有效入库单"); + verify(completionCommands, never()).insert(any()); + verify(repository, never()).acceptCompletion(anyLong(), anyInt()); + } + @Test void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() { LoadingPickTaskEntity completing = task(); @@ -1040,7 +1128,7 @@ class LoadingPickServiceTest { when(repository.task(20L)).thenReturn(completing); List 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.completionOwners(20L)).thenReturn(owners); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.review(20L)).thenReturn(null); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); @@ -1066,7 +1154,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setTaskStatus(initialTaskStatus); when(repository.task(20L)).thenReturn(task); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); @@ -1102,7 +1190,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.review(20L)).thenReturn(null); @@ -1129,7 +1217,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(202L, 80L), new LoadingPickItemMapper.InboundOwner(101L, 60L), new LoadingPickItemMapper.InboundOwner(202L, 70L), @@ -1168,7 +1256,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 70L))); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪")) @@ -1196,7 +1284,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(101L, 60L))); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, @@ -1224,7 +1312,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); - when(repository.inboundOwners(20L)).thenReturn(List.of( + when(repository.completionOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 60L))); when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");