This commit is contained in:
2026-09-01 15:25:15 +08:00
parent 4d7ea28a61
commit d1225845ec
12 changed files with 129 additions and 45 deletions
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.lifecycle(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING、PDA尚未领取或产生物理作业事实时,可首次补录或纠正集装箱号并维护航次;已有柜号的纠正必须提供原因和版本,柜号不能清空。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
@@ -51,7 +51,7 @@ public final class LoadingListDtos {
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 500) String operationReason
@Size(max = 500) String operationReason
) {
}
@@ -250,7 +250,6 @@ public interface LoadingListQueryMapper {
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='EDITING' AND p.preload_pushed_at IS NOT NULL
AND #{containerNo} IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.id)
@@ -151,13 +151,12 @@ public class LoadingListRepository {
java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
if (!containerReady) blockers.add("CONTAINER_NO_MISSING");
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released;
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released;
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED"
@@ -423,7 +423,6 @@ public class LoadingListService {
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
String reason = trim(request.operationReason());
if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空");
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
@@ -434,15 +433,21 @@ public class LoadingListService {
return repository.preloadDetail(before);
}
requireTransportCorrectionMutable(before);
requireTransportMutable(before);
String containerNo = targetContainerNo(before, request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo())
&& !java.util.Objects.equals(before.getContainerNo(), containerNo);
if (correctingContainerNo && !StringUtils.hasText(reason)) {
throw badRequest("纠正已有真实集装箱号时纠错原因不能为空");
}
String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason);
if (java.util.Objects.equals(before.getContainerNo(), containerNo)
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
if (containerNo != null
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("集装箱号已存在");
}
try {
@@ -464,8 +469,9 @@ public class LoadingListService {
}
private static String targetContainerNo(AggregateRow before, String requested) {
return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo())
: Iso6346ContainerNumber.require(requested);
if (requested != null) return Iso6346ContainerNumber.require(requested);
return StringUtils.hasText(before.getContainerNo())
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
}
private static String transportHash(long planId, int version, String containerNo,
@@ -577,14 +583,11 @@ public class LoadingListService {
}
}
private void requireTransportCorrectionMutable(AggregateRow row) {
private void requireTransportMutable(AggregateRow row) {
if (!"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
}
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (!StringUtils.hasText(row.getContainerNo())) {
throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入");
}
if (repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
}
@@ -23,6 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -113,11 +114,11 @@ public class LoadingPickService {
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
/** Opens the field task only after planning, transport identity and ocean release have converged. */
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
var links = loadingModelBridge.byLoadingPlan(plan.getId());
@@ -137,10 +138,6 @@ public class LoadingPickService {
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
throw conflict("预装清单已确认或当前状态不允许确认");
}
var booking = smartLoadingRepository.booking(link.containerBookingId());
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("确认预装前必须录入真实集装箱号");
}
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -462,6 +459,7 @@ public class LoadingPickService {
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
if ("FAILED".equals(replay.getCommandStatus())) {
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
requireCompletionContainerNumber(taskId);
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试");
@@ -485,6 +483,7 @@ public class LoadingPickService {
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
requireCompletionContainerNumber(taskId);
if (!repository.allTaskItemsResolved(taskId)) {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
@@ -514,6 +513,18 @@ public class LoadingPickService {
return view(requiredTask(taskId), null);
}
private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
}
try {
Iso6346ContainerNumber.require(booking.getContainerNo());
} catch (BizException exception) {
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
}
}
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
@@ -49,8 +49,8 @@ paths:
patch:
summary: 修改预装运输信息
description: >-
唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许;
containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。
仅已推送且仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时允许。可首次补录真实集装箱号;已有柜号的纠正必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
@@ -66,7 +66,7 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/confirm:
post:
summary: 原子确认整份预装计划
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED;真实集装箱号可在PDA确认装柜完成前补录。任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
x-permission: platform:firstleg:preload:edit
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
@@ -313,11 +313,11 @@ components:
pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态}
confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态}
canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口}
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;task 已创建后仍为 true}
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;不依赖真实集装箱号,task 已创建后仍为 true}
blockingReasons:
type: array
description: 阻止计划确认的稳定原因码
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest:
type: object
required: [expectedVersion, operationReason]
@@ -428,4 +428,4 @@ components:
containerNo: {type: string, maxLength: 64, nullable: true, description: 新真实集装箱号;null表示保持当前值,空白字符串非法,禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因}
operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略}