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");
}
}
+16
View File
@@ -53,6 +53,22 @@ export async function run(context) {
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
## WAIT_SPLIT 到 LOADED 冻结链
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 预装 EDITING 补集装箱号 -> 关联 CONFIRMED 订舱 -> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;未补号、订舱未关联或未 `RELEASED`、整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
部署后需要定向验证该完整链时,模块顺序固定为:
```powershell
node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize
```
该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。
## 消息中心模块
`notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权:
@@ -233,6 +233,112 @@ export async function run(context) {
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
const pushKey = `${context.marker}-container-push`.slice(0, 128)
const pushRequest = Object.freeze({
path: `${SMART}/plans/${planId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assigned.version) }),
headers: Object.freeze({ 'Idempotency-Key': pushKey }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushed = context.apiSuccess(MODULE, '无真实柜号推送预装计划成功', pushResponse, '', OPTIONS)
context.assert(MODULE, '无号 push 仅冻结 EDITING 计划且版本递增', pushResponse,
id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null
&& Number(pushed?.version) > Number(assigned.version),
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS)
const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS)
const pushedLink = Array.isArray(pushedLinks)
? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null
context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse,
Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1,
`linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!pushedLink?.id) return
const pushedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse,
pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404',
responseEvidence(pushedTaskResponse), OPTIONS)
const pushBaseline = JSON.stringify({
plan: pushed, detail: pushedDetail, links: pushedLinks,
task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
context.apiSuccess(MODULE, '推送预装 exact replay', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const pushReplaySnapshot = JSON.stringify({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse,
pushBaseline === pushReplaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, replayBytes=${pushReplaySnapshot.length}`, OPTIONS)
const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushed.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody,
failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS)
const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
{ 'Idempotency-Key': `${context.marker}-container-push-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已推送计划使用新 key 重复 push 拒绝', pushNewKeyDuplicate,
failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse,
failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate)
&& pushBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (pushBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
const realContainerNo = `TST-${suffix}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(pushed.version), operationReason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse,
transported?.summary?.containerNo === realContainerNo
&& id(transported?.summary?.planId) === planId
&& id(transported?.summary?.containerBookingId) === containerId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, containerBookingId=${id(transported?.summary?.containerBookingId) || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== realContainerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404'
context.addStep(MODULE, '已补号但未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS)
if (!unlinkedTaskAbsent) return
const transportedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const transportedContainer = context.apiSuccess(MODULE, '预装补号后读取模拟柜最新版本', transportedContainerResponse, '', OPTIONS)
if (!Number.isInteger(Number(transportedContainer?.version))) return
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
const bookingCreated = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的已确认真实订舱', bookingCreate, '', OPTIONS)
@@ -264,7 +370,7 @@ export async function run(context) {
const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType)
if (!requirement?.id || !wrongTypeRequirement?.id) return
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.version),
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(transportedContainer.version),
})
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
@@ -303,7 +409,7 @@ export async function run(context) {
const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(containerDetail.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(transportedContainer.version),
})
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
responseEvidence(linkResponse), OPTIONS)
@@ -334,41 +440,11 @@ export async function run(context) {
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
const missingContainerPushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) },
{ 'Idempotency-Key': `${context.marker}-container-push-without-container-no`.slice(0, 128) })
const missingContainerPushRejected = failure(missingContainerPushResponse)
context.addStep(MODULE, '无真实柜号拒绝推送预装计划', missingContainerPushResponse,
missingContainerPushRejected,
`HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerPushRejected) return
const gatedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const gatedTaskAbsent = failure(gatedTaskResponse)
|| !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id)
context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', gatedTaskResponse,
gatedTaskAbsent,
`HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!gatedTaskAbsent) return
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(linkedContainer.version), reason: context.marker,
})
const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS)
context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse,
transported?.containerNo === `TST-${suffix}`.slice(0, 64)
&& id(transported?.id) === containerId && transported?.cabinetNo === linkedContainer.cabinetNo,
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${planId}`, OPTIONS)
if (transported?.containerNo !== `TST-${suffix}`.slice(0, 64)) return
const confirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedTaskAbsent = failure(confirmedTaskResponse)
|| !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id)
context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent,
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404'
context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS)
if (!confirmedTaskAbsent) return
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
@@ -383,29 +459,6 @@ export async function run(context) {
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const pushKey = `${context.marker}-container-push`.slice(0, 128)
const pushRequest = Object.freeze({
path: `${SMART}/plans/${planId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assigned.version) }),
headers: Object.freeze({ 'Idempotency-Key': pushKey }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
&& Number(pushReplay?.version) === Number(pushed.version),
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -413,20 +466,84 @@ export async function run(context) {
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务', unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
responseEvidence(unconfirmedTaskResponse), OPTIONS)
const confirmableResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
&& confirmable?.summary?.readiness?.pdaTaskId == null,
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return
const confirmPath = `${FIRST_LEG}/loading-plans/${planId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({
'Idempotency-Key': `${context.marker}-container-plan-confirm`.slice(0, 128),
})
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmedPreload = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirmResponse, '', OPTIONS)
const confirmedTaskId = id(confirmedPreload?.readiness?.pdaTaskId)
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirmResponse,
id(confirmedPreload?.planId) === planId && id(confirmedPreload?.containerBookingId) === containerId
&& confirmedPreload?.confirmationStatus === 'CONFIRMED'
&& confirmedPreload?.readiness?.confirmationStatus === 'CONFIRMED'
&& confirmedPreload?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
&& Number(confirmedPreload?.version) > confirmableVersion,
`confirmationStatus=${confirmedPreload?.confirmationStatus || 'missing'}, readyForPda=${confirmedPreload?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
if (!confirmedTaskId || confirmedPreload?.confirmationStatus !== 'CONFIRMED') return
const confirmReplayResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplayResponse,
confirmReplayResponse.http === 200 && codeOf(confirmReplayResponse) === '200'
&& id(confirmReplayResponse.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplayResponse.body?.data?.version) === Number(confirmedPreload.version),
responseEvidence(confirmReplayResponse), OPTIONS)
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
{ expectedVersion: Number(confirmedPreload.version) }, confirmHeaders)
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
responseEvidence(confirmSameKeyDifferentBody), OPTIONS)
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
{ 'Idempotency-Key': `${context.marker}-container-plan-confirm-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
responseEvidence(confirmNewKeyDuplicate), OPTIONS)
const confirmedInboundResponse = await context.request('GET',
`/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`)
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
const confirmedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS)
context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse,
confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING'
&& Number(confirmedPlan?.version) === Number(confirmedPreload.version),
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId
Boolean(taskId) && taskId === confirmedTaskId && id(task?.loadingPlanId) === planId
&& id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo)
&& task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && task?.taskStatus === 'LOADING'
&& task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
cabinetNo: id(task.cabinetNo),
@@ -9,6 +9,7 @@ const overviewOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-c
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const overviewAcceptance = read('phase3-container-overview.mjs')
const oceanAcceptance = read('ocean-bookings.mjs')
const oceanFixture = read('ocean-booking-fixture.mjs')
const adminRoot = '../../../apps/admin-web/src/modules/'
const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`)
const releaseTypes = read(`${adminRoot}ocean-bookings/types.ts`)
@@ -28,7 +29,7 @@ test('registration freezes all five transport fields and release reuses the conf
assert.match(releaseTypes, new RegExp(`${field}: string`), field)
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
assert.match(releaseView, new RegExp(`detail\\.value\\.${field}`), field)
assert.match(oceanAcceptance, new RegExp(`${field}:`), field)
assert.match(oceanFixture, new RegExp(`${field}:`), field)
}
assert.match(releaseView, /当前订舱缺少已确认运输资料,无法放舱/)
assert.match(releaseView, /放舱不会重新采集或修改这些信息/)
@@ -84,14 +85,22 @@ test('existing container number replacement keeps container plan and PDA identit
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
})
test('PDA task opens only after preload push, real container number and ocean release converge', () => {
assert.match(containerLoadingAcceptance, /无真实柜号拒绝推送预装计划/)
assert.match(containerLoadingAcceptance, /无真实柜号推送失败后不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/)
test('PDA task opens only after no-number push, transport, release and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(containerLoadingAcceptance, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/)
assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/)
assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/)
assert.match(containerLoadingAcceptance, /计划级确认同 key 异参返回冲突/)
assert.match(containerLoadingAcceptance, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoadingAcceptance, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
})
+197 -148
View File
@@ -144,38 +144,6 @@ export async function run(context) {
&& simulation?.oceanBookingId == null,
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
if (!containerBookingId) return
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
const bookingCreated = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期订舱', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingCreated?.oceanBooking?.id)
if (!bookingId) return
const bookingConfirmed = bookingCreated
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
frozenTransportMatches,
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportMatches) return
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, requirementId=${id(requirement?.id) || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (!requirement?.id) return
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, '关联三期模拟柜与真实订舱', linkResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱关联保持内部柜次身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
@@ -204,93 +172,149 @@ export async function run(context) {
&& Number(assignedLoadingPlan?.version) === 1,
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0,
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
const planId = id(smartData?.preloadPlan?.id)
const smartPreloadVersion = Number(smartData?.preloadPlan?.version)
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
&& Number.isInteger(smartPreloadVersion) && smartPreloadVersion >= 0
&& smartData?.preloadPlan?.items?.length === 1,
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id)
context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse,
Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)),
`preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS)
if (!planId) return
const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, items: [], remark: 'replay must not replace',
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay,
codeOf(replay) === '200' && id(replay.body?.data?.preloadPlan?.id) === planId
&& replay.body?.data?.preloadPlan?.items?.length === 1,
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
const bindVersions = Object.freeze({
expectedPreloadVersion: smartPreloadVersion,
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
const pushRequest = Object.freeze({
path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }),
headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }),
})
const bindResponse = await request(context, 'POST',
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
&& Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion
&& Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion,
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS)
context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse,
id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING'
&& pushedLoadingPlan?.containerNo == null
&& Number(pushedLoadingPlan?.version) > Number(assignedLoadingPlan.version),
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
if (pushedLoadingPlan?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS)
const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS)
const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse,
Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId
&& Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!link?.id) return
const missingContainerPushResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
{ expectedVersion: Number(link.loadingPlanVersion) },
{ 'Idempotency-Key': `${context.marker}-bridge-push-without-container-no`.slice(0, 128) })
const missingContainerPushRejected = failure(missingContainerPushResponse)
context.addStep(MODULE, '无真实柜号拒绝推送柜次计划', missingContainerPushResponse,
missingContainerPushRejected,
`HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerPushRejected) return
const missingContainerTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const missingContainerTaskAbsent = failure(missingContainerTaskResponse)
|| !id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id)
context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', missingContainerTaskResponse,
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse,
missingContainerTaskAbsent,
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, task=${id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerTaskAbsent) return
const containerBeforeTransportResponse = await context.request('GET',
`/api/platform/v1/containers/${containerBookingId}`)
const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次最新版本',
containerBeforeTransportResponse, '', OPTIONS)
const transportVersion = Number(containerBeforeTransport?.version)
context.assert(MODULE, '补真实柜号使用订舱关联后的最新柜次版本', containerBeforeTransportResponse,
id(containerBeforeTransport?.id) === containerBookingId && Number.isInteger(transportVersion),
`containerBookingId=${id(containerBeforeTransport?.id) || 'missing'}, version=${containerBeforeTransport?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(transportVersion)) return
const replayBaseline = JSON.stringify({
plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks,
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replaySnapshot = JSON.stringify({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse,
replayBaseline === replaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, replayBytes=${replaySnapshot.length}`, OPTIONS)
const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushedLoadingPlan.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody,
failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS)
const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
{ 'Idempotency-Key': `${context.marker}-bridge-push-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已推送计划使用新 key 重复 push 返回冲突', newKeyDuplicate,
failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
failure(sameKeyDifferentBody) && failure(newKeyDuplicate)
&& replayBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (replayBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
const transportResponse = await request(context, 'PATCH',
`/api/platform/v1/containers/${containerBookingId}/transport`, {
containerNo, voyageNo: `P3V-${suffix}`, version: transportVersion,
reason: `${context.marker}-bind-container-no`,
})
const transported = context.apiSuccess(MODULE, '绑定真实柜号后开放三期 PDA 推送门禁', transportResponse, '', OPTIONS)
context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse,
transported?.containerNo === containerNo && id(transported?.id) === containerBookingId
&& transported?.cabinetNo === linked.cabinetNo,
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (transported?.containerNo !== containerNo) return
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version),
operationReason: `${context.marker}-bind-container-no`,
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse,
transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId
&& id(transported?.summary?.containerBookingId) === containerBookingId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== containerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '已补号但未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404',
`HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS)
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期 CONFIRMED 订舱', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingConfirmed?.oceanBooking?.id)
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`)
const latestContainer = context.apiSuccess(MODULE, '补号后读取模拟柜最新版本用于订舱关联', latestContainerResponse, '', OPTIONS)
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(latestContainer?.version),
})
const linked = context.apiSuccess(MODULE, '预装补号后关联三期 CONFIRMED 订舱', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
const confirmedBookingTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedBookingTaskAbsent = failure(confirmedBookingTaskResponse)
|| !id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id)
context.addStep(MODULE, '订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务', confirmedBookingTaskResponse,
confirmedBookingTaskAbsent,
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!confirmedBookingTaskAbsent) return
context.addStep(MODULE, '订舱已关联但未 RELEASED 仍无 PDA 任务', confirmedBookingTaskResponse,
confirmedBookingTaskResponse.http === 200 && codeOf(confirmedBookingTaskResponse) === 'A0404',
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}`, OPTIONS)
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
@@ -301,24 +325,66 @@ export async function run(context) {
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, transportFrozen=${transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
{ expectedVersion: Number(link.loadingPlanVersion) },
{ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) })
const pushedLoadingPlan = context.apiSuccess(MODULE, '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse,
pushedLoadingPlan?.status === 'EDITING'
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}`, OPTIONS)
const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: initialVersion, remark: 'must confirm preload first' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) })
context.addStep(MODULE, '预装未确认时拒绝完成装柜', blockedBeforePreloadConfirm,
failure(blockedBeforePreloadConfirm),
`HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS)
const confirmableResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
&& confirmable?.summary?.readiness?.pdaTaskId == null,
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return
const confirmPath = `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-p3-plan-confirm`.slice(0, 128) })
const confirm = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirm, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirm,
id(confirmed?.planId) === loadingPlanId && id(confirmed?.containerBookingId) === containerBookingId
&& confirmed?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
&& Number(confirmed?.version) > confirmableVersion,
`confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplay,
confirmReplay.http === 200 && codeOf(confirmReplay) === '200'
&& id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplay.body?.data?.version) === Number(confirmed.version),
`HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS)
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
{ expectedVersion: Number(confirmed.version) }, confirmHeaders)
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
`HTTP=${confirmSameKeyDifferentBody.http}, code=${codeOf(confirmSameKeyDifferentBody)}, requestId=${confirmSameKeyDifferentBody.requestId || 'missing'}, traceId=${confirmSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
{ 'Idempotency-Key': `${context.marker}-p3-plan-confirm-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
`HTTP=${confirmNewKeyDuplicate.http}, code=${codeOf(confirmNewKeyDuplicate)}, requestId=${confirmNewKeyDuplicate.requestId || 'missing'}, traceId=${confirmNewKeyDuplicate.traceId || 'missing'}`, OPTIONS)
const confirmedInboundResponse = await context.request('GET', detailPath)
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
const confirmedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS)
context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse,
confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING'
&& Number(confirmedPlan?.version) === Number(confirmed.version),
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
const canonicalTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -326,11 +392,17 @@ export async function run(context) {
const canonicalTaskId = id(canonicalTask?.id)
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId
Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId
&& id(canonicalTask?.containerBookingId) === containerBookingId
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
if (!canonicalTaskId || !canonicalItem?.id) return
const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS)
const beforeClaimPlanVersion = Number(beforeClaimPlan?.version)
if (!Number.isInteger(beforeClaimPlanVersion)) return
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0),
operationId: `${context.marker}-bridge-claim`.slice(0, 128),
@@ -343,7 +415,7 @@ export async function run(context) {
const claimedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS)
context.assert(MODULE, '领取 PDA 任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
const inventoryResponse = await context.request('GET',
@@ -607,34 +679,11 @@ export async function run(context) {
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
confirmingData?.preloadPlan?.status === 'CONFIRMING'
&& Number(confirmingData?.preloadPlan?.version) > Number(link.preloadVersion),
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
const confirm = await request(context, 'POST', `${base}/preload/confirm?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(confirmingData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirm`.slice(0, 128) })
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
context.assert(MODULE, directPdaCompletion
? '装柜已开始时预装确认保持 LOADING 和 CONFIRMING'
: '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
directPdaCompletion
? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING'
&& Number(confirmed?.preloadPlan?.version) === Number(confirmingData.preloadPlan.version)
: confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
&& Number(confirmed?.preloadPlan?.version) > Number(confirmingData.preloadPlan.version),
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmed?.mainStatus !== 'LOADING') return
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
context.assert(MODULE, '预装清单归属、明细和 marker 持久化', preload,
context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload,
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
&& preloadData?.items?.[0]?.containerCode === `P3-${suffix}` && preloadData?.remark === context.marker,
&& preloadData?.status === 'CONFIRMED',
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
const loadingResponse = await context.request('GET', detailPath)
@@ -83,47 +83,69 @@ test('container loading follows push, claim, real pick, completed sequence', ()
assert.match(value, /operationId: `\$\{context\.marker\}-pda-complete`\.slice\(0, 128\)/)
})
test('bridge loading starts through a real PDA batch before preload confirmation', () => {
test('bridge loading confirms preload before creating and claiming the PDA task', () => {
const value = source('./phase3-loading.mjs')
assertOrder(value, [
'原子关联入库预装与柜次装柜计划',
'无真实柜号拒绝推送柜次计划',
'无真实柜号推送失败后不生成 PDA 任务',
'绑定真实柜号后开放三期 PDA 推送门禁',
'订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务',
'无真实柜号推送预装成功并冻结柜次明细',
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'预装 EDITING 清单绑定真实柜号',
'已补号但未关联订舱仍无 PDA 任务',
'预装补号后关联三期 CONFIRMED 订舱',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'三期订舱 RELEASED 后保持模拟柜关联身份',
'真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务',
"pushedLoadingPlan?.status === 'EDITING'",
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
'读取柜次级 PDA 唯一事实任务',
"claimedPlan?.status === 'LOADING'",
"const canonicalPick = await request",
"pickedPlan?.status === 'COMPLETED'",
"const confirming = await request",
])
assert.match(value, /confirmed\?\.mainStatus === 'LOADING' && confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMED'/)
assert.doesNotMatch(value, /关联柜次计划进入 LOADING/)
assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === Number\(confirmingData\.preloadPlan\.version\)/)
assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
assert.match(value, /version: transportVersion/)
assert.match(value, /Number\.isInteger\(transportVersion\)/)
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
assert.match(value, /confirmed\?\.readiness\?\.readyForPda === true/)
assert.match(value, /canonicalTaskId === confirmedTaskId/)
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /loading-plans\/\$\{loadingPlanId\}\/transport\?warehouseId=/)
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assertOrder(value, [
'CONFIRMED 订舱回显完整且冻结五项运输快照',
'if (!frozenTransportMatches) return',
'三期订舱携带已冻结运输快照并放舱',
])
assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/)
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => {
for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) {
const value = source(file)
assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/)
assert.match(value, /push exact replay[\s\S]*零漂移/)
assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/)
assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/)
assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/)
assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/)
}
})
test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => {
const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay'))
assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/)
assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/)
assert.doesNotMatch(push, /loadingPickService\.beginIfReady/)
assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/)
assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/)
})
test('finalize rechecks the same unified order detail and list as LOADED', () => {
const value = source('./phase3-loading-finalize.mjs')
assertOrder(value, [
@@ -176,6 +198,8 @@ test('container loading arranges a simulation before linking a confirmed real bo
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
"const assignResponse = await request",
'无真实柜号推送预装计划成功',
'预装 EDITING 清单绑定本轮真实柜号',
"const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
])
@@ -188,8 +212,7 @@ test('container loading arranges a simulation before linking a confirmed real bo
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /containerAllocations\.length === 0/)
assert.match(value, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.doesNotMatch(value, /version: Number\(containerDetail\?\.version \?\? 0\), reason: context\.marker/)
assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/)
@@ -206,11 +229,18 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport"))
assert.match(value, /在放舱前绑定本轮真实箱号/)
assert.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId='))
assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /无真实柜号拒绝推送预装计划/)
assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(value, /confirmationStatus === 'CONFIRMED'/)
assert.match(value, /readiness\?\.readyForPda === true/)
assert.match(value, /taskId === confirmedTaskId/)
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
assert.match(value, /POST', CONTAINERS/)
})
@@ -283,13 +313,17 @@ test('container loading enforces channel isolation and stable PDA identity acros
'新增真实异渠道模拟柜',
'两个真实有效渠道之间拒绝跨渠道装柜',
'人工分配本轮候选货物',
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'预装 EDITING 清单绑定本轮真实柜号',
'已补号但未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'无真实柜号拒绝推送预装计划',
'无真实柜号推送失败后不生成 PDA 任务',
'在放舱前绑定本轮真实箱号',
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
'柜号与 RELEASED 订舱就绪后推送预装计划',
'订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
'换柜号前读取 PDA 稳定库存快照',
'PDA 任务创建后替换真实柜号',
'PDA 任务创建后清空真实柜号失败',
@@ -320,11 +354,10 @@ test('preload and loading versions use immediate operation baselines and exact p
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
const loading = source('./phase3-loading.mjs')
assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/)
assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/)
assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/)
assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/)
assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/)
assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/)
assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2)
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
})
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
+60 -17
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
const scriptsDir = dirname(fileURLToPath(import.meta.url))
const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8')
@@ -62,33 +63,75 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
})
test('test-environment loading chains bind the latest container version before opening PDA', () => {
test('test-environment loading chains push without a number and open PDA only after all prerequisites', () => {
const containerLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8')
const bridgeLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(containerLoading, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.match(containerLoading, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
const bridgeIndex = bridgeLoading.indexOf('原子关联入库预装与柜次装柜计划')
const rejectedPushIndex = bridgeLoading.indexOf('无真实柜号拒绝推送柜次计划')
const transportIndex = bridgeLoading.indexOf('绑定真实柜号后开放三期 PDA 推送门禁')
const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const transportIndex = bridgeLoading.indexOf('预装 EDITING 清单绑定真实柜号')
const linkIndex = bridgeLoading.indexOf('预装补号后关联三期 CONFIRMED 订舱')
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
const successfulPushIndex = bridgeLoading.indexOf('真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
assert.ok(bridgeIndex >= 0 && rejectedPushIndex > bridgeIndex && transportIndex > rejectedPushIndex
&& releaseIndex > transportIndex && successfulPushIndex > releaseIndex && pdaIndex > successfulPushIndex)
assert.match(bridgeLoading, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
assert.match(bridgeLoading, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
assert.match(bridgeLoading, /version: transportVersion/)
assert.ok(pushIndex >= 0 && absentIndex > pushIndex && transportIndex > absentIndex
&& linkIndex > transportIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/)
assert.match(bridgeLoading, /readiness\?\.readyForPda === true/)
assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerRejectedPushIndex = containerLoading.indexOf('无真实柜号拒绝推送预装计划')
const containerTransportIndex = containerLoading.indexOf('在放舱前绑定本轮真实箱号')
const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
const containerTransportIndex = containerLoading.indexOf('预装 EDITING 清单绑定本轮真实柜号')
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
const containerSuccessfulPushIndex = containerLoading.indexOf('柜号与 RELEASED 订舱就绪后推送预装计划')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
assert.ok(containerRejectedPushIndex >= 0 && containerTransportIndex > containerRejectedPushIndex
&& containerReleaseIndex > containerTransportIndex && containerSuccessfulPushIndex > containerReleaseIndex
&& containerPdaIndex > containerSuccessfulPushIndex)
assert.ok(containerPushIndex >= 0 && containerTransportIndex > containerPushIndex
&& containerLinkIndex > containerTransportIndex && containerReleaseIndex > containerLinkIndex
&& containerConfirmIndex > containerReleaseIndex
&& containerPdaIndex > containerConfirmIndex)
assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoading, /readiness\?\.readyForPda === true/)
assert.match(containerLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
})
test('default Admin HTTP entry carries one marker-isolated loading chain through evidence and LOADED', () => {
const focused = normalizeModuleOrder([
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs',
'phase3-loading-finalize',
])
assert.deepEqual(focused, [
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs',
'phase3-loading-finalize',
])
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
const evidenceGate = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-evidence-gate.mjs'), 'utf8')
const customs = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-customs.mjs'), 'utf8')
const finalize = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading-finalize.mjs'), 'utf8')
assert.match(loading, /context\.createdResources\.phase3Loading = \{[\s\S]*marker: context\.marker/)
for (const field of ['inboundId', 'loadingPlanId', 'containerBookingId', 'canonicalPdaTaskId']) {
assert.match(loading, new RegExp(`context\\.createdResources\\.phase3Loading = \\{[\\s\\S]*${field}`))
}
assert.match(loading, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/)
assert.match(evidenceGate, /fixture\??\.marker !== context\.marker/)
assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/)
assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/)
assert.match(finalize, /fixture\?\.marker !== context\.marker/)
assert.match(finalize, /统一硬门禁通过后推进 LOADED 且五类有效证据仍完整/)
assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/)
})