fix(firstleg): finish only actual picked cargo owners

This commit is contained in:
2026-10-04 20:45:01 +08:00
parent 1302275b71
commit ea9732749a
6 changed files with 212 additions and 10 deletions
@@ -165,6 +165,21 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
""")
java.util.List<InboundOwner> 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<InboundOwner> selectCompletionOwners(@Param("taskId") long taskId);
record InboundOwner(Long tenantId, Long inboundId) {}
@Select("""
@@ -246,6 +246,10 @@ public class LoadingPickRepository {
return items.selectInboundOwners(taskId);
}
public List<LoadingPickItemMapper.InboundOwner> completionOwners(long taskId) {
return items.selectCompletionOwners(taskId);
}
public LoadingInboundMapper.Snapshot inbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
}
@@ -576,7 +576,7 @@ public class LoadingPickService {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
List<LoadingPickItemMapper.InboundOwner> discoveredOwners = repository.inboundOwners(taskId).stream()
List<LoadingPickItemMapper.InboundOwner> 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<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
List<LoadingPickItemMapper.InboundOwner> owners = repository.completionOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
@@ -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<InboundOwner> 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);
}
}
@@ -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();
@@ -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<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.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");