feat: close preload confirmation and loading flow

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent 5b8e7d6989
commit a35794e918
34 changed files with 1194 additions and 512 deletions
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
@@ -66,7 +68,7 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@@ -80,6 +82,19 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmPreload(
planId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@GetMapping("/loading-lists")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
@@ -83,12 +83,43 @@ public final class LoadingListDtos {
public record Customer(Long accountId, String customerName) {
}
public record PreloadReadiness(
boolean preloadPushed,
boolean containerNoReady,
int preloadLinkCount,
int confirmedPreloadCount,
Long oceanBookingId,
String oceanBookingStatus,
Long pdaTaskId,
String pdaTaskStatus,
String confirmationStatus,
boolean canConfirm,
boolean readyForPda,
List<String> blockingReasons
) {
public PreloadReadiness {
blockingReasons = blockingReasons == null ? List.of() : List.copyOf(blockingReasons);
}
}
public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) {
}
public record PreloadConfirmationResponse(
Long planId,
Long containerBookingId,
String confirmationStatus,
Integer version,
PreloadReadiness readiness
) {
}
public record PreloadSummary(
Long planId,
Long containerBookingId,
String preloadNo,
String containerNo,
String internalContainerNo,
String cabinetNo,
List<Customer> customers,
Long warehouseId,
String warehouseName,
@@ -101,7 +132,8 @@ public final class LoadingListDtos {
BigDecimal totalVolumeCbm,
BigDecimal totalGrossWeightKg,
LocalDateTime createdAt,
Integer version
Integer version,
PreloadReadiness readiness
) {
public PreloadSummary {
customers = customers == null ? List.of() : List.copyOf(customers);
@@ -115,7 +147,7 @@ public final class LoadingListDtos {
Long warehouseId,
String warehouseName,
String containerNo,
String internalContainerNo,
String cabinetNo,
String status,
long totalPackageCount,
BigDecimal totalGrossWeightKg,
@@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
+ "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b "
+ "p.plan_status status,p.version,p.preload_pushed_at preloadPushedAt FROM fl_loading_plan p JOIN fl_container_booking b "
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
@@ -74,6 +74,11 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {}
String status, Integer version, java.time.LocalDateTime preloadPushedAt) {
public LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {
this(id, containerBookingId, containerNo, status, version, null);
}
}
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
}
@@ -112,6 +112,23 @@ public class LoadingModelBridgeService {
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) {
var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) {
throw conflict("仅已推送且仍为EDITING的预装计划可以确认");
}
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
List<Link> links = repository.byLoadingPlan(loadingPlanId);
boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link ->
Objects.equals(link.tenantId(), scope.tenantId())
&& Objects.equals(link.inboundId(), scope.inboundId())
&& link.allocatedPackageCount() != null && link.allocatedPackageCount() > 0));
if (!complete) throw conflict("装柜计划的预装关联不完整");
return links;
}
public void requireStarted(long tenantId, long inboundId) {
List<Link> links = requireLinks(tenantId, inboundId);
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
@@ -21,9 +21,24 @@ import java.util.List;
public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link confirmed_link
JOIN fl_preload_plan confirmed_preload ON confirmed_preload.id = confirmed_link.preload_plan_id
WHERE confirmed_link.loading_plan_id = p.id
AND confirmed_preload.status = 'CONFIRMED') AS confirmed_preload_count,
(SELECT allocation.ocean_booking_id FROM fl_ocean_booking_container_allocation allocation
WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_id,
(SELECT ocean.status FROM fl_ocean_booking_container_allocation allocation
JOIN fl_ocean_booking ocean ON ocean.id = allocation.ocean_booking_id
WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_status,
(SELECT pick_task.id FROM fl_loading_pick_task pick_task
WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_id,
(SELECT pick_task.task_status FROM fl_loading_pick_task pick_task
WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_status,
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
COALESCE(SUM(i.package_count), 0) AS total_package_count,
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
@@ -34,7 +49,7 @@ public interface LoadingListQueryMapper {
String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type
""";
@@ -202,7 +217,8 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'LOADING'
WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING')
AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL)
AND version = #{expectedVersion}
""")
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@@ -214,7 +230,8 @@ public interface LoadingListQueryMapper {
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 = 'LOADING'
WHERE p.id = #{planId} AND p.plan_status IN ('EDITING', 'LOADING')
AND (p.plan_status = 'LOADING' OR p.preload_pushed_at IS NOT NULL)
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id))
""")
@@ -222,6 +239,30 @@ public interface LoadingListQueryMapper {
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_preload_plan
SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{preloadPlanId} AND status = #{fromStatus} AND version = #{expectedVersion}
""")
int confirmLinkedPreload(@Param("preloadPlanId") long preloadPlanId,
@Param("fromStatus") String fromStatus,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
AND version = #{expectedVersion}
""")
int touchForPreloadConfirmation(@Param("planId") long planId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan
SET plan_status = #{toStatus},
@@ -268,6 +309,7 @@ public interface LoadingListQueryMapper {
private String createdByName;
private LocalDateTime createdAt;
private Integer version;
private LocalDateTime preloadPushedAt;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
@@ -275,6 +317,12 @@ public interface LoadingListQueryMapper {
private String vesselName;
private String voyageNo;
private String containerType;
private Long preloadLinkCount;
private Long confirmedPreloadCount;
private Long oceanBookingId;
private String oceanBookingStatus;
private Long pdaTaskId;
private String pdaTaskStatus;
private Long skuCount;
private Long totalPackageCount;
private BigDecimal totalGrossWeightKg;
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
@@ -85,6 +86,17 @@ public class LoadingListRepository {
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion,
operatorId, operatorName);
}
public int touchForPreloadConfirmation(long planId, int expectedVersion,
long operatorId, String operatorName) {
return mapper.touchForPreloadConfirmation(planId, expectedVersion, operatorId, operatorName);
}
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName);
@@ -116,7 +128,36 @@ public class LoadingListRepository {
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion());
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion(), readiness(row));
}
private PreloadReadiness readiness(AggregateRow row) {
boolean pushed = row.getPreloadPushedAt() != null;
boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo());
int linkCount = Math.toIntExact(value(row.getPreloadLinkCount()));
int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount()));
boolean oceanLinked = row.getOceanBookingId() != null;
boolean released = "RELEASED".equals(row.getOceanBookingStatus());
boolean editing = "EDITING".equals(row.getPlanStatus());
boolean taskCreated = row.getPdaTaskId() != null;
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 canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED"
: confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING";
return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount,
row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(),
row.getPdaTaskStatus(), confirmationStatus, canConfirm,
prerequisitesReady && allConfirmed, blockers);
}
private ConfirmedSummary confirmed(AggregateRow row) {
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
@@ -18,6 +20,7 @@ import com.baiyue.firstleg.client.LoadingDocumentFileClient;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -28,6 +31,7 @@ import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.context.annotation.Lazy;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@@ -38,6 +42,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -57,6 +62,9 @@ public class LoadingListService {
@Resource
private LoadingPickRepository pickRepository;
@Resource
@Lazy
private LoadingPickService loadingPickService;
@Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource
private ObjectMapper objectMapper;
@@ -420,9 +428,51 @@ public class LoadingListService {
"containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName);
return repository.preloadDetail(requirePlan(planId));
}
@Transactional
@BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "原子确认装柜计划全部预装票据")
public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName,
String idempotencyKey,
ConfirmPreloadRequest request) {
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion());
String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) return confirmationReplay(replay, requestHash);
AggregateRow before = requirePlan(planId);
List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId);
if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(),
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
}
Map<Long, Link> preloads = new LinkedHashMap<>();
links.forEach(link -> preloads.putIfAbsent(link.preloadPlanId(), link));
for (Link link : preloads.values()) {
if (repository.confirmLinkedPreload(link.preloadPlanId(), link.preloadStatus(),
link.preloadVersion(), operatorId, trim(operatorName)) != 1) {
throw conflict("关联预装票据已被其他操作修改,请刷新后重试");
}
}
links.stream().map(Link::containerBookingId).filter(java.util.Objects::nonNull).distinct()
.forEach(containerBookingId -> loadingPickService.beginIfReady(
containerBookingId, operatorId, operatorName));
AggregateRow after = requirePlan(planId);
PreloadDetail detail = repository.preloadDetail(after);
PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId,
after.getContainerBookingId(), detail.summary().readiness().confirmationStatus(),
after.getVersion(), detail.summary().readiness());
repository.appendHistory(planId, operationId, "CONFIRM_PRELOAD",
before.getPlanStatus(), after.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "response", response)),
operatorId, trim(operatorName), null);
return response;
}
/**
* PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@@ -481,6 +531,9 @@ public class LoadingListService {
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
}
if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) {
throw conflict("仅已推送的预装清单可维护运输字段");
}
}
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
@@ -507,6 +560,17 @@ public class LoadingListService {
}
}
private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) {
requireSameRequest(replay, requestHash);
try {
var response = objectMapper.readTree(replay.getAfterSnapshot()).get("response");
if (response == null) throw new IllegalStateException("预装确认幂等响应缺失");
return objectMapper.treeToValue(response, PreloadConfirmationResponse.class);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法读取预装确认幂等响应", exception);
}
}
private Map<String, Object> snapshot(AggregateRow row) {
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
@@ -32,6 +32,8 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -71,6 +73,8 @@ public class LoadingPickService {
@Resource
private OceanBookingRepository oceanBookingRepository;
@Resource
private LoadingModelBridgeService loadingModelBridge;
@Resource
private LoadingListService loadingListService;
@Resource
private InboundStatusHistoryMapper inboundHistories;
@@ -88,6 +92,8 @@ public class LoadingPickService {
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
var links = loadingModelBridge.byLoadingPlan(plan.getId());
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
if (allocation == null) return;
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -97,6 +103,29 @@ public class LoadingPickService {
smartLoadingRepository.items(plan.getId()));
}
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
for (Link link : links) {
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());
if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) {
throw conflict("确认预装前关联订舱必须已放舱");
}
if (repository.taskByPlan(link.loadingPlanId()) != null) {
throw conflict("PDA任务已创建,不能重复确认预装");
}
}
return links;
}
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
@@ -236,9 +236,6 @@ public class SmartLoadingService {
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (!StringUtils.hasText(booking.getContainerNo())) {
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
}
if (key.equals(plan.getPushIdempotencyKey())) {
requirePushReplay(planId, key, requestHash);
return view(plan, booking);
@@ -261,7 +258,6 @@ public class SmartLoadingService {
}
LoadingPlanEntity pushed = repository.plan(planId);
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
@@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
@@ -44,32 +43,6 @@ public class PlatformSplitPreloadController {
return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "提交预装清单确认")
@PostMapping("/{inboundId}/preload/confirming")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId",
description = "提交预装清单确认")
public ApiResponse<SplitActionResponse> confirming(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认预装清单并进入装柜中")
@PostMapping("/{inboundId}/preload/confirm")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认预装清单")
public ApiResponse<SplitActionResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询预装清单")
@GetMapping("/{inboundId}/preload")
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
@@ -18,8 +18,6 @@ public final class SplitRequests {
List<@Valid PreloadItem> items,
@Size(max = 1000) String remark) {}
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
public record PreloadItem(
@Size(max = 64) String containerCode,
@Size(max = 128) String cargoReference,
@@ -16,12 +16,4 @@ public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, "
+ "updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} "
+ "AND version=#{expectedVersion}")
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
}
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.split.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SplitInboundMapper {
@@ -11,11 +10,5 @@ public interface SplitInboundMapper {
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -20,10 +20,6 @@ public class SplitRepository {
return inbounds.find(tenantId, inboundId);
}
public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
.eq(PreloadPlanEntity::getTenantId, tenantId)
@@ -39,12 +35,6 @@ public class SplitRepository {
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
}
public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus,
long operatorId, String operatorName, int expectedVersion) {
return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus,
operatorId, operatorName, expectedVersion);
}
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
@@ -2,11 +2,7 @@ package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
@@ -24,19 +20,14 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.List;
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
@Service
public class SplitPreloadService {
private static final String SMART_SPLIT = "SMART_SPLIT";
private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING";
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -101,67 +92,6 @@ public class SplitPreloadService {
}
}
@Transactional
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
boolean loading = "LOADING".equals(inbound.processStatus());
if (loading) {
// Starting a real loading task advances the inbound before its preload
// confirmation is submitted; retain the linked-container hard gate.
loadingModelBridge.requireStarted(tenantId, inboundId);
} else {
requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认");
}
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT",
response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
public PreloadPlanResponse detail(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return response(requirePlan(tenantId, inboundId));
@@ -209,19 +139,6 @@ public class SplitPreloadService {
return "WAIT_SPLIT".equals(status);
}
private void addHistory(long tenantId, long inboundId, String from, String to, String action,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId);
history.setInboundId(inboundId);
history.setFromStatus(from);
history.setToStatus(to);
history.setAction(action);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
histories.insert(history);
}
private PreloadPlanResponse response(PreloadPlanEntity plan) {
if (plan == null) return null;
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
@@ -361,8 +361,9 @@ paths:
post:
summary: 推送智能装柜计划到预装清单
description: >-
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号,
只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。
预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 CONFIRMED/RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
@@ -17,7 +17,7 @@ paths:
- name: status
in: query
description: 预装状态
schema: {type: string, enum: [LOADING]}
schema: {type: string, enum: [EDITING, LOADING]}
- {$ref: '#/components/parameters/OptionalWarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
@@ -34,7 +34,7 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
patch:
summary: 修改预装运输信息
description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。
description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
@@ -47,6 +47,17 @@ paths:
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/confirm:
post:
summary: 原子确认整份预装计划
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
x-permission: platform:firstleg:preload:edit
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}}
responses:
'200': {$ref: '#/components/responses/PreloadConfirmationResponse'}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
@@ -175,6 +186,9 @@ components:
PreloadDetailResponse:
description: 预装清单详情查询或操作成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
PreloadConfirmationResponse:
description: 计划级预装确认成功、精确重放或业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadConfirmationEnvelope'}}}
ConfirmedPageResponse:
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
@@ -218,6 +232,14 @@ components:
data:
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
nullable: true
PreloadConfirmationEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/PreloadConfirmation'}]
nullable: true
ConfirmedPageEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
@@ -268,26 +290,61 @@ components:
customerName: {type: string, nullable: true, description: 客户名称}
PreloadSummary:
type: object
required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version, readiness]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号;不得与真实集装箱号混用}
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
vesselName: {type: string, nullable: true, description: 船名}
voyageNo: {type: string, nullable: true, description: 航次}
containerType: {type: string, nullable: true, description: 货柜类型}
status: {type: string, enum: [LOADING], description: 装柜状态}
status: {type: string, enum: [EDITING, LOADING], description: 装柜计划状态}
skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
createdAt: {type: string, format: date-time, description: 创建时间}
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
readiness: {$ref: '#/components/schemas/PreloadReadiness'}
PreloadReadiness:
type: object
required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons]
properties:
preloadPushed: {type: boolean, description: 是否已完成 push-preload}
containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo}
preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数}
confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数}
oceanBookingId: {$ref: '#/components/schemas/NullableLongId'}
oceanBookingStatus: {type: string, nullable: true, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED], description: 真实订舱当前状态}
pdaTaskId: {$ref: '#/components/schemas/NullableLongId'}
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}
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]}
ConfirmPreloadRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version}
PreloadConfirmation:
type: object
required: [planId, containerBookingId, confirmationStatus, version, readiness]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
confirmationStatus: {type: string, enum: [CONFIRMED]}
version: {type: integer, format: int32, minimum: 0, description: 确认后的装柜计划 version}
readiness: {$ref: '#/components/schemas/PreloadReadiness'}
ConfirmedSummary:
type: object
required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
@@ -298,7 +355,7 @@ components:
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号}
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Phase 3 Preload and Container Loading Bridge API
version: 1.0.0
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before PDA task creation; LOADING plans cannot be repaired through post-hoc binding.
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before any PDA task creation; push-preload never requires containerNo and never advances LOADING. LOADING plans cannot be repaired through post-hoc binding.
paths:
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
get:
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.LoadingListService;
@@ -34,6 +35,8 @@ class LoadingListControllerContractTest {
authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, ConfirmPreloadRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
@@ -50,6 +53,8 @@ class LoadingListControllerContractTest {
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
audit("confirmPreload", "PRELOAD_PLAN_CONFIRM", long.class, long.class, String.class,
String.class, ConfirmPreloadRequest.class);
}
@Test
@@ -74,6 +79,16 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.contains("已推送且仍为 EDITING")
.contains("不能清空已创建 PDA 任务的柜号")
.contains("/loading-plans/{planId}/confirm")
.contains("confirmationStatus")
.contains("canConfirm")
.contains("blockingReasons")
.contains("oceanBookingStatus")
.contains("pdaTaskId")
.contains("cabinetNo")
.doesNotContain("internalContainerNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
.doesNotContain("/items/{itemId}");
@@ -114,6 +129,7 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans",
"/api/platform/v1/first-leg/loading-plans/{planId}",
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
@@ -125,7 +141,9 @@ class LoadingListControllerContractTest {
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
@@ -134,7 +152,9 @@ class LoadingListControllerContractTest {
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary"))
.containsKeys("planId", "containerBookingId", "customers", "skuCount");
.containsKeys("planId", "containerBookingId", "containerNo", "cabinetNo",
"customers", "skuCount", "readiness")
.doesNotContainKey("internalContainerNo");
assertThat(properties(schemas, "ConfirmedSummary"))
.containsKeys("planId", "containerBookingId", "loadingListNo");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
@@ -26,11 +26,13 @@ class PlatformSmartLoadingControllerContractTest {
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
@Test void openApiRequiresRealContainerNumberBeforePreloadPush() throws Exception {
@Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:")
.contains("真实柜号必须已录入")
.contains("PDA作业任务");
.contains("不要求真实柜号")
.contains("不创建 PDA 作业任务")
.contains("领取后才进入 LOADING")
.doesNotContain("真实柜号必须已录入");
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -166,6 +166,19 @@ class LoadingModelBridgeServiceTest {
verify(repository, never()).insert(any());
}
@Test
void confirmationGateRejectsIncompleteAllocatedLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3, LocalDateTime.now()));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byLoadingPlan(40)).thenReturn(List.of(
link("PENDING", 2, "EDITING", 3, 8)));
assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40))
.hasMessageContaining("关联不完整");
}
@Test
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
when(repository.byInbound(10, 20)).thenReturn(List.of(
@@ -37,6 +37,10 @@ class LoadingListQueryMapperContractTest {
.contains("p.preload_pushed_at IS NOT NULL")
.contains("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0")
.contains("fl_preload_loading_plan_link")
.contains("fl_ocean_booking_container_allocation")
.contains("fl_loading_pick_task")
.contains("pda_task_status")
.contains("customer_item.account_id = #{query.customerId}")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
@@ -54,14 +58,19 @@ class LoadingListQueryMapperContractTest {
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String confirmPreload = sql("confirmLinkedPreload", Update.class);
String confirmPlan = sql("touchForPreloadConfirmation", Update.class);
String advance = sql("advance", Update.class);
assertThat(touch)
.contains("plan_status = 'LOADING'")
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("preload_pushed_at IS NOT NULL")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("preload_pushed_at IS NOT NULL")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.doesNotContain("DELETE")
@@ -71,6 +80,16 @@ class LoadingListQueryMapperContractTest {
.contains("plan_status = #{toStatus}")
.contains("version = #{expectedVersion}")
.doesNotContain("ROLLED_BACK");
assertThat(confirmPreload)
.contains("status = 'CONFIRMED'")
.contains("status = #{fromStatus}")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(confirmPlan)
.contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
}
@Test
@@ -39,6 +39,13 @@ class LoadingListRepositoryTest {
row.setPlanNo("LP-41");
row.setPlanStatus("LOADING");
row.setVersion(1);
row.setContainerNo("MSCU-001");
row.setCabinetNo("CAB-001");
row.setPreloadPushedAt(java.time.LocalDateTime.now());
row.setPreloadLinkCount(2L);
row.setConfirmedPreloadCount(1L);
row.setOceanBookingId(81L);
row.setOceanBookingStatus("RELEASED");
Page<AggregateRow> preloadPage = new Page<>(1, 20);
preloadPage.setRecords(List.of(row));
@@ -58,6 +65,10 @@ class LoadingListRepositoryTest {
assertThat(preload.planId()).isEqualTo(41L);
assertThat(preload.containerBookingId()).isEqualTo(61L);
assertThat(preload.containerNo()).isEqualTo("MSCU-001");
assertThat(preload.cabinetNo()).isEqualTo("CAB-001");
assertThat(preload.readiness().confirmationStatus()).isEqualTo("PARTIALLY_CONFIRMED");
assertThat(preload.readiness().oceanBookingStatus()).isEqualTo("RELEASED");
assertThat(confirmed.planId()).isEqualTo(41L);
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
}
@@ -6,6 +6,9 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
@@ -13,6 +16,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,6 +35,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -45,6 +50,8 @@ class LoadingListServiceTest {
@MockBean
private LoadingPickRepository pickRepository;
@MockBean
private LoadingPickService loadingPickService;
@MockBean
private LoadingDocumentFileClient documentFiles;
@MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@@ -87,11 +94,43 @@ class LoadingListServiceTest {
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号"));
}
@Test
void pushedEditingPreloadCanReceiveContainerNumber() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSLU1234567");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", null, 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", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"));
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
@Test
void editingPlanCannotReceiveTransportBeforePush() {
when(repository.aggregate(10L)).thenReturn(row("EDITING", 3));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")))
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void shouldRejectTransportUpdateAfterConfirmation() {
when(repository.history(anyString())).thenReturn(null);
@@ -456,10 +495,110 @@ class LoadingListServiceTest {
verify(repository, never()).aggregateByBooking(30L);
}
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
List<Link> links = List.of(
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
"MSCU-001", "EDITING", 3, 4, null),
new Link(2L, 102L, 202L, 302L, "CONFIRMING", 5, 10L, 30L,
"MSCU-001", "EDITING", 3, 6, null));
PreloadReadiness readiness = readiness("CONFIRMED", false, true, 80L);
var detail = org.mockito.Mockito.mock(
com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail.class);
var summary = org.mockito.Mockito.mock(
com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary.class);
when(detail.summary()).thenReturn(summary);
when(summary.readiness()).thenReturn(readiness);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links);
when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(detail);
PreloadConfirmationResponse response = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(4);
assertThat(response.readiness().pdaTaskId()).isEqualTo(80L);
verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator");
verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator");
verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"),
eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), org.mockito.ArgumentMatchers.isNull());
}
@Test
void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
String requestHash = sha256("CONFIRM_PRELOAD|10|3");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(new ObjectMapper().writeValueAsString(
java.util.Map.of("requestHash", requestHash, "response", expected)));
when(repository.history(anyString())).thenReturn(replay);
PreloadConfirmationResponse actual = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
assertThat(actual).isEqualTo(expected);
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
}
@Test
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(loadingPickService.requirePreloadConfirmationReady(10L))
.thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱"));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)))
.isInstanceOf(BizException.class).hasMessageContaining("第二票");
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void reusedConfirmationKeyWithDifferentVersionConflictsBeforeReadiness() {
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4)))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
}
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
boolean readyForPda, Long taskId) {
return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId,
taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
readyForPda, List.of());
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
row.setPlanNo("PL-10");
row.setContainerBookingId(30L);
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setVersion(version);
@@ -19,6 +19,8 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -38,6 +40,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -55,6 +58,8 @@ class LoadingPickServiceTest {
@MockBean
private OceanBookingRepository oceanBookingRepository;
@MockBean
private LoadingModelBridgeService loadingModelBridge;
@MockBean
private LoadingListService loadingListService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@@ -77,6 +82,8 @@ class LoadingPickServiceTest {
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(null);
@@ -102,6 +109,8 @@ class LoadingPickServiceTest {
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
@@ -110,6 +119,93 @@ class LoadingPickServiceTest {
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
}
@Test
void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(oceanBookingRepository, never()).allocationByContainer(40L);
}
@Test
void planConfirmationGateRequiresRealContainerAndReleasedBooking() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L); ocean.setStatus("RELEASED");
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
@Test
void planConfirmationGateRejectsMissingRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, null, "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号");
verify(oceanBookingRepository, never()).allocationByContainer(anyLong());
}
@Test
void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() {
Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2,
10L, 40L, "MSCU-001", "EDITING", 4, 2, null);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L))
.thenReturn(List.of(confirmed));
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("已确认");
verify(smartLoadingRepository, never()).booking(anyLong());
}
@Test
void readinessCreatesAtMostOneTask() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity());
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(smartLoadingRepository, never()).startPicking(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
LoadingPickTaskEntity task = task();
@@ -397,10 +397,10 @@ class SmartLoadingServiceTest {
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
assertThat(history.getValue().getAfterSnapshot())
.contains(sha256("PUSH_PRELOAD|1|3"));
var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService);
var ordered = inOrder(splitPreloadService, loadingModelBridge);
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
@@ -539,37 +539,34 @@ class SmartLoadingServiceTest {
}
@Test
void rejectsPushWithoutContainerNumberBeforePdaOpening() {
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing);
when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2));
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1);
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"),
BizException.class);
service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("真实柜号");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsReplayWhenTheContainerNumberIsStillMissing() {
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 2);
pushed.setPushIdempotencyKey("replay-key");
ContainerBookingEntity booking = booking();
booking.setContainerNo(" ");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking);
when(repository.operation("replay-key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"),
BizException.class);
service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -13,14 +12,19 @@ class PlatformSplitPreloadControllerContractTest {
@Test
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class);
assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertThat(PlatformSplitPreloadController.class
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
long.class, long.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:inbound:preload-view')");
}
@Test
void doesNotExposeInboundScopedPreloadConfirmation() {
assertThat(PlatformSplitPreloadController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("confirm")
|| method.getName().equals("confirming"));
}
private void assertAuthority(String name, String expected, Class<?> requestType) throws Exception {
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
@@ -1,8 +1,5 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
@@ -33,8 +30,6 @@ import static org.mockito.Mockito.never;
@ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest {
@Mock SplitRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -85,53 +80,4 @@ class SplitPreloadServiceTest {
verify(repository, never()).createPlan(any());
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4);
}
@Test
void startsPreloadConfirmationAfterLoadingBeginsWithLinkedContainerGate() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("PENDING"); plan.setVersion(3);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3)).thenReturn(1);
SplitActionResponse result = service.startConfirming(10, 20, 7, "admin", "k4",
new ConfirmPreloadRequest(3));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3);
}
@Test
void rejectsLoadingPreloadConfirmationWhenLinkedContainersHaveNotStarted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
org.mockito.Mockito.doThrow(new IllegalStateException("not started"))
.when(loadingModelBridge).requireStarted(10, 20);
assertThatThrownBy(() -> service.startConfirming(10, 20, 7, "admin", "k5",
new ConfirmPreloadRequest(3)))
.isInstanceOf(IllegalStateException.class)
.hasMessage("not started");
}
}