fix(firstleg): allow late initial container fill

This commit is contained in:
2026-09-01 15:38:33 +08:00
parent 07ffd8ddce
commit 8b2a964bfb
8 changed files with 93 additions and 11 deletions
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.lifecycle(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING、PDA尚未领取或产生物理作业事实时,可首次补录或纠正集装箱号并维护航次;已有柜号的纠正必须提供原因和版本,柜号不能清空。")
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@@ -265,6 +265,36 @@ public interface LoadingListQueryMapper {
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id
SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion}
AND b.container_no IS NULL
""")
int touchForInitialContainerFill(@Param("planId") long planId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking b
JOIN fl_loading_plan p ON p.container_booking_id=b.id
SET b.container_no=#{containerNo}, b.voyage_no=#{voyageNo},
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL
AND #{containerNo} IS NOT NULL
""")
int fillInitialContainerNo(@Param("planId") long planId,
@Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT CASE WHEN
EXISTS (SELECT 1 FROM fl_loading_pick_task task
@@ -86,6 +86,14 @@ public class LoadingListRepository {
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int fillInitialContainerNo(long planId, int expectedVersion, String containerNo, String voyageNo,
long operatorId, String operatorName) {
if (mapper.touchForInitialContainerFill(planId, expectedVersion, operatorId, operatorName) != 1) {
return 0;
}
return mapper.fillInitialContainerNo(planId, containerNo, voyageNo, operatorId, operatorName);
}
public boolean hasPdaWorkStarted(long planId) {
return mapper.hasPdaWorkStarted(planId) > 0;
}
@@ -433,9 +433,10 @@ public class LoadingListService {
return repository.preloadDetail(before);
}
requireTransportMutable(before);
String containerNo = targetContainerNo(before, request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
boolean initialContainerFill = !StringUtils.hasText(before.getContainerNo()) && containerNo != null;
requireTransportMutable(before, initialContainerFill);
boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo())
&& !java.util.Objects.equals(before.getContainerNo(), containerNo);
if (correctingContainerNo && !StringUtils.hasText(reason)) {
@@ -451,8 +452,12 @@ public class LoadingListService {
throw conflict("集装箱号已存在");
}
try {
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
int updated = initialContainerFill
? repository.fillInitialContainerNo(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName))
: repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName));
if (updated != 1) {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
@@ -583,12 +588,15 @@ public class LoadingListService {
}
}
private void requireTransportMutable(AggregateRow row) {
if (!"EDITING".equals(row.getPlanStatus())) {
private void requireTransportMutable(AggregateRow row, boolean initialContainerFill) {
if (initialContainerFill && !Set.of("EDITING", "LOADING").contains(row.getPlanStatus())) {
throw conflict("仅装柜完成前允许首次补录真实集装箱号");
}
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
}
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (repository.hasPdaWorkStarted(row.getPlanId())) {
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
}
}
@@ -49,7 +49,7 @@ paths:
patch:
summary: 修改预装运输信息
description: >-
仅已推送且仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时允许。可首次补录真实集装箱号;已有柜号的纠正必须提供原因和当前版本;
已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit
parameters:
@@ -69,7 +69,7 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.contains("可首次补录真实集装箱号")
.contains("首次补录真实集装箱号")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
@@ -80,6 +80,8 @@ class LoadingListQueryMapperContractTest {
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String touchInitialFill = sql("touchForInitialContainerFill", Update.class);
String initialFill = sql("fillInitialContainerNo", Update.class);
String confirmPreload = sql("confirmLinkedPreload", Update.class);
String confirmPlan = sql("touchForPreloadConfirmation", Update.class);
String advance = sql("advance", Update.class);
@@ -98,6 +100,16 @@ class LoadingListQueryMapperContractTest {
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(touchInitialFill)
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("p.version=#{expectedVersion}")
.contains("b.container_no IS NULL")
.doesNotContain("fl_loading_pick_assignment", "item_status");
assertThat(initialFill)
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("b.container_no IS NULL")
.contains("#{containerNo} IS NOT NULL")
.doesNotContain("fl_loading_pick_assignment", "item_status");
assertThat(advance)
.contains("plan_status = #{fromStatus}")
.contains("plan_status = #{toStatus}")
@@ -218,7 +218,7 @@ class LoadingListServiceTest {
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
afterFill.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(afterFill)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
@@ -229,10 +229,34 @@ class LoadingListServiceTest {
service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, null));
verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator");
verify(repository).fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
@Test
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
AggregateRow before = row("LOADING", 7);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("LOADING", 8);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
when(repository.fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "late-fill-key",
new TransportUpdateRequest("CSQU3054383", "023E", 7, null));
verify(repository).fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator");
verify(repository, never()).hasPdaWorkStarted(10L);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
org.mockito.ArgumentMatchers.isNull());
}
@Test
void correctingExistingContainerNumberStillRequiresReason() {
AggregateRow before = row("EDITING", 3);