feat: close preload confirmation and loading flow
This commit is contained in:
+16
-1
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
|
|||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
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.TransportUpdateRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||||
@@ -66,7 +68,7 @@ public class PlatformLoadingListController {
|
|||||||
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
|
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
|
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
|
||||||
@PatchMapping("/loading-plans/{planId}/transport")
|
@PatchMapping("/loading-plans/{planId}/transport")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||||
public ApiResponse<PreloadDetail> updateTransport(
|
public ApiResponse<PreloadDetail> updateTransport(
|
||||||
@@ -80,6 +82,19 @@ public class PlatformLoadingListController {
|
|||||||
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
|
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 状态的装柜计划。")
|
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
|
||||||
@GetMapping("/loading-lists")
|
@GetMapping("/loading-lists")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||||
|
|||||||
@@ -83,12 +83,43 @@ public final class LoadingListDtos {
|
|||||||
public record Customer(Long accountId, String customerName) {
|
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(
|
public record PreloadSummary(
|
||||||
Long planId,
|
Long planId,
|
||||||
Long containerBookingId,
|
Long containerBookingId,
|
||||||
String preloadNo,
|
String preloadNo,
|
||||||
String containerNo,
|
String containerNo,
|
||||||
String internalContainerNo,
|
String cabinetNo,
|
||||||
List<Customer> customers,
|
List<Customer> customers,
|
||||||
Long warehouseId,
|
Long warehouseId,
|
||||||
String warehouseName,
|
String warehouseName,
|
||||||
@@ -101,7 +132,8 @@ public final class LoadingListDtos {
|
|||||||
BigDecimal totalVolumeCbm,
|
BigDecimal totalVolumeCbm,
|
||||||
BigDecimal totalGrossWeightKg,
|
BigDecimal totalGrossWeightKg,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
Integer version
|
Integer version,
|
||||||
|
PreloadReadiness readiness
|
||||||
) {
|
) {
|
||||||
public PreloadSummary {
|
public PreloadSummary {
|
||||||
customers = customers == null ? List.of() : List.copyOf(customers);
|
customers = customers == null ? List.of() : List.copyOf(customers);
|
||||||
@@ -115,7 +147,7 @@ public final class LoadingListDtos {
|
|||||||
Long warehouseId,
|
Long warehouseId,
|
||||||
String warehouseName,
|
String warehouseName,
|
||||||
String containerNo,
|
String containerNo,
|
||||||
String internalContainerNo,
|
String cabinetNo,
|
||||||
String status,
|
String status,
|
||||||
long totalPackageCount,
|
long totalPackageCount,
|
||||||
BigDecimal totalGrossWeightKg,
|
BigDecimal totalGrossWeightKg,
|
||||||
|
|||||||
+7
-2
@@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
|||||||
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||||
|
|
||||||
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
|
@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}")
|
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
|
||||||
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long 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 PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
|
||||||
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
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) {}
|
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -112,6 +112,23 @@ public class LoadingModelBridgeService {
|
|||||||
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
|
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> 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) {
|
public void requireStarted(long tenantId, long inboundId) {
|
||||||
List<Link> links = requireLinks(tenantId, inboundId);
|
List<Link> links = requireLinks(tenantId, inboundId);
|
||||||
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|
||||||
|
|||||||
@@ -21,9 +21,24 @@ import java.util.List;
|
|||||||
public interface LoadingListQueryMapper {
|
public interface LoadingListQueryMapper {
|
||||||
String AGGREGATE_COLUMNS = """
|
String AGGREGATE_COLUMNS = """
|
||||||
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
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.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||||
b.vessel_name, b.voyage_no, b.container_type,
|
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,
|
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
|
||||||
COALESCE(SUM(i.package_count), 0) AS total_package_count,
|
COALESCE(SUM(i.package_count), 0) AS total_package_count,
|
||||||
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
|
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
|
||||||
@@ -34,7 +49,7 @@ public interface LoadingListQueryMapper {
|
|||||||
|
|
||||||
String AGGREGATE_GROUP = """
|
String AGGREGATE_GROUP = """
|
||||||
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
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.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||||
b.vessel_name, b.voyage_no, b.container_type
|
b.vessel_name, b.voyage_no, b.container_type
|
||||||
""";
|
""";
|
||||||
@@ -202,7 +217,8 @@ public interface LoadingListQueryMapper {
|
|||||||
UPDATE fl_loading_plan
|
UPDATE fl_loading_plan
|
||||||
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
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}
|
AND version = #{expectedVersion}
|
||||||
""")
|
""")
|
||||||
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int 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},
|
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
|
||||||
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
|
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
|
||||||
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
|
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 (
|
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
|
||||||
SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id))
|
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("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
|
||||||
@Param("operatorName") String operatorName);
|
@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("""
|
||||||
UPDATE fl_loading_plan
|
UPDATE fl_loading_plan
|
||||||
SET plan_status = #{toStatus},
|
SET plan_status = #{toStatus},
|
||||||
@@ -268,6 +309,7 @@ public interface LoadingListQueryMapper {
|
|||||||
private String createdByName;
|
private String createdByName;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private Integer version;
|
private Integer version;
|
||||||
|
private LocalDateTime preloadPushedAt;
|
||||||
private String containerNo;
|
private String containerNo;
|
||||||
private String cabinetNo;
|
private String cabinetNo;
|
||||||
private Long warehouseId;
|
private Long warehouseId;
|
||||||
@@ -275,6 +317,12 @@ public interface LoadingListQueryMapper {
|
|||||||
private String vesselName;
|
private String vesselName;
|
||||||
private String voyageNo;
|
private String voyageNo;
|
||||||
private String containerType;
|
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 skuCount;
|
||||||
private Long totalPackageCount;
|
private Long totalPackageCount;
|
||||||
private BigDecimal totalGrossWeightKg;
|
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.PreloadDetail;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||||
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
|
||||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
|
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||||
@@ -85,6 +86,17 @@ public class LoadingListRepository {
|
|||||||
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
|
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,
|
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
|
||||||
long operatorId, String operatorName) {
|
long operatorId, String operatorName) {
|
||||||
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, 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(),
|
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
|
||||||
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
|
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
|
||||||
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
|
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) {
|
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.PreloadDetail;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
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.TransportUpdateRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
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.entity.ContainerDocumentEntity;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
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.LoadingListRepository;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
@@ -28,6 +31,7 @@ import feign.FeignException;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -38,6 +42,7 @@ import java.security.MessageDigest;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -57,6 +62,9 @@ public class LoadingListService {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoadingPickRepository pickRepository;
|
private LoadingPickRepository pickRepository;
|
||||||
@Resource
|
@Resource
|
||||||
|
@Lazy
|
||||||
|
private LoadingPickService loadingPickService;
|
||||||
|
@Resource
|
||||||
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
@@ -420,9 +428,51 @@ public class LoadingListService {
|
|||||||
"containerNo", containerNo, "voyageNo", voyageNo,
|
"containerNo", containerNo, "voyageNo", voyageNo,
|
||||||
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
|
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
|
||||||
trim(request.operationReason()));
|
trim(request.operationReason()));
|
||||||
|
loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName);
|
||||||
return repository.preloadDetail(requirePlan(planId));
|
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 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
|
* PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
|
||||||
*/
|
*/
|
||||||
@@ -481,6 +531,9 @@ public class LoadingListService {
|
|||||||
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
|
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
|
||||||
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
|
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
|
||||||
}
|
}
|
||||||
|
if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) {
|
||||||
|
throw conflict("仅已推送的预装清单可维护运输字段");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
|
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) {
|
private Map<String, Object> snapshot(AggregateRow row) {
|
||||||
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
|
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
|
||||||
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
|
"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.LoadingPlanHistoryEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
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.InboundStatusHistoryMapper;
|
||||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
@@ -71,6 +73,8 @@ public class LoadingPickService {
|
|||||||
@Resource
|
@Resource
|
||||||
private OceanBookingRepository oceanBookingRepository;
|
private OceanBookingRepository oceanBookingRepository;
|
||||||
@Resource
|
@Resource
|
||||||
|
private LoadingModelBridgeService loadingModelBridge;
|
||||||
|
@Resource
|
||||||
private LoadingListService loadingListService;
|
private LoadingListService loadingListService;
|
||||||
@Resource
|
@Resource
|
||||||
private InboundStatusHistoryMapper inboundHistories;
|
private InboundStatusHistoryMapper inboundHistories;
|
||||||
@@ -88,6 +92,8 @@ public class LoadingPickService {
|
|||||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
|
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
|
||||||
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
||||||
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
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);
|
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
|
||||||
if (allocation == null) return;
|
if (allocation == null) return;
|
||||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||||
@@ -97,6 +103,29 @@ public class LoadingPickService {
|
|||||||
smartLoadingRepository.items(plan.getId()));
|
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
|
@Transactional
|
||||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||||
List<LoadingPlanItemEntity> planItems) {
|
List<LoadingPlanItemEntity> planItems) {
|
||||||
|
|||||||
@@ -236,9 +236,6 @@ public class SmartLoadingService {
|
|||||||
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
|
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
|
||||||
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||||
if (!StringUtils.hasText(booking.getContainerNo())) {
|
|
||||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
|
||||||
}
|
|
||||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||||
requirePushReplay(planId, key, requestHash);
|
requirePushReplay(planId, key, requestHash);
|
||||||
return view(plan, booking);
|
return view(plan, booking);
|
||||||
@@ -261,7 +258,6 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
LoadingPlanEntity pushed = repository.plan(planId);
|
LoadingPlanEntity pushed = repository.plan(planId);
|
||||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||||
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
|
||||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-27
@@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit;
|
|||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
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.SplitRequests.SmartSplitRequest;
|
||||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
||||||
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
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));
|
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 = "查询预装清单")
|
@Operation(summary = "查询预装清单")
|
||||||
@GetMapping("/{inboundId}/preload")
|
@GetMapping("/{inboundId}/preload")
|
||||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
|
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ public final class SplitRequests {
|
|||||||
List<@Valid PreloadItem> items,
|
List<@Valid PreloadItem> items,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
|
|
||||||
|
|
||||||
public record PreloadItem(
|
public record PreloadItem(
|
||||||
@Size(max = 64) String containerCode,
|
@Size(max = 64) String containerCode,
|
||||||
@Size(max = 128) String cargoReference,
|
@Size(max = 128) String cargoReference,
|
||||||
|
|||||||
@@ -16,12 +16,4 @@ public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
|
|||||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||||
@Param("expectedVersion") int expectedVersion);
|
@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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface SplitInboundMapper {
|
public interface SplitInboundMapper {
|
||||||
@@ -11,11 +10,5 @@ public interface SplitInboundMapper {
|
|||||||
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||||
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
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) {}
|
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ public class SplitRepository {
|
|||||||
return inbounds.find(tenantId, inboundId);
|
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) {
|
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
|
||||||
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||||
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
||||||
@@ -39,12 +35,6 @@ public class SplitRepository {
|
|||||||
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
|
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,
|
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
|
||||||
String operation, String key) {
|
String operation, String key) {
|
||||||
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
|
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.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
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.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.PreloadItem;
|
||||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
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.util.StringUtils;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
|
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
|
||||||
@Service
|
@Service
|
||||||
public class SplitPreloadService {
|
public class SplitPreloadService {
|
||||||
private static final String SMART_SPLIT = "SMART_SPLIT";
|
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 SplitRepository repository;
|
||||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
|
||||||
@Resource private InboundStatusHistoryMapper histories;
|
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Transactional
|
@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) {
|
public PreloadPlanResponse detail(long tenantId, long inboundId) {
|
||||||
requireInbound(tenantId, inboundId);
|
requireInbound(tenantId, inboundId);
|
||||||
return response(requirePlan(tenantId, inboundId));
|
return response(requirePlan(tenantId, inboundId));
|
||||||
@@ -209,19 +139,6 @@ public class SplitPreloadService {
|
|||||||
return "WAIT_SPLIT".equals(status);
|
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) {
|
private PreloadPlanResponse response(PreloadPlanEntity plan) {
|
||||||
if (plan == null) return null;
|
if (plan == null) return null;
|
||||||
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
|
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
|
||||||
|
|||||||
@@ -361,8 +361,9 @@ paths:
|
|||||||
post:
|
post:
|
||||||
summary: 推送智能装柜计划到预装清单
|
summary: 推送智能装柜计划到预装清单
|
||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
|
需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号,
|
||||||
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
|
只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。
|
||||||
|
预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 CONFIRMED/RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。
|
||||||
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
|
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
|
||||||
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
|
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
|
||||||
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ paths:
|
|||||||
- name: status
|
- name: status
|
||||||
in: query
|
in: query
|
||||||
description: 预装状态
|
description: 预装状态
|
||||||
schema: {type: string, enum: [LOADING]}
|
schema: {type: string, enum: [EDITING, LOADING]}
|
||||||
- {$ref: '#/components/parameters/OptionalWarehouseId'}
|
- {$ref: '#/components/parameters/OptionalWarehouseId'}
|
||||||
- {$ref: '#/components/parameters/CreatedFrom'}
|
- {$ref: '#/components/parameters/CreatedFrom'}
|
||||||
- {$ref: '#/components/parameters/CreatedTo'}
|
- {$ref: '#/components/parameters/CreatedTo'}
|
||||||
@@ -34,7 +34,7 @@ paths:
|
|||||||
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
|
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
|
||||||
patch:
|
patch:
|
||||||
summary: 修改预装运输信息
|
summary: 修改预装运输信息
|
||||||
description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。
|
description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。
|
||||||
x-permission: platform:firstleg:preload:edit
|
x-permission: platform:firstleg:preload:edit
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/PlanId'}
|
- {$ref: '#/components/parameters/PlanId'}
|
||||||
@@ -47,6 +47,17 @@ paths:
|
|||||||
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
|
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
|
||||||
responses:
|
responses:
|
||||||
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
|
'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:
|
/api/platform/v1/first-leg/loading-lists:
|
||||||
get:
|
get:
|
||||||
summary: 分页查询装柜清单
|
summary: 分页查询装柜清单
|
||||||
@@ -175,6 +186,9 @@ components:
|
|||||||
PreloadDetailResponse:
|
PreloadDetailResponse:
|
||||||
description: 预装清单详情查询或操作成功,也可能返回业务失败
|
description: 预装清单详情查询或操作成功,也可能返回业务失败
|
||||||
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
|
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
|
||||||
|
PreloadConfirmationResponse:
|
||||||
|
description: 计划级预装确认成功、精确重放或业务失败
|
||||||
|
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadConfirmationEnvelope'}}}
|
||||||
ConfirmedPageResponse:
|
ConfirmedPageResponse:
|
||||||
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
|
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
|
||||||
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
|
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
|
||||||
@@ -218,6 +232,14 @@ components:
|
|||||||
data:
|
data:
|
||||||
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
|
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
|
||||||
nullable: true
|
nullable: true
|
||||||
|
PreloadConfirmationEnvelope:
|
||||||
|
allOf:
|
||||||
|
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
allOf: [{$ref: '#/components/schemas/PreloadConfirmation'}]
|
||||||
|
nullable: true
|
||||||
ConfirmedPageEnvelope:
|
ConfirmedPageEnvelope:
|
||||||
allOf:
|
allOf:
|
||||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||||
@@ -268,26 +290,61 @@ components:
|
|||||||
customerName: {type: string, nullable: true, description: 客户名称}
|
customerName: {type: string, nullable: true, description: 客户名称}
|
||||||
PreloadSummary:
|
PreloadSummary:
|
||||||
type: object
|
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:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
preloadNo: {type: string, description: 预装单号}
|
preloadNo: {type: string, description: 预装单号}
|
||||||
containerNo: {type: string, nullable: true, 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'}}
|
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
vesselName: {type: string, nullable: true, description: 船名}
|
vesselName: {type: string, nullable: true, description: 船名}
|
||||||
voyageNo: {type: string, nullable: true, description: 航次}
|
voyageNo: {type: string, nullable: true, description: 航次}
|
||||||
containerType: {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 种类数}
|
skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数}
|
||||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||||
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
|
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
|
||||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||||
createdAt: {type: string, format: date-time, description: 创建时间}
|
createdAt: {type: string, format: date-time, description: 创建时间}
|
||||||
version: {type: integer, format: int32, minimum: 0, 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:
|
ConfirmedSummary:
|
||||||
type: object
|
type: object
|
||||||
required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
|
required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
|
||||||
@@ -298,7 +355,7 @@ components:
|
|||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
containerNo: {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: 装柜清单状态}
|
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
|
||||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: Phase 3 Preload and Container Loading Bridge API
|
title: Phase 3 Preload and Container Loading Bridge API
|
||||||
version: 1.0.0
|
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:
|
paths:
|
||||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
|
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
|
||||||
get:
|
get:
|
||||||
|
|||||||
+22
-2
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
|||||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
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.DocumentAttachRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||||
import com.baiyue.firstleg.service.LoadingListService;
|
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("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class);
|
||||||
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
|
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
|
||||||
long.class, long.class, String.class, TransportUpdateRequest.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,
|
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||||
ConfirmedQuery.class);
|
ConfirmedQuery.class);
|
||||||
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
||||||
@@ -50,6 +53,8 @@ class LoadingListControllerContractTest {
|
|||||||
void writesAudited() throws Exception {
|
void writesAudited() throws Exception {
|
||||||
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
|
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
|
||||||
String.class, TransportUpdateRequest.class);
|
String.class, TransportUpdateRequest.class);
|
||||||
|
audit("confirmPreload", "PRELOAD_PLAN_CONFIRM", long.class, long.class, String.class,
|
||||||
|
String.class, ConfirmPreloadRequest.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -74,6 +79,16 @@ class LoadingListControllerContractTest {
|
|||||||
.contains("beforeSnapshot")
|
.contains("beforeSnapshot")
|
||||||
.contains("containerNo")
|
.contains("containerNo")
|
||||||
.contains("voyageNo")
|
.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("rollback")
|
||||||
.doesNotContain("delete:")
|
.doesNotContain("delete:")
|
||||||
.doesNotContain("/items/{itemId}");
|
.doesNotContain("/items/{itemId}");
|
||||||
@@ -114,6 +129,7 @@ class LoadingListControllerContractTest {
|
|||||||
"/api/platform/v1/first-leg/loading-plans",
|
"/api/platform/v1/first-leg/loading-plans",
|
||||||
"/api/platform/v1/first-leg/loading-plans/{planId}",
|
"/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}/transport",
|
||||||
|
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
|
||||||
"/api/platform/v1/first-leg/loading-lists",
|
"/api/platform/v1/first-leg/loading-lists",
|
||||||
"/api/platform/v1/first-leg/loading-lists/{planId}",
|
"/api/platform/v1/first-leg/loading-lists/{planId}",
|
||||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
|
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
|
||||||
@@ -125,7 +141,9 @@ class LoadingListControllerContractTest {
|
|||||||
assertThat(schemas).containsKeys(
|
assertThat(schemas).containsKeys(
|
||||||
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
|
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
|
||||||
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
|
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
|
||||||
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
|
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
|
||||||
|
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
|
||||||
|
"BusinessDocumentEnvelope",
|
||||||
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
|
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
|
||||||
assertThat(properties(schemas, "DocumentUploadRequest"))
|
assertThat(properties(schemas, "DocumentUploadRequest"))
|
||||||
.containsOnlyKeys("file", "expectedVersion", "operationReason");
|
.containsOnlyKeys("file", "expectedVersion", "operationReason");
|
||||||
@@ -134,7 +152,9 @@ class LoadingListControllerContractTest {
|
|||||||
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
|
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
|
||||||
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
|
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
|
||||||
assertThat(properties(schemas, "PreloadSummary"))
|
assertThat(properties(schemas, "PreloadSummary"))
|
||||||
.containsKeys("planId", "containerBookingId", "customers", "skuCount");
|
.containsKeys("planId", "containerBookingId", "containerNo", "cabinetNo",
|
||||||
|
"customers", "skuCount", "readiness")
|
||||||
|
.doesNotContainKey("internalContainerNo");
|
||||||
assertThat(properties(schemas, "ConfirmedSummary"))
|
assertThat(properties(schemas, "ConfirmedSummary"))
|
||||||
.containsKeys("planId", "containerBookingId", "loadingListNo");
|
.containsKeys("planId", "containerBookingId", "loadingListNo");
|
||||||
assertThat(properties(schemas, "LoadingItem")).containsKeys(
|
assertThat(properties(schemas, "LoadingItem")).containsKeys(
|
||||||
|
|||||||
+5
-3
@@ -26,11 +26,13 @@ class PlatformSmartLoadingControllerContractTest {
|
|||||||
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
|
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);
|
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"));
|
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:")
|
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:")
|
||||||
.contains("真实柜号必须已录入")
|
.contains("不要求真实柜号")
|
||||||
.contains("PDA作业任务");
|
.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+"')");}
|
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+"')");}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -166,6 +166,19 @@ class LoadingModelBridgeServiceTest {
|
|||||||
verify(repository, never()).insert(any());
|
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
|
@Test
|
||||||
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
||||||
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
||||||
|
|||||||
+20
-1
@@ -37,6 +37,10 @@ class LoadingListQueryMapperContractTest {
|
|||||||
.contains("p.preload_pushed_at IS NOT NULL")
|
.contains("p.preload_pushed_at IS NOT NULL")
|
||||||
.contains("p.plan_status IN ('EDITING', 'LOADING')")
|
.contains("p.plan_status IN ('EDITING', 'LOADING')")
|
||||||
.contains("active_item.deleted = 0")
|
.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("customer_item.account_id = #{query.customerId}")
|
||||||
.contains("b.warehouse_id = #{query.warehouseId}")
|
.contains("b.warehouse_id = #{query.warehouseId}")
|
||||||
.doesNotContain("query.accountId")
|
.doesNotContain("query.accountId")
|
||||||
@@ -54,14 +58,19 @@ class LoadingListQueryMapperContractTest {
|
|||||||
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
|
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
|
||||||
String touch = sql("touchEditable", Update.class);
|
String touch = sql("touchEditable", Update.class);
|
||||||
String transport = sql("updateTransport", 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);
|
String advance = sql("advance", Update.class);
|
||||||
|
|
||||||
assertThat(touch)
|
assertThat(touch)
|
||||||
.contains("plan_status = 'LOADING'")
|
.contains("plan_status IN ('EDITING', 'LOADING')")
|
||||||
|
.contains("preload_pushed_at IS NOT NULL")
|
||||||
.contains("version = #{expectedVersion}")
|
.contains("version = #{expectedVersion}")
|
||||||
.contains("version = version + 1");
|
.contains("version = version + 1");
|
||||||
assertThat(transport)
|
assertThat(transport)
|
||||||
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
|
.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("#{containerNo} IS NOT NULL")
|
||||||
.contains("fl_loading_pick_task")
|
.contains("fl_loading_pick_task")
|
||||||
.doesNotContain("DELETE")
|
.doesNotContain("DELETE")
|
||||||
@@ -71,6 +80,16 @@ class LoadingListQueryMapperContractTest {
|
|||||||
.contains("plan_status = #{toStatus}")
|
.contains("plan_status = #{toStatus}")
|
||||||
.contains("version = #{expectedVersion}")
|
.contains("version = #{expectedVersion}")
|
||||||
.doesNotContain("ROLLED_BACK");
|
.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
|
@Test
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class LoadingListRepositoryTest {
|
|||||||
row.setPlanNo("LP-41");
|
row.setPlanNo("LP-41");
|
||||||
row.setPlanStatus("LOADING");
|
row.setPlanStatus("LOADING");
|
||||||
row.setVersion(1);
|
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);
|
Page<AggregateRow> preloadPage = new Page<>(1, 20);
|
||||||
preloadPage.setRecords(List.of(row));
|
preloadPage.setRecords(List.of(row));
|
||||||
@@ -58,6 +65,10 @@ class LoadingListRepositoryTest {
|
|||||||
|
|
||||||
assertThat(preload.planId()).isEqualTo(41L);
|
assertThat(preload.planId()).isEqualTo(41L);
|
||||||
assertThat(preload.containerBookingId()).isEqualTo(61L);
|
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.planId()).isEqualTo(41L);
|
||||||
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
|
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.ConfirmedQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
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.DocumentAttachRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||||
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
|
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.common.core.api.ApiResponse;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
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.LoadingListRepository;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
@@ -45,6 +50,8 @@ class LoadingListServiceTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private LoadingPickRepository pickRepository;
|
private LoadingPickRepository pickRepository;
|
||||||
@MockBean
|
@MockBean
|
||||||
|
private LoadingPickService loadingPickService;
|
||||||
|
@MockBean
|
||||||
private LoadingDocumentFileClient documentFiles;
|
private LoadingDocumentFileClient documentFiles;
|
||||||
@MockBean
|
@MockBean
|
||||||
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
||||||
@@ -87,11 +94,43 @@ class LoadingListServiceTest {
|
|||||||
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
|
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
|
||||||
|
|
||||||
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
|
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"),
|
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
|
||||||
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
|
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
|
||||||
eq("operator"), eq("补录箱号"));
|
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
|
@Test
|
||||||
void shouldRejectTransportUpdateAfterConfirmation() {
|
void shouldRejectTransportUpdateAfterConfirmation() {
|
||||||
when(repository.history(anyString())).thenReturn(null);
|
when(repository.history(anyString())).thenReturn(null);
|
||||||
@@ -456,10 +495,110 @@ class LoadingListServiceTest {
|
|||||||
verify(repository, never()).aggregateByBooking(30L);
|
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) {
|
private static AggregateRow row(String status, int version) {
|
||||||
AggregateRow row = new AggregateRow();
|
AggregateRow row = new AggregateRow();
|
||||||
row.setPlanId(10L);
|
row.setPlanId(10L);
|
||||||
row.setPlanNo("PL-10");
|
row.setPlanNo("PL-10");
|
||||||
|
row.setContainerBookingId(30L);
|
||||||
row.setPlanStatus(status);
|
row.setPlanStatus(status);
|
||||||
row.setWarehouseId(100L);
|
row.setWarehouseId(100L);
|
||||||
row.setVersion(version);
|
row.setVersion(version);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
|||||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
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.InboundStatusHistoryMapper;
|
||||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -55,6 +58,8 @@ class LoadingPickServiceTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private OceanBookingRepository oceanBookingRepository;
|
private OceanBookingRepository oceanBookingRepository;
|
||||||
@MockBean
|
@MockBean
|
||||||
|
private LoadingModelBridgeService loadingModelBridge;
|
||||||
|
@MockBean
|
||||||
private LoadingListService loadingListService;
|
private LoadingListService loadingListService;
|
||||||
@MockBean
|
@MockBean
|
||||||
private InboundStatusHistoryMapper inboundHistories;
|
private InboundStatusHistoryMapper inboundHistories;
|
||||||
@@ -77,6 +82,8 @@ class LoadingPickServiceTest {
|
|||||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||||
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
|
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
|
||||||
when(smartLoadingRepository.plan(10L)).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.allocationByContainer(40L)).thenReturn(allocation);
|
||||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||||
when(repository.taskByPlan(10L)).thenReturn(null);
|
when(repository.taskByPlan(10L)).thenReturn(null);
|
||||||
@@ -102,6 +109,8 @@ class LoadingPickServiceTest {
|
|||||||
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
|
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
|
||||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||||
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
|
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.allocationByContainer(40L)).thenReturn(allocation);
|
||||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||||
|
|
||||||
@@ -110,6 +119,93 @@ class LoadingPickServiceTest {
|
|||||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
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
|
@Test
|
||||||
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
|
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
|
|||||||
@@ -397,10 +397,10 @@ class SmartLoadingServiceTest {
|
|||||||
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
|
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
|
||||||
assertThat(history.getValue().getAfterSnapshot())
|
assertThat(history.getValue().getAfterSnapshot())
|
||||||
.contains(sha256("PUSH_PRELOAD|1|3"));
|
.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(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||||
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -539,37 +539,34 @@ class SmartLoadingServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsPushWithoutContainerNumberBeforePdaOpening() {
|
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
|
||||||
LoadingPlanEntity editing = plan("EDITING", 1);
|
LoadingPlanEntity editing = plan("EDITING", 1);
|
||||||
ContainerBookingEntity booking = booking();
|
ContainerBookingEntity booking = booking();
|
||||||
booking.setContainerNo(null);
|
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.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");
|
||||||
() -> service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"),
|
|
||||||
BizException.class);
|
|
||||||
|
|
||||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
verify(repository).push(1, "no-container-key", 1, 7, "u");
|
||||||
assertThat(error.getMessage()).contains("真实柜号");
|
|
||||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
|
||||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsReplayWhenTheContainerNumberIsStillMissing() {
|
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
|
||||||
LoadingPlanEntity pushed = plan("EDITING", 2);
|
LoadingPlanEntity pushed = plan("EDITING", 2);
|
||||||
pushed.setPushIdempotencyKey("replay-key");
|
pushed.setPushIdempotencyKey("replay-key");
|
||||||
ContainerBookingEntity booking = booking();
|
ContainerBookingEntity booking = booking();
|
||||||
booking.setContainerNo(" ");
|
booking.setContainerNo(" ");
|
||||||
when(repository.plan(1)).thenReturn(pushed);
|
when(repository.plan(1)).thenReturn(pushed);
|
||||||
when(repository.booking(99)).thenReturn(booking);
|
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");
|
||||||
() -> service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"),
|
|
||||||
BizException.class);
|
|
||||||
|
|
||||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
|
||||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,5 @@
|
|||||||
package com.baiyue.firstleg.split.controller;
|
package com.baiyue.firstleg.split.controller;
|
||||||
|
|
||||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
|
||||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -13,14 +12,19 @@ class PlatformSplitPreloadControllerContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
|
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
|
||||||
assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class);
|
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
|
assertThat(PlatformSplitPreloadController.class
|
||||||
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
|
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
|
||||||
long.class, long.class).getAnnotation(PreAuthorize.class).value())
|
long.class, long.class).getAnnotation(PreAuthorize.class).value())
|
||||||
.isEqualTo("hasAuthority('firstleg:inbound:preload-view')");
|
.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 {
|
private void assertAuthority(String name, String expected, Class<?> requestType) throws Exception {
|
||||||
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
|
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
|
||||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
|
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.baiyue.firstleg.split.service;
|
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.PreloadItem;
|
||||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||||
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
||||||
@@ -33,8 +30,6 @@ import static org.mockito.Mockito.never;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class SplitPreloadServiceTest {
|
class SplitPreloadServiceTest {
|
||||||
@Mock SplitRepository repository;
|
@Mock SplitRepository repository;
|
||||||
@Mock LoadingModelBridgeService loadingModelBridge;
|
|
||||||
@Mock InboundStatusHistoryMapper histories;
|
|
||||||
@InjectMocks SplitPreloadService service;
|
@InjectMocks SplitPreloadService service;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@@ -85,53 +80,4 @@ class SplitPreloadServiceTest {
|
|||||||
verify(repository, never()).createPlan(any());
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ export async function run(context) {
|
|||||||
|
|
||||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
表中的 `*-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 验收需要当前对话明确授权:
|
`notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权:
|
||||||
|
|||||||
@@ -233,6 +233,112 @@ export async function run(context) {
|
|||||||
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
|
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
|
||||||
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
|
`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',
|
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||||
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
||||||
const bookingCreated = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的已确认真实订舱', bookingCreate, '', OPTIONS)
|
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)
|
const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType)
|
||||||
if (!requirement?.id || !wrongTypeRequirement?.id) return
|
if (!requirement?.id || !wrongTypeRequirement?.id) return
|
||||||
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
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,
|
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
|
||||||
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
|
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 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`, {
|
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,
|
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
|
||||||
responseEvidence(linkResponse), OPTIONS)
|
responseEvidence(linkResponse), OPTIONS)
|
||||||
@@ -334,41 +440,11 @@ export async function run(context) {
|
|||||||
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
|
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
|
||||||
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
|
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',
|
const confirmedTaskResponse = await context.request('GET',
|
||||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const confirmedTaskAbsent = failure(confirmedTaskResponse)
|
const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404'
|
||||||
|| !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id)
|
context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse,
|
||||||
context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse,
|
confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS)
|
||||||
confirmedTaskAbsent,
|
|
||||||
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
|
|
||||||
if (!confirmedTaskAbsent) return
|
if (!confirmedTaskAbsent) return
|
||||||
|
|
||||||
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
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)
|
`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
|
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',
|
const preloadPageResponse = await context.request('GET',
|
||||||
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
|
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
|
||||||
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
|
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(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
|
||||||
`records=${records(preloadPage).length}`, OPTIONS)
|
`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',
|
const taskResponse = await context.request('GET',
|
||||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
|
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
|
||||||
const taskId = id(task?.id)
|
const taskId = id(task?.id)
|
||||||
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
|
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
|
||||||
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
|
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)
|
&& 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'
|
&& task?.workflowStatus === 'UNASSIGNED'
|
||||||
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
|
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
|
||||||
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
|
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
|
||||||
if (!taskId || !pickItem?.id) return
|
if (!taskId || !pickItem?.id) return
|
||||||
|
|
||||||
|
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||||
|
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
|
||||||
|
|
||||||
const stableIdentity = {
|
const stableIdentity = {
|
||||||
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
|
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
|
||||||
cabinetNo: id(task.cabinetNo),
|
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 oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
|
||||||
const overviewAcceptance = read('phase3-container-overview.mjs')
|
const overviewAcceptance = read('phase3-container-overview.mjs')
|
||||||
const oceanAcceptance = read('ocean-bookings.mjs')
|
const oceanAcceptance = read('ocean-bookings.mjs')
|
||||||
|
const oceanFixture = read('ocean-booking-fixture.mjs')
|
||||||
const adminRoot = '../../../apps/admin-web/src/modules/'
|
const adminRoot = '../../../apps/admin-web/src/modules/'
|
||||||
const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`)
|
const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`)
|
||||||
const releaseTypes = read(`${adminRoot}ocean-bookings/types.ts`)
|
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(releaseTypes, new RegExp(`${field}: string`), field)
|
||||||
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
|
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
|
||||||
assert.match(releaseView, new RegExp(`detail\\.value\\.${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, /当前订舱缺少已确认运输资料,无法放舱/)
|
||||||
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/)
|
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PDA task opens only after preload push, real container number and ocean release converge', () => {
|
test('PDA task opens only after no-number push, transport, release and plan-level confirmation', () => {
|
||||||
assert.match(containerLoadingAcceptance, /无真实柜号拒绝推送预装计划/)
|
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
|
||||||
assert.match(containerLoadingAcceptance, /无真实柜号推送失败后不生成 PDA 任务/)
|
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
|
||||||
assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
|
assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/)
|
||||||
assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/)
|
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
|
||||||
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
|
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
|
||||||
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
|
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'/)
|
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -144,38 +144,6 @@ export async function run(context) {
|
|||||||
&& simulation?.oceanBookingId == null,
|
&& simulation?.oceanBookingId == null,
|
||||||
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
|
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
|
||||||
if (!containerBookingId) return
|
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 candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
|
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
|
||||||
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
|
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,
|
&& Number(assignedLoadingPlan?.version) === 1,
|
||||||
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||||||
expectedVersion: 0,
|
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
|
||||||
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
|
const planId = id(pendingPreload?.id)
|
||||||
remark: context.marker,
|
context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse,
|
||||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)),
|
||||||
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
`preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, 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)
|
|
||||||
if (!planId) return
|
if (!planId) return
|
||||||
|
|
||||||
const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
const pushRequest = Object.freeze({
|
||||||
expectedVersion: 0, items: [], remark: 'replay must not replace',
|
path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
|
||||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }),
|
||||||
context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay,
|
headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }),
|
||||||
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 bindResponse = await request(context, 'POST',
|
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
|
||||||
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
|
const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS)
|
||||||
bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse,
|
||||||
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
|
id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING'
|
||||||
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
|
&& pushedLoadingPlan?.containerNo == null
|
||||||
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
|
&& Number(pushedLoadingPlan?.version) > Number(assignedLoadingPlan.version),
|
||||||
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
|
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
&& Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion
|
if (pushedLoadingPlan?.status !== 'EDITING') return
|
||||||
&& Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion,
|
|
||||||
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
|
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
|
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',
|
const missingContainerTaskResponse = await context.request('GET',
|
||||||
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const missingContainerTaskAbsent = failure(missingContainerTaskResponse)
|
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
|
||||||
|| !id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id)
|
context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse,
|
||||||
context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', missingContainerTaskResponse,
|
|
||||||
missingContainerTaskAbsent,
|
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
|
if (!missingContainerTaskAbsent) return
|
||||||
|
|
||||||
const containerBeforeTransportResponse = await context.request('GET',
|
const replayBaseline = JSON.stringify({
|
||||||
`/api/platform/v1/containers/${containerBookingId}`)
|
plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks,
|
||||||
const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次最新版本',
|
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
|
||||||
containerBeforeTransportResponse, '', OPTIONS)
|
})
|
||||||
const transportVersion = Number(containerBeforeTransport?.version)
|
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
|
||||||
context.assert(MODULE, '补真实柜号使用订舱关联后的最新柜次版本', containerBeforeTransportResponse,
|
context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS)
|
||||||
id(containerBeforeTransport?.id) === containerBookingId && Number.isInteger(transportVersion),
|
const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
`containerBookingId=${id(containerBeforeTransport?.id) || 'missing'}, version=${containerBeforeTransport?.version ?? 'missing'}`, OPTIONS)
|
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
|
||||||
if (!Number.isInteger(transportVersion)) return
|
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',
|
const transportResponse = await request(context, 'PATCH',
|
||||||
`/api/platform/v1/containers/${containerBookingId}/transport`, {
|
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
||||||
containerNo, voyageNo: `P3V-${suffix}`, version: transportVersion,
|
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version),
|
||||||
reason: `${context.marker}-bind-container-no`,
|
operationReason: `${context.marker}-bind-container-no`,
|
||||||
})
|
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
|
||||||
const transported = context.apiSuccess(MODULE, '绑定真实柜号后开放三期 PDA 推送门禁', transportResponse, '', OPTIONS)
|
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse,
|
context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse,
|
||||||
transported?.containerNo === containerNo && id(transported?.id) === containerBookingId
|
transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId
|
||||||
&& transported?.cabinetNo === linked.cabinetNo,
|
&& id(transported?.summary?.containerBookingId) === containerBookingId,
|
||||||
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS)
|
`containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
|
||||||
if (transported?.containerNo !== containerNo) return
|
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',
|
const confirmedBookingTaskResponse = await context.request('GET',
|
||||||
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const confirmedBookingTaskAbsent = failure(confirmedBookingTaskResponse)
|
context.addStep(MODULE, '订舱已关联但未 RELEASED 仍无 PDA 任务', confirmedBookingTaskResponse,
|
||||||
|| !id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id)
|
confirmedBookingTaskResponse.http === 200 && codeOf(confirmedBookingTaskResponse) === 'A0404',
|
||||||
context.addStep(MODULE, '订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务', confirmedBookingTaskResponse,
|
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}`, OPTIONS)
|
||||||
confirmedBookingTaskAbsent,
|
|
||||||
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
|
|
||||||
if (!confirmedBookingTaskAbsent) return
|
|
||||||
|
|
||||||
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
||||||
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
|
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
|
||||||
@@ -301,24 +325,66 @@ export async function run(context) {
|
|||||||
bookingReleased?.oceanBooking?.status === 'RELEASED'
|
bookingReleased?.oceanBooking?.status === 'RELEASED'
|
||||||
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
|
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
|
||||||
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
|
&& 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
|
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
|
||||||
|
|
||||||
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
|
const unconfirmedTaskResponse = await context.request('GET',
|
||||||
{ expectedVersion: Number(link.loadingPlanVersion) },
|
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
{ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) })
|
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', unconfirmedTaskResponse,
|
||||||
const pushedLoadingPlan = context.apiSuccess(MODULE, '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', pushResponse, '', OPTIONS)
|
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
|
||||||
context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse,
|
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}`, OPTIONS)
|
||||||
pushedLoadingPlan?.status === 'EDITING'
|
|
||||||
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
|
|
||||||
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
|
|
||||||
|
|
||||||
const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
const confirmableResponse = await context.request('GET',
|
||||||
{ expectedVersion: initialVersion, remark: 'must confirm preload first' },
|
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) })
|
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
|
||||||
context.addStep(MODULE, '预装未确认时拒绝完成装柜', blockedBeforePreloadConfirm,
|
const confirmableVersion = Number(confirmable?.summary?.version)
|
||||||
failure(blockedBeforePreloadConfirm),
|
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
|
||||||
`HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS)
|
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',
|
const canonicalTaskResponse = await context.request('GET',
|
||||||
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
@@ -326,11 +392,17 @@ export async function run(context) {
|
|||||||
const canonicalTaskId = id(canonicalTask?.id)
|
const canonicalTaskId = id(canonicalTask?.id)
|
||||||
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
|
||||||
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
|
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),
|
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
|
||||||
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
|
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
|
||||||
if (!canonicalTaskId || !canonicalItem?.id) return
|
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`, {
|
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
|
||||||
expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0),
|
expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0),
|
||||||
operationId: `${context.marker}-bridge-claim`.slice(0, 128),
|
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 claimedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS)
|
const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '领取 PDA 任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
|
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)
|
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const inventoryResponse = await context.request('GET',
|
const inventoryResponse = await context.request('GET',
|
||||||
@@ -607,34 +679,11 @@ export async function run(context) {
|
|||||||
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
||||||
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
`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 preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||||||
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
|
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
|
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)
|
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
|
||||||
|
|
||||||
const loadingResponse = await context.request('GET', detailPath)
|
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\)/)
|
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')
|
const value = source('./phase3-loading.mjs')
|
||||||
assertOrder(value, [
|
assertOrder(value, [
|
||||||
'原子关联入库预装与柜次装柜计划',
|
'无真实柜号推送预装成功并冻结柜次明细',
|
||||||
'无真实柜号拒绝推送柜次计划',
|
'无号 push 成功后 PDA 查询明确 A0404',
|
||||||
'无真实柜号推送失败后不生成 PDA 任务',
|
'push exact replay 对计划版本历史关联和任务零漂移',
|
||||||
'绑定真实柜号后开放三期 PDA 推送门禁',
|
'预装 EDITING 清单绑定真实柜号',
|
||||||
'订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务',
|
'已补号但未关联订舱仍无 PDA 任务',
|
||||||
|
'预装补号后关联三期 CONFIRMED 订舱',
|
||||||
|
'订舱已关联但未 RELEASED 仍无 PDA 任务',
|
||||||
'三期订舱携带已冻结运输快照并放舱',
|
'三期订舱携带已冻结运输快照并放舱',
|
||||||
'三期订舱 RELEASED 后保持模拟柜关联身份',
|
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
|
||||||
'真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务',
|
'计划级确认前读取即时版本与就绪门禁',
|
||||||
"pushedLoadingPlan?.status === 'EDITING'",
|
'计划级原子确认整份预装并触发唯一 PDA 任务',
|
||||||
|
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
|
||||||
|
'读取柜次级 PDA 唯一事实任务',
|
||||||
"claimedPlan?.status === 'LOADING'",
|
"claimedPlan?.status === 'LOADING'",
|
||||||
"const canonicalPick = await request",
|
"const canonicalPick = await request",
|
||||||
"pickedPlan?.status === 'COMPLETED'",
|
"pickedPlan?.status === 'COMPLETED'",
|
||||||
"const confirming = await request",
|
|
||||||
])
|
])
|
||||||
assert.match(value, /confirmed\?\.mainStatus === 'LOADING' && confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
|
||||||
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMED'/)
|
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
|
||||||
assert.doesNotMatch(value, /关联柜次计划进入 LOADING/)
|
assert.match(value, /confirmed\?\.readiness\?\.readyForPda === true/)
|
||||||
assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
|
assert.match(value, /canonicalTaskId === confirmedTaskId/)
|
||||||
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
assert.match(value, /计划级确认同 key 异参返回冲突/)
|
||||||
assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === Number\(confirmingData\.preloadPlan\.version\)/)
|
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
|
||||||
assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
|
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
|
||||||
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
|
assert.match(value, /loading-plans\/\$\{loadingPlanId\}\/transport\?warehouseId=/)
|
||||||
assert.match(value, /version: transportVersion/)
|
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
|
||||||
assert.match(value, /Number\.isInteger\(transportVersion\)/)
|
assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
|
||||||
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
|
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
|
||||||
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
|
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
|
||||||
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
||||||
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
|
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
|
||||||
assertOrder(value, [
|
assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/)
|
||||||
'CONFIRMED 订舱回显完整且冻结五项运输快照',
|
|
||||||
'if (!frozenTransportMatches) return',
|
|
||||||
'三期订舱携带已冻结运输快照并放舱',
|
|
||||||
])
|
|
||||||
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
|
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
|
||||||
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
|
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
|
||||||
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
|
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', () => {
|
test('finalize rechecks the same unified order detail and list as LOADED', () => {
|
||||||
const value = source('./phase3-loading-finalize.mjs')
|
const value = source('./phase3-loading-finalize.mjs')
|
||||||
assertOrder(value, [
|
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 candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
|
||||||
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
|
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
|
||||||
"const assignResponse = await request",
|
"const assignResponse = await request",
|
||||||
|
'无真实柜号推送预装计划成功',
|
||||||
|
'预装 EDITING 清单绑定本轮真实柜号',
|
||||||
"const bookingCreate = await request",
|
"const bookingCreate = await request",
|
||||||
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
|
"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.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
|
||||||
assert.match(value, /containerAllocations\.length === 0/)
|
assert.match(value, /containerAllocations\.length === 0/)
|
||||||
assert.match(value, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
|
assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
|
||||||
assert.doesNotMatch(value, /version: Number\(containerDetail\?\.version \?\? 0\), reason: 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, /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, '空模拟柜首次关联真实订舱', 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/)
|
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, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
|
||||||
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
|
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
|
||||||
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
|
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
|
||||||
assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport"))
|
assert.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId='))
|
||||||
assert.match(value, /在放舱前绑定本轮真实箱号/)
|
assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/)
|
||||||
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
|
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
|
||||||
assert.match(value, /无真实柜号拒绝推送预装计划/)
|
assert.match(value, /无真实柜号推送预装计划成功/)
|
||||||
assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/)
|
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, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
|
||||||
assert.match(value, /POST', CONTAINERS/)
|
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 任务',
|
||||||
'首次关联真实已确认订舱柜',
|
'首次关联真实已确认订舱柜',
|
||||||
'无真实柜号拒绝推送预装计划',
|
'订舱已关联但未 RELEASED 不生成 PDA 任务',
|
||||||
'无真实柜号推送失败后不生成 PDA 任务',
|
|
||||||
'在放舱前绑定本轮真实箱号',
|
|
||||||
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
|
|
||||||
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
|
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
|
||||||
'柜号与 RELEASED 订舱就绪后推送预装计划',
|
'订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务',
|
||||||
|
'计划级确认前读取即时版本与就绪门禁',
|
||||||
|
'计划级原子确认整份预装并触发唯一 PDA 任务',
|
||||||
|
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
|
||||||
'换柜号前读取 PDA 稳定库存快照',
|
'换柜号前读取 PDA 稳定库存快照',
|
||||||
'PDA 任务创建后替换真实柜号',
|
'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/)
|
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
|
||||||
|
|
||||||
const loading = source('./phase3-loading.mjs')
|
const loading = source('./phase3-loading.mjs')
|
||||||
assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/)
|
assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/)
|
||||||
assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/)
|
assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2)
|
||||||
assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/)
|
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
|
||||||
assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/)
|
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
|
||||||
assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
|
||||||
|
|
||||||
const scriptsDir = dirname(fileURLToPath(import.meta.url))
|
const scriptsDir = dirname(fileURLToPath(import.meta.url))
|
||||||
const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8')
|
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/)
|
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(
|
const containerLoading = readFileSync(
|
||||||
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8')
|
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8')
|
||||||
const bridgeLoading = readFileSync(
|
const bridgeLoading = readFileSync(
|
||||||
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
|
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 pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
|
||||||
const rejectedPushIndex = bridgeLoading.indexOf('无真实柜号拒绝推送柜次计划')
|
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
|
||||||
const transportIndex = bridgeLoading.indexOf('绑定真实柜号后开放三期 PDA 推送门禁')
|
const transportIndex = bridgeLoading.indexOf('预装 EDITING 清单绑定真实柜号')
|
||||||
|
const linkIndex = bridgeLoading.indexOf('预装补号后关联三期 CONFIRMED 订舱')
|
||||||
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
|
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
|
||||||
const successfulPushIndex = bridgeLoading.indexOf('真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务')
|
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
|
||||||
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
|
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
|
||||||
assert.ok(bridgeIndex >= 0 && rejectedPushIndex > bridgeIndex && transportIndex > rejectedPushIndex
|
assert.ok(pushIndex >= 0 && absentIndex > pushIndex && transportIndex > absentIndex
|
||||||
&& releaseIndex > transportIndex && successfulPushIndex > releaseIndex && pdaIndex > successfulPushIndex)
|
&& linkIndex > transportIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
|
||||||
assert.match(bridgeLoading, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
|
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
|
||||||
assert.match(bridgeLoading, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
|
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
|
||||||
assert.match(bridgeLoading, /version: transportVersion/)
|
|
||||||
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
|
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 containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
|
||||||
const containerTransportIndex = containerLoading.indexOf('在放舱前绑定本轮真实箱号')
|
const containerTransportIndex = containerLoading.indexOf('预装 EDITING 清单绑定本轮真实柜号')
|
||||||
|
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
|
||||||
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
|
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
|
||||||
const containerSuccessfulPushIndex = containerLoading.indexOf('柜号与 RELEASED 订舱就绪后推送预装计划')
|
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
|
||||||
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
|
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
|
||||||
assert.ok(containerRejectedPushIndex >= 0 && containerTransportIndex > containerRejectedPushIndex
|
assert.ok(containerPushIndex >= 0 && containerTransportIndex > containerPushIndex
|
||||||
&& containerReleaseIndex > containerTransportIndex && containerSuccessfulPushIndex > containerReleaseIndex
|
&& containerLinkIndex > containerTransportIndex && containerReleaseIndex > containerLinkIndex
|
||||||
&& containerPdaIndex > containerSuccessfulPushIndex)
|
&& 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/)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user