Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -16989,12 +16989,6 @@ CREATE TABLE IF NOT EXISTS fl_coload_command (
|
||||
CONSTRAINT chk_coload_command_status CHECK(command_status IN('PROCESSING','SUCCESS'))
|
||||
)ENGINE=InnoDB COMMENT='同柜不可拆组幂等写命令';
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
|
||||
|
||||
|
||||
-- 一件代发测量完成后的独立 PDA 合托;不代表国内仓库存入库。
|
||||
USE baiyue_firstleg;
|
||||
|
||||
@@ -17003,16 +16997,16 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
|
||||
task_status VARCHAR(24) NOT NULL COMMENT 'PENDING/CLAIMED/COMPLETED',
|
||||
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
|
||||
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
|
||||
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
|
||||
assignee_id BIGINT UNSIGNED NULL,
|
||||
assignee_name VARCHAR(64) NULL,
|
||||
claimed_at DATETIME(3) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
|
||||
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
|
||||
claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '任务完成时间',
|
||||
version INT NOT NULL DEFAULT 1 COMMENT '任务乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_task_inbound (tenant_id,inbound_id),
|
||||
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
|
||||
@@ -17023,22 +17017,22 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '合托托盘ID',
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
|
||||
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号',
|
||||
pallet_status VARCHAR(16) NOT NULL COMMENT 'OPEN/COMPLETED',
|
||||
open_operation_id VARCHAR(128) NOT NULL,
|
||||
seal_operation_id VARCHAR(128) NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_by_name VARCHAR(64) NULL,
|
||||
completed_by BIGINT UNSIGNED NULL,
|
||||
completed_by_name VARCHAR(64) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托',
|
||||
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
|
||||
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '托盘创建人姓名快照',
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '封托操作人姓名快照',
|
||||
completed_at DATETIME(3) NULL COMMENT '封托完成时间',
|
||||
version INT NOT NULL DEFAULT 1 COMMENT '托盘乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '托盘创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '托盘更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_pallet_no (pallet_no),
|
||||
UNIQUE KEY uk_dropship_pallet_open_op (open_operation_id),
|
||||
@@ -17049,20 +17043,20 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
|
||||
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
pallet_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL,
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL,
|
||||
sku_code VARCHAR(128) NOT NULL,
|
||||
cargo_name VARCHAR(255) NULL,
|
||||
box_code VARCHAR(64) NOT NULL,
|
||||
operation_id VARCHAR(128) NOT NULL,
|
||||
bound_by BIGINT UNSIGNED NOT NULL,
|
||||
bound_by_name VARCHAR(64) NULL,
|
||||
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
|
||||
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
|
||||
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID',
|
||||
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID',
|
||||
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
|
||||
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
|
||||
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
|
||||
@@ -17072,3 +17066,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
|
||||
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
|
||||
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.lifecycle(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
|
||||
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
|
||||
@PatchMapping("/loading-plans/{planId}/transport")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadDetail> updateTransport(
|
||||
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
|
||||
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
|
||||
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
|
||||
@PostMapping("/loading-plans/{planId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller;
|
||||
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime;
|
||||
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
|
||||
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid CandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(q));}
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
|
||||
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
|
||||
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
|
||||
|
||||
@@ -51,7 +51,7 @@ public final class LoadingListDtos {
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 500) String operationReason
|
||||
@Size(max = 500) String operationReason
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.baiyue.firstleg.dto;
|
||||
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
|
||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Long requirementId,String containerType,Integer requirementQuantity,Long containerBookingId,String cabinetNo,String containerNo,Long loadingPlanId,String loadingPlanNo,String loadingPlanStatus,Boolean bookingDocumentReady){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
@@ -250,7 +250,6 @@ public interface LoadingListQueryMapper {
|
||||
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
|
||||
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
|
||||
WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL
|
||||
AND #{containerNo} IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
|
||||
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
|
||||
WHERE task.loading_plan_id=p.id)
|
||||
@@ -266,6 +265,36 @@ public interface LoadingListQueryMapper {
|
||||
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id
|
||||
SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
|
||||
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
|
||||
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
|
||||
AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion}
|
||||
AND b.container_no IS NULL
|
||||
""")
|
||||
int touchForInitialContainerFill(@Param("planId") long planId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking b
|
||||
JOIN fl_loading_plan p ON p.container_booking_id=b.id
|
||||
SET b.container_no=#{containerNo}, b.voyage_no=#{voyageNo},
|
||||
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
|
||||
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
|
||||
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
|
||||
AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL
|
||||
AND #{containerNo} IS NOT NULL
|
||||
""")
|
||||
int fillInitialContainerNo(@Param("planId") long planId,
|
||||
@Param("containerNo") String containerNo,
|
||||
@Param("voyageNo") String voyageNo,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT CASE WHEN
|
||||
EXISTS (SELECT 1 FROM fl_loading_pick_task task
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
|
||||
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
|
||||
o.channel_name channelName,r.id requirementId,r.container_type containerType,
|
||||
r.quantity requirementQuantity,c.id containerBookingId,c.cabinet_no cabinetNo,
|
||||
c.container_no containerNo,p.id loadingPlanId,p.plan_no loadingPlanNo,
|
||||
p.plan_status loadingPlanStatus,TRUE bookingDocumentReady
|
||||
FROM fl_ocean_booking_container_allocation a
|
||||
JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id AND o.status='RELEASED'
|
||||
JOIN fl_ocean_booking_container_requirement r ON r.id=a.requirement_id AND r.ocean_booking_id=o.id
|
||||
JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.ocean_booking_id=o.id AND c.record_status='ACTIVE'
|
||||
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
|
||||
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
|
||||
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||
AND (p.id IS NULL OR p.plan_status<>'COMPLETED')
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.container_booking_id=c.id
|
||||
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
|
||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
OR c.cabinet_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
OR c.container_no LIKE CONCAT('%',#{q.keyword},'%'))
|
||||
ORDER BY o.updated_at DESC,o.id DESC,c.updated_at DESC,c.id DESC
|
||||
</script>
|
||||
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{containerBookingId} AND record_status='ACTIVE' AND container_no IS NULL") int bindContainerNoForPickup(@Param("containerBookingId") long containerBookingId,@Param("containerNo") String containerNo,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
|
||||
@@ -86,6 +86,14 @@ public class LoadingListRepository {
|
||||
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int fillInitialContainerNo(long planId, int expectedVersion, String containerNo, String voyageNo,
|
||||
long operatorId, String operatorName) {
|
||||
if (mapper.touchForInitialContainerFill(planId, expectedVersion, operatorId, operatorName) != 1) {
|
||||
return 0;
|
||||
}
|
||||
return mapper.fillInitialContainerNo(planId, containerNo, voyageNo, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public boolean hasPdaWorkStarted(long planId) {
|
||||
return mapper.hasPdaWorkStarted(planId) > 0;
|
||||
}
|
||||
@@ -151,13 +159,12 @@ public class LoadingListRepository {
|
||||
java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
|
||||
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
|
||||
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
|
||||
if (!containerReady) blockers.add("CONTAINER_NO_MISSING");
|
||||
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
|
||||
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
|
||||
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
|
||||
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
|
||||
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
|
||||
boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released;
|
||||
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released;
|
||||
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
|
||||
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
|
||||
String confirmationStatus = allConfirmed ? "CONFIRMED"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
|
||||
@Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations;
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult<Candidate> candidates(CandidateQuery q){return PageResult.from(mapper.candidates(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
|
||||
public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);}
|
||||
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
|
||||
public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
|
||||
|
||||
@@ -423,7 +423,6 @@ public class LoadingListService {
|
||||
AggregateRow before = requirePlan(planId);
|
||||
requireWarehouse(before, warehouseId);
|
||||
String reason = trim(request.operationReason());
|
||||
if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空");
|
||||
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
@@ -434,20 +433,31 @@ public class LoadingListService {
|
||||
return repository.preloadDetail(before);
|
||||
}
|
||||
|
||||
requireTransportCorrectionMutable(before);
|
||||
String containerNo = targetContainerNo(before, request.containerNo());
|
||||
String voyageNo = nullableTrim(request.voyageNo());
|
||||
boolean initialContainerFill = !StringUtils.hasText(before.getContainerNo()) && containerNo != null;
|
||||
requireTransportMutable(before, initialContainerFill);
|
||||
boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo())
|
||||
&& !java.util.Objects.equals(before.getContainerNo(), containerNo);
|
||||
if (correctingContainerNo && !StringUtils.hasText(reason)) {
|
||||
throw badRequest("纠正已有真实集装箱号时纠错原因不能为空");
|
||||
}
|
||||
String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason);
|
||||
if (java.util.Objects.equals(before.getContainerNo(), containerNo)
|
||||
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
|
||||
return repository.preloadDetail(before);
|
||||
}
|
||||
if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
|
||||
if (containerNo != null
|
||||
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
|
||||
throw conflict("集装箱号已存在");
|
||||
}
|
||||
try {
|
||||
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
int updated = initialContainerFill
|
||||
? repository.fillInitialContainerNo(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName))
|
||||
: repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName));
|
||||
if (updated != 1) {
|
||||
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
@@ -464,8 +474,9 @@ public class LoadingListService {
|
||||
}
|
||||
|
||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||
return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo())
|
||||
: Iso6346ContainerNumber.require(requested);
|
||||
if (requested != null) return Iso6346ContainerNumber.require(requested);
|
||||
return StringUtils.hasText(before.getContainerNo())
|
||||
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
|
||||
}
|
||||
|
||||
private static String transportHash(long planId, int version, String containerNo,
|
||||
@@ -577,15 +588,15 @@ public class LoadingListService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireTransportCorrectionMutable(AggregateRow row) {
|
||||
if (!"EDITING".equals(row.getPlanStatus())) {
|
||||
private void requireTransportMutable(AggregateRow row, boolean initialContainerFill) {
|
||||
if (initialContainerFill && !Set.of("EDITING", "LOADING").contains(row.getPlanStatus())) {
|
||||
throw conflict("仅装柜完成前允许首次补录真实集装箱号");
|
||||
}
|
||||
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
|
||||
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
|
||||
}
|
||||
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
|
||||
if (!StringUtils.hasText(row.getContainerNo())) {
|
||||
throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入");
|
||||
}
|
||||
if (repository.hasPdaWorkStarted(row.getPlanId())) {
|
||||
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
|
||||
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
@@ -33,6 +34,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
|
||||
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
|
||||
@@ -113,11 +115,11 @@ public class LoadingPickService {
|
||||
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
/** Opens the field task only after planning, transport identity and ocean release have converged. */
|
||||
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
|
||||
@Transactional
|
||||
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
|
||||
var booking = smartLoadingRepository.booking(containerBookingId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
|
||||
if (booking == null) return;
|
||||
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
||||
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
||||
var links = loadingModelBridge.byLoadingPlan(plan.getId());
|
||||
@@ -137,10 +139,6 @@ public class LoadingPickService {
|
||||
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
|
||||
throw conflict("预装清单已确认或当前状态不允许确认");
|
||||
}
|
||||
var booking = smartLoadingRepository.booking(link.containerBookingId());
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("确认预装前必须录入真实集装箱号");
|
||||
}
|
||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
@@ -462,6 +460,7 @@ public class LoadingPickService {
|
||||
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
|
||||
if ("FAILED".equals(replay.getCommandStatus())) {
|
||||
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
|
||||
requireCompletionContainerNumber(taskId);
|
||||
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|
||||
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
||||
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
||||
@@ -485,6 +484,7 @@ public class LoadingPickService {
|
||||
if (!request.expectedTaskVersion().equals(task.getVersion())) {
|
||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||
}
|
||||
requireCompletionContainerNumber(taskId);
|
||||
if (!repository.allTaskItemsResolved(taskId)) {
|
||||
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
|
||||
}
|
||||
@@ -514,6 +514,18 @@ public class LoadingPickService {
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||
}
|
||||
try {
|
||||
Iso6346ContainerNumber.require(booking.getContainerNo());
|
||||
} catch (BizException exception) {
|
||||
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only by the completion worker, after its durable lease has committed. */
|
||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||
long taskId = command.getPickTaskId();
|
||||
|
||||
@@ -47,15 +47,15 @@ public class TruckAppointmentService {
|
||||
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken;
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public PageResult<Candidate> candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
|
||||
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){required(id);return repo.attachments(id);}
|
||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
||||
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
||||
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");if(!"PICKUP".equals(action)&&StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;if("PICKUP".equals(action))bindContainerNoForPickup(e,r.containerNo(),uid,name);int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){if(StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List<TruckAppointmentEntity> active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);}
|
||||
private AssociationContext validateAssociations(SaveRequest r){
|
||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||
@@ -70,9 +70,8 @@ public class TruckAppointmentService {
|
||||
long containerId=container.getId();
|
||||
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));
|
||||
if(!allocated)throw bad("containerBookingId未分配至该订舱");
|
||||
Iso6346ContainerNumber.require(container.getContainerNo());
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}
|
||||
LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan!=null&&"COMPLETED".equals(plan.getPlanStatus()))throw conflict("关联柜次已装柜完成,不能新建或修改拖车预约");if(r.loadingPlanId()!=null&&(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId())))throw bad("loadingPlanId与柜次当前装柜计划不一致");
|
||||
return new AssociationContext(booking,container,warehouseId,billOfLadingNo);
|
||||
}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
|
||||
@@ -95,12 +94,12 @@ public class TruckAppointmentService {
|
||||
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
||||
ContainerBookingEntity container=containers.find(appointment.getContainerBookingId());
|
||||
if(container==null||!"ACTIVE".equals(container.getRecordStatus())||!Objects.equals(container.getOceanBookingId(),booking.getId()))throw conflict("关联柜次已失效或不属于当前订舱");
|
||||
Iso6346ContainerNumber.require(container.getContainerNo());
|
||||
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),container.getId()));
|
||||
if(!allocated)throw conflict("关联柜次未分配至当前订舱");
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return bill;
|
||||
}
|
||||
private void bindContainerNoForPickup(TruckAppointmentEntity appointment,String requested,long operatorId,String operatorName){if(appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少柜次关联");ContainerBookingEntity container=containers.findActiveForUpdate(appointment.getContainerBookingId());if(container==null||!Objects.equals(container.getOceanBookingId(),appointment.getOceanBookingId()))throw conflict("关联柜次已失效或不属于当前订舱");String existing=trim(container.getContainerNo());String supplied=trim(requested);if(StringUtils.hasText(existing)){String normalizedExisting=Iso6346ContainerNumber.require(existing);if(StringUtils.hasText(supplied)&&!normalizedExisting.equals(Iso6346ContainerNumber.require(supplied)))throw conflict("PICKUP提交的真实集装箱号与柜次已有号码不一致,请通过预装纠错修改");return;}if(!StringUtils.hasText(supplied))return;String normalized=Iso6346ContainerNumber.require(supplied);if(containers.containerNoExists(normalized,container.getId()))throw conflict("真实集装箱号已被其他柜次使用");try{if(repo.bindContainerNoForPickup(container.getId(),normalized,operatorId,operatorName)!=1)throw conflict("柜次真实集装箱号已被并发修改,请刷新后重试");}catch(DuplicateKeyException exception){throw conflict("真实集装箱号已被其他柜次使用");}}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
|
||||
|
||||
@@ -856,16 +856,16 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
|
||||
task_status VARCHAR(24) NOT NULL COMMENT 'PENDING/CLAIMED/COMPLETED',
|
||||
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
|
||||
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
|
||||
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
|
||||
assignee_id BIGINT UNSIGNED NULL,
|
||||
assignee_name VARCHAR(64) NULL,
|
||||
claimed_at DATETIME(3) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
|
||||
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
|
||||
claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '任务完成时间',
|
||||
version INT NOT NULL DEFAULT 1 COMMENT '任务乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_task_inbound (tenant_id,inbound_id),
|
||||
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
|
||||
@@ -876,22 +876,22 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '合托托盘ID',
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
|
||||
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号',
|
||||
pallet_status VARCHAR(16) NOT NULL COMMENT 'OPEN/COMPLETED',
|
||||
open_operation_id VARCHAR(128) NOT NULL,
|
||||
seal_operation_id VARCHAR(128) NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_by_name VARCHAR(64) NULL,
|
||||
completed_by BIGINT UNSIGNED NULL,
|
||||
completed_by_name VARCHAR(64) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托',
|
||||
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
|
||||
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '托盘创建人姓名快照',
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '封托操作人姓名快照',
|
||||
completed_at DATETIME(3) NULL COMMENT '封托完成时间',
|
||||
version INT NOT NULL DEFAULT 1 COMMENT '托盘乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '托盘创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '托盘更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_pallet_no (pallet_no),
|
||||
UNIQUE KEY uk_dropship_pallet_open_op (open_operation_id),
|
||||
@@ -902,20 +902,20 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
|
||||
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
task_id BIGINT UNSIGNED NOT NULL,
|
||||
pallet_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL,
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL,
|
||||
sku_code VARCHAR(128) NOT NULL,
|
||||
cargo_name VARCHAR(255) NULL,
|
||||
box_code VARCHAR(64) NOT NULL,
|
||||
operation_id VARCHAR(128) NOT NULL,
|
||||
bound_by BIGINT UNSIGNED NOT NULL,
|
||||
bound_by_name VARCHAR(64) NULL,
|
||||
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
|
||||
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
|
||||
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID',
|
||||
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID',
|
||||
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
|
||||
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
|
||||
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
|
||||
|
||||
@@ -49,8 +49,8 @@ paths:
|
||||
patch:
|
||||
summary: 修改预装运输信息
|
||||
description: >-
|
||||
唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许;
|
||||
containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。
|
||||
已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
@@ -66,7 +66,7 @@ paths:
|
||||
/api/platform/v1/first-leg/loading-plans/{planId}/confirm:
|
||||
post:
|
||||
summary: 原子确认整份预装计划
|
||||
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
|
||||
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED;真实集装箱号可在PDA确认装柜完成前补录。任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
@@ -313,11 +313,11 @@ components:
|
||||
pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态}
|
||||
confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态}
|
||||
canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口}
|
||||
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;task 已创建后仍为 true}
|
||||
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;不依赖真实集装箱号,task 已创建后仍为 true}
|
||||
blockingReasons:
|
||||
type: array
|
||||
description: 阻止计划确认的稳定原因码
|
||||
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
|
||||
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
|
||||
ConfirmPreloadRequest:
|
||||
type: object
|
||||
required: [expectedVersion, operationReason]
|
||||
@@ -428,4 +428,4 @@ components:
|
||||
containerNo: {type: string, maxLength: 64, nullable: true, description: 新真实集装箱号;null表示保持当前值,空白字符串非法,禁止清空}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
|
||||
operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因}
|
||||
operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略}
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Truck Appointment API
|
||||
version: 1.3.0
|
||||
description: Every truck appointment must select one RELEASED ocean booking and one ACTIVE real-numbered container already allocated to that booking. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
|
||||
description: Every truck appointment must select one RELEASED ocean booking and its allocated ACTIVE internal container identity. A real ISO 6346 container number is not required for create, edit, submit or dispatch. PICKUP may omit it and still advances normally; when supplied, PICKUP atomically records it if still missing or reuses the existing valid number. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
|
||||
paths:
|
||||
/api/platform/v1/truck-appointments:
|
||||
get:
|
||||
@@ -20,7 +20,7 @@ paths:
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
|
||||
post:
|
||||
summary: Create a truck appointment for a released booking and its allocated real container
|
||||
summary: Create a truck appointment for a released booking and its allocated internal container
|
||||
description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates.
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -30,6 +30,16 @@ paths:
|
||||
responses:
|
||||
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
|
||||
'409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
|
||||
/api/platform/v1/truck-appointments/candidates:
|
||||
get:
|
||||
summary: Query authoritative create-time ocean-booking and internal-container candidates
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns only RELEASED bookings with an active booking-owned confirmation or bill file and allocated ACTIVE internal containers that have no non-terminal truck appointment and whose loading plan is absent or not COMPLETED. A missing containerNo remains eligible. Completed/LOADED containers, occupied containers and bookings without any remaining eligible allocated container are absent. Edit pages must display their current binding from appointment detail rather than injecting it into this create-only candidate result.
|
||||
parameters:
|
||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number, bill of lading, cabinet number or real container number}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
'200': {description: Paged flat booking-container candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}}
|
||||
/api/platform/v1/truck-appointments/{id}:
|
||||
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
|
||||
get:
|
||||
@@ -53,7 +63,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/RequestHash'}
|
||||
post:
|
||||
summary: Apply an idempotent appointment lifecycle action
|
||||
description: ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
|
||||
description: PICKUP accepts optional containerNo. Omitting it never blocks the transition to PICKUP_IN_PROGRESS; when supplied, the server atomically records it on the linked internal container if missing. An existing valid ISO 6346 number may be omitted or resubmitted unchanged; a different value is rejected and must use the preload correction flow. Other actions reject containerNo. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -138,6 +148,45 @@ components:
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
|
||||
schemas:
|
||||
CandidatePageEnvelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/Candidate'}}
|
||||
page: {type: integer, format: int64}
|
||||
size: {type: integer, format: int64}
|
||||
total: {type: integer, format: int64}
|
||||
pages: {type: integer, format: int64}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
Candidate:
|
||||
type: object
|
||||
required: [oceanBookingId, oceanBookingNo, warehouseId, requirementId, containerType, requirementQuantity, containerBookingId, cabinetNo, bookingDocumentReady]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64}
|
||||
oceanBookingNo: {type: string}
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
warehouseId: {type: integer, format: int64}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
requirementId: {type: integer, format: int64}
|
||||
containerType: {type: string}
|
||||
requirementQuantity: {type: integer, minimum: 1}
|
||||
containerBookingId: {type: integer, format: int64}
|
||||
cabinetNo: {type: string}
|
||||
containerNo: {type: string, nullable: true, description: Optional until PICKUP or preload correction; absence does not make the candidate unavailable}
|
||||
loadingPlanId: {type: integer, format: int64, nullable: true}
|
||||
loadingPlanNo: {type: string, nullable: true}
|
||||
loadingPlanStatus: {type: string, nullable: true}
|
||||
bookingDocumentReady: {type: boolean, enum: [true]}
|
||||
DetailEnvelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
@@ -176,13 +225,14 @@ components:
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, maxLength: 500}
|
||||
containerNo: {type: string, maxLength: 32, nullable: true, description: Optional and only valid for PICKUP. Omission does not block PICKUP; when supplied it is normalized and validated as unique ISO 6346 before being written atomically. A different value from an existing number is rejected and must use preload correction.}
|
||||
SaveRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]
|
||||
properties:
|
||||
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE container already allocated to oceanBookingId and carrying a valid unique ISO 6346 container number.}
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE internal container already allocated to oceanBookingId. Its real ISO 6346 number may remain empty until PICKUP.}
|
||||
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.}
|
||||
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
|
||||
|
||||
+3
-3
@@ -69,7 +69,7 @@ class LoadingListControllerContractTest {
|
||||
.contains("beforeSnapshot")
|
||||
.contains("containerNo")
|
||||
.contains("voyageNo")
|
||||
.contains("唯一纠错入口")
|
||||
.contains("首次补录真实集装箱号")
|
||||
.contains("校验ISO 6346结构、设备类别和校验位")
|
||||
.contains("PDA任务尚未领取且无任何物理作业事实")
|
||||
.contains("/loading-plans/{planId}/confirm")
|
||||
@@ -100,10 +100,10 @@ class LoadingListControllerContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() {
|
||||
void transportCorrectionReasonIsRuntimeConditionalAndNullContainerKeepsCurrentValue() {
|
||||
assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor()
|
||||
.getAnnotation(NotBlank.class))
|
||||
.isNotNull();
|
||||
.isNull();
|
||||
assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor()
|
||||
.getAnnotation(NotBlank.class))
|
||||
.isNull();
|
||||
|
||||
+4
-3
@@ -18,7 +18,7 @@ import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TruckAppointmentControllerContractTest {
|
||||
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
||||
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
||||
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
|
||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -31,12 +31,13 @@ class TruckAppointmentControllerContractTest {
|
||||
}
|
||||
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
|
||||
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
|
||||
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
|
||||
@Test void openApiPublishesCreateOnlyAuthoritativeCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged flat booking-container candidates","bookingDocumentReady","A missing containerNo remains eligible","Completed/LOADED containers, occupied containers","Edit pages must display their current binding from appointment detail");}
|
||||
@Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
||||
Map<String,String> expected=Map.ofEntries(
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
Map.entry("history","platform:firstleg:truck-appointment:list"), Map.entry("attachments","platform:firstleg:truck-appointment:list"),
|
||||
Map.entry("attach","platform:firstleg:truck-appointment:edit"), Map.entry("upload","platform:firstleg:truck-appointment:edit"),
|
||||
Map.entry("deleteAttachment","platform:firstleg:truck-appointment:edit"), Map.entry("create","platform:firstleg:truck-appointment:create"),
|
||||
|
||||
+13
-1
@@ -80,6 +80,8 @@ class LoadingListQueryMapperContractTest {
|
||||
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
|
||||
String touch = sql("touchEditable", Update.class);
|
||||
String transport = sql("updateTransport", Update.class);
|
||||
String touchInitialFill = sql("touchForInitialContainerFill", Update.class);
|
||||
String initialFill = sql("fillInitialContainerNo", Update.class);
|
||||
String confirmPreload = sql("confirmLinkedPreload", Update.class);
|
||||
String confirmPlan = sql("touchForPreloadConfirmation", Update.class);
|
||||
String advance = sql("advance", Update.class);
|
||||
@@ -94,10 +96,20 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
|
||||
.contains("p.plan_status='EDITING'")
|
||||
.contains("preload_pushed_at IS NOT NULL")
|
||||
.contains("#{containerNo} IS NOT NULL")
|
||||
.doesNotContain("#{containerNo} IS NOT NULL")
|
||||
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
|
||||
.doesNotContain("DELETE")
|
||||
.doesNotContain("fl_loading_plan_item");
|
||||
assertThat(touchInitialFill)
|
||||
.contains("p.plan_status IN ('EDITING','LOADING')")
|
||||
.contains("p.version=#{expectedVersion}")
|
||||
.contains("b.container_no IS NULL")
|
||||
.doesNotContain("fl_loading_pick_assignment", "item_status");
|
||||
assertThat(initialFill)
|
||||
.contains("p.plan_status IN ('EDITING','LOADING')")
|
||||
.contains("b.container_no IS NULL")
|
||||
.contains("#{containerNo} IS NOT NULL")
|
||||
.doesNotContain("fl_loading_pick_assignment", "item_status");
|
||||
assertThat(advance)
|
||||
.contains("plan_status = #{fromStatus}")
|
||||
.contains("plan_status = #{toStatus}")
|
||||
|
||||
@@ -72,4 +72,30 @@ class LoadingListRepositoryTest {
|
||||
assertThat(confirmed.planId()).isEqualTo(41L);
|
||||
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingContainerNumberDoesNotBlockPreloadConfirmationOrPdaReadiness() {
|
||||
AggregateRow row = new AggregateRow();
|
||||
row.setPlanId(41L);
|
||||
row.setContainerBookingId(61L);
|
||||
row.setPlanNo("LP-41");
|
||||
row.setPlanStatus("EDITING");
|
||||
row.setVersion(1);
|
||||
row.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
row.setPreloadLinkCount(2L);
|
||||
row.setConfirmedPreloadCount(0L);
|
||||
row.setOceanBookingId(81L);
|
||||
row.setOceanBookingStatus("RELEASED");
|
||||
when(mapper.selectItems(41L)).thenReturn(List.of());
|
||||
when(mapper.selectHistories(41L)).thenReturn(List.of());
|
||||
|
||||
var pending = repository.preloadDetail(row).summary().readiness();
|
||||
assertThat(pending.containerNoReady()).isFalse();
|
||||
assertThat(pending.canConfirm()).isTrue();
|
||||
assertThat(pending.blockingReasons()).doesNotContain("CONTAINER_NO_MISSING");
|
||||
|
||||
row.setConfirmedPreloadCount(2L);
|
||||
var confirmed = repository.preloadDetail(row).summary().readiness();
|
||||
assertThat(confirmed.readyForPda()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -16,10 +16,20 @@ class TruckAppointmentMapperContractTest {
|
||||
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}",
|
||||
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
|
||||
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
|
||||
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(candidates).contains("o.status='RELEASED'", "fl_ocean_booking_container_allocation",
|
||||
"fl_ocean_booking_container_requirement", "c.record_status='ACTIVE'", "d.valid=1",
|
||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "p.plan_status<>'COMPLETED'",
|
||||
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
|
||||
"bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
|
||||
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
|
||||
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
|
||||
String pickupContainer = TruckAppointmentMapper.class.getMethod("bindContainerNoForPickup", long.class, String.class, long.class, String.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(pickupContainer).contains("UPDATE fl_container_booking", "container_no=#{containerNo}",
|
||||
"id=#{containerBookingId}", "record_status='ACTIVE'", "container_no IS NULL", "version=version+1");
|
||||
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
|
||||
@@ -208,20 +208,67 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() {
|
||||
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
|
||||
AggregateRow loading = row("LOADING", 3);
|
||||
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
loading.setContainerNo("MSCU6639870");
|
||||
AggregateRow missing = row("EDITING", 3);
|
||||
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
when(repository.aggregate(10L)).thenReturn(loading, missing);
|
||||
AggregateRow afterFill = row("EDITING", 4);
|
||||
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
|
||||
afterFill.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
|
||||
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
|
||||
.thenReturn(1);
|
||||
when(repository.preloadDetail(afterFill)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始");
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "首次补录")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("首次写入");
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, null));
|
||||
|
||||
verify(repository).fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
|
||||
AggregateRow before = row("LOADING", 7);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
AggregateRow after = row("LOADING", 8);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
|
||||
when(repository.fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator"))
|
||||
.thenReturn(1);
|
||||
when(repository.preloadDetail(after)).thenReturn(
|
||||
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
|
||||
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "late-fill-key",
|
||||
new TransportUpdateRequest("CSQU3054383", "023E", 7, null));
|
||||
|
||||
verify(repository).fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator");
|
||||
verify(repository, never()).hasPdaWorkStarted(10L);
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
|
||||
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
|
||||
org.mockito.ArgumentMatchers.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void correctingExistingContainerNumberStillRequiresReason() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reason-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("纠错原因");
|
||||
|
||||
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -113,7 +113,7 @@ class LoadingPickServiceTest {
|
||||
}).when(completionTransaction).executeWithoutResult(any());
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setContainerNo("CSLU1234567");
|
||||
booking.setContainerNo("MSCU6639870");
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setSourceType("SIMULATION");
|
||||
booking.setRecordStatus("ACTIVE");
|
||||
@@ -124,9 +124,9 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() {
|
||||
void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() {
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
booking.setId(40L);
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
@@ -136,7 +136,7 @@ class LoadingPickServiceTest {
|
||||
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
|
||||
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
|
||||
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
|
||||
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
|
||||
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, null, "EDITING", 1, 1, null)));
|
||||
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
|
||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||
when(repository.taskByPlan(10L)).thenReturn(null);
|
||||
@@ -191,11 +191,11 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void planConfirmationGateRequiresRealContainerAndReleasedBooking() {
|
||||
void planConfirmationGateRequiresReleasedBookingButNotRealContainerNumber() {
|
||||
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");
|
||||
booking.setId(40L);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
@@ -209,17 +209,21 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void planConfirmationGateRejectsMissingRealContainerNumber() {
|
||||
void planConfirmationGateAllowsMissingRealContainerNumber() {
|
||||
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);
|
||||
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);
|
||||
|
||||
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号");
|
||||
verify(oceanBookingRepository, never()).allocationByContainer(anyLong());
|
||||
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -775,6 +779,25 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRejectsMissingRealContainerNumberBeforeCreatingCommand() {
|
||||
LoadingPickTaskEntity loading = task();
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setRecordStatus("ACTIVE");
|
||||
when(repository.taskForUpdate(20L)).thenReturn(loading);
|
||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "complete-missing-container", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("确认装柜完成前");
|
||||
|
||||
verify(repository, never()).allTaskItemsResolved(20L);
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
|
||||
+215
-18
@@ -1,9 +1,12 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
|
||||
@@ -25,6 +28,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.mockito.Spy;
|
||||
@@ -90,6 +94,18 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
|
||||
}
|
||||
|
||||
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西",
|
||||
70L, "45HC", 1, 88L, "CAB-1", null, 77L, "LP-1", "EDITING", true);
|
||||
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
|
||||
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
|
||||
|
||||
assertThat(result.records()).containsExactly(candidate);
|
||||
verify(repository, times(1)).candidates(any(CandidateQuery.class));
|
||||
verifyNoInteractions(oceanBookings, containers, loadingPlans);
|
||||
}
|
||||
|
||||
@Test void createRequiresZeroVersionAndInitialisesDraft() {
|
||||
SaveRequest invalid = request(1);
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
|
||||
@@ -272,15 +288,21 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() {
|
||||
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
|
||||
SaveRequest request = validRequest();
|
||||
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
|
||||
when(containers.find(88L)).thenReturn(missingNo);
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号");
|
||||
verify(repository, never()).insert(any());
|
||||
|
||||
when(containers.find(88L)).thenReturn(realContainer());
|
||||
service.create(7L, "admin", request);
|
||||
|
||||
verify(repository).insert(any(TruckAppointmentEntity.class));
|
||||
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
|
||||
}
|
||||
|
||||
@Test void createStillRequiresBookingDocumentBeforeRealNumberIsAssigned() {
|
||||
SaveRequest request = validRequest();
|
||||
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
|
||||
when(containers.find(88L)).thenReturn(missingNo);
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
@@ -377,6 +399,19 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsCompletedLoadingContainerEvenWhenAppointmentIsTerminal() {
|
||||
SaveRequest request = validRequest();
|
||||
LoadingPlanEntity completed = new LoadingPlanEntity(); completed.setId(77L);
|
||||
completed.setContainerBookingId(88L); completed.setPlanStatus("COMPLETED");
|
||||
when(loadingPlans.planByBooking(88L)).thenReturn(completed);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已装柜完成");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
@@ -385,7 +420,7 @@ class TruckAppointmentServiceTest {
|
||||
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1);
|
||||
|
||||
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
|
||||
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||
assertThat(result.getVersion()).isEqualTo(1);
|
||||
@@ -399,12 +434,12 @@ class TruckAppointmentServiceTest {
|
||||
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
|
||||
operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1");
|
||||
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
|
||||
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交", null));
|
||||
operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false);
|
||||
when(repository.operation("idem-1")).thenReturn(operation);
|
||||
|
||||
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
|
||||
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", hash, 7L, "admin");
|
||||
|
||||
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
@@ -412,7 +447,7 @@ class TruckAppointmentServiceTest {
|
||||
}
|
||||
|
||||
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
|
||||
var request = new ActionRequest(0, "并发提交");
|
||||
var request = new ActionRequest(0, "并发提交", null);
|
||||
String hash = requestHash("SUBMIT", 1L, request);
|
||||
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
|
||||
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
|
||||
@@ -437,7 +472,7 @@ class TruckAppointmentServiceTest {
|
||||
.thenReturn(false);
|
||||
when(repository.operation("reused-key")).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
|
||||
"reused-key", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
|
||||
|
||||
@@ -453,7 +488,7 @@ class TruckAppointmentServiceTest {
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
|
||||
when(repository.find(1L)).thenReturn(missingBill);
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
|
||||
"dispatch-no-bill", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
|
||||
|
||||
@@ -465,26 +500,28 @@ class TruckAppointmentServiceTest {
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
|
||||
"dispatch-no-file", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
|
||||
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
|
||||
ContainerBookingEntity internalContainer = realContainer(); internalContainer.setContainerNo(null);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
when(containers.find(88L)).thenReturn(internalContainer);
|
||||
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
|
||||
|
||||
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
|
||||
"dispatch-ok", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
|
||||
@@ -494,8 +531,168 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
|
||||
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
|
||||
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(container);
|
||||
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1);
|
||||
|
||||
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜", null),
|
||||
"pickup-without-number", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
|
||||
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void pickupRejectsSuppliedInvalidContainerNumberWithoutDrift() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
|
||||
when(repository.find(1L)).thenReturn(before);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-invalid"), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(container);
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "PICKUP", new ActionRequest(2, "提柜", "MSCU6639871"),
|
||||
"pickup-invalid", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
|
||||
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void pickupNormalizesAndAtomicallyBindsContainerNumberBeforeTransition() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
|
||||
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
|
||||
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(container);
|
||||
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
|
||||
when(repository.bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin")).thenReturn(1);
|
||||
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(1);
|
||||
|
||||
TruckAppointmentEntity result = service.action(1L, "PICKUP",
|
||||
new ActionRequest(2, "司机已提柜", " ms cu 663987 0 "), "pickup-bind", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
|
||||
InOrder writes = inOrder(repository);
|
||||
writes.verify(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
|
||||
writes.verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
|
||||
writes.verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void pickupReusesExistingValidContainerNumberWithoutRewritingIt() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
|
||||
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
|
||||
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(1);
|
||||
|
||||
service.action(1L, "PICKUP", new ActionRequest(2, "复用已有柜号", null),
|
||||
"pickup-reuse", null, 7L, "admin");
|
||||
|
||||
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
|
||||
}
|
||||
|
||||
@Test void pickupIdempotentReplayDoesNotLockOrRewriteContainerAndChangedNumberConflicts() throws Exception {
|
||||
ActionRequest request = new ActionRequest(2, "提柜", "MSCU6639870");
|
||||
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
|
||||
completed.setOceanBookingId(99L); completed.setContainerBookingId(88L);
|
||||
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
|
||||
operation.setAppointmentId(1L); operation.setAction("PICKUP"); operation.setIdempotencyKey("pickup-replay");
|
||||
operation.setRequestHash(requestHash("PICKUP", 1L, request));
|
||||
operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-replay"), anyString()))
|
||||
.thenReturn(false);
|
||||
when(repository.operation("pickup-replay")).thenReturn(operation);
|
||||
|
||||
TruckAppointmentEntity replay = service.action(1L, "PICKUP", request,
|
||||
"pickup-replay", operation.getRequestHash(), 7L, "admin");
|
||||
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
|
||||
assertThatThrownBy(() -> service.action(1L, "PICKUP",
|
||||
new ActionRequest(2, "提柜", "TSTU1503306"), "pickup-replay", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
|
||||
|
||||
verify(containers, never()).findActiveForUpdate(anyLong());
|
||||
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test void pickupRejectsMismatchedOrDuplicateContainerNumberBeforeStatusDrift() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
when(repository.find(1L)).thenReturn(before);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), anyString(), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "PICKUP",
|
||||
new ActionRequest(2, "错误柜号", "TSTU1503306"), "pickup-mismatch", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不一致").hasMessageContaining("预装纠错");
|
||||
|
||||
ContainerBookingEntity unnumbered = realContainer(); unnumbered.setContainerNo(null);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(unnumbered);
|
||||
when(containers.containerNoExists("TSTU1503306", 88L)).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.action(1L, "PICKUP",
|
||||
new ActionRequest(2, "重复柜号", "TSTU1503306"), "pickup-duplicate", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void concurrentPickupContainerNumberCollisionRollsBackBeforeStatusTransition() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
|
||||
when(repository.find(1L)).thenReturn(before);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-concurrent"), anyString())).thenReturn(true);
|
||||
when(containers.findActiveForUpdate(88L)).thenReturn(container);
|
||||
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
|
||||
doThrow(new DuplicateKeyException("uk_container_booking_container_no"))
|
||||
.when(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "PICKUP",
|
||||
new ActionRequest(2, "并发提柜", "MSCU6639870"), "pickup-concurrent", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test void nonPickupActionsRejectContainerNumberWithoutWritingIt() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
when(repository.find(1L)).thenReturn(before);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("submit-with-container"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT",
|
||||
new ActionRequest(0, "错误携带柜号", "MSCU6639870"), "submit-with-container", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅PICKUP动作");
|
||||
|
||||
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
|
||||
var request = new ActionRequest(0, "提交");
|
||||
var request = new ActionRequest(0, "提交", null);
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key");
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin"))
|
||||
@@ -507,11 +704,11 @@ class TruckAppointmentServiceTest {
|
||||
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
when(repository.find(1L)).thenReturn(e);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin"))
|
||||
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null, null), "idem-action", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("专用接口");
|
||||
e.setStatus(TruckAppointmentStatus.COMPLETED);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
|
||||
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约", null), "idem-reschedule", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
|
||||
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
|
||||
}
|
||||
@@ -524,7 +721,7 @@ class TruckAppointmentServiceTest {
|
||||
when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1);
|
||||
var at = LocalDateTime.of(2026, 8, 12, 10, 0);
|
||||
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
|
||||
var result = service.reschedule(1L, at, new ActionRequest(2, "改约", null), "idem-reschedule", null, 7L, "admin");
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
|
||||
assertThat(result.getVersion()).isEqualTo(3);
|
||||
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
|
||||
|
||||
@@ -120,6 +120,14 @@ node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --ove
|
||||
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
|
||||
```
|
||||
|
||||
只验证真实集装箱号晚补门禁时,使用正式可选参数 `LATE_CONTAINER_GATE_ONLY=1` 并仍从总入口执行 `phase3-loading`。该模式使用本轮 marker 独立造数,固定验证无号 push、订舱关联、整份预装确认、PDA claim/batch、无号 complete `A0409`、LOADING 阶段首次补号和补号后 complete 受理;随后允许后台命令停在 `CABINET_EVIDENCE/A0409`,以证明柜号门禁已经消失且下一门禁来自未上传的五类柜级证据。该模式不继续完整报关或 LOADED 链,也不得与完整链同时解释为同一终态验收。
|
||||
|
||||
```powershell
|
||||
$env:LATE_CONTAINER_GATE_ONLY='1'
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase3-loading
|
||||
Remove-Item Env:LATE_CONTAINER_GATE_ONLY
|
||||
```
|
||||
|
||||
该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。
|
||||
|
||||
## 消息中心模块
|
||||
|
||||
@@ -8,6 +8,7 @@ const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: t
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
|
||||
const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
export function markerSuffix(marker) {
|
||||
@@ -496,19 +497,24 @@ export async function run(context) {
|
||||
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), containerNo,
|
||||
oceanBookingId: bookingId, requirementId: id(requirement.id),
|
||||
...(LATE_CONTAINER_GATE_ONLY ? {} : { containerNo }),
|
||||
expectedVersion: Number(latestContainer?.version),
|
||||
})
|
||||
const linked = context.apiSuccess(MODULE, '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
|
||||
const linked = context.apiSuccess(MODULE, LATE_CONTAINER_GATE_ONLY
|
||||
? '无号模拟柜原地关联三期 RELEASED 订舱需求'
|
||||
: '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '订舱关联保持柜次计划与运输身份', linkResponse,
|
||||
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
|
||||
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
|
||||
&& (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo)
|
||||
&& id(linked?.oceanBookingId) === bookingId,
|
||||
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
|
||||
if (id(linked?.id) !== containerBookingId) return
|
||||
|
||||
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse,
|
||||
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
|
||||
&& id(linked?.oceanBookingId) === bookingId && linked?.containerNo === containerNo,
|
||||
&& id(linked?.oceanBookingId) === bookingId
|
||||
&& (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo),
|
||||
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
|
||||
|
||||
const unconfirmedTaskResponse = await context.request('GET',
|
||||
@@ -610,7 +616,8 @@ export async function run(context) {
|
||||
&& id(inventory?.loadingPlanId) === loadingPlanId
|
||||
&& id(inventory?.containerBookingId) === containerBookingId
|
||||
&& Number(inventory?.taskVersion) === Number(claimed?.version)
|
||||
&& id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo
|
||||
&& id(inventory?.cabinetNo)
|
||||
&& (LATE_CONTAINER_GATE_ONLY ? inventory?.currentContainerNo == null : inventory?.currentContainerNo === containerNo)
|
||||
&& id(inventory?.warehouseId) === warehouseId
|
||||
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
|
||||
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
|
||||
@@ -869,6 +876,73 @@ export async function run(context) {
|
||||
&& id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING',
|
||||
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
if (LATE_CONTAINER_GATE_ONLY) {
|
||||
const missingOperationId = `${context.marker}-late-container-missing-complete`.slice(0, 128)
|
||||
const missingComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||
expectedTaskVersion: Number(pickedCanonical?.version), operationId: missingOperationId,
|
||||
remark: `${context.marker}-missing-container`,
|
||||
})
|
||||
context.addStep(MODULE, '无号拣货完成后 PDA complete 精确返回 A0409', missingComplete,
|
||||
missingComplete.http === 200 && codeOf(missingComplete) === 'A0409'
|
||||
&& String(missingComplete.body?.message || '').includes('真实集装箱号'),
|
||||
`${responseEvidence(missingComplete)}, message=${missingComplete.body?.message || 'missing'}`, OPTIONS)
|
||||
|
||||
const transport = await request(context, 'PATCH',
|
||||
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
||||
containerNo, voyageNo: bookingDraft.voyageNo, expectedVersion: Number(pickedPlan?.version),
|
||||
operationReason: null,
|
||||
}, { 'Idempotency-Key': `${context.marker}-late-container-fill`.slice(0, 128) })
|
||||
const filled = context.apiSuccess(MODULE, 'LOADING 阶段通过预装运输信息首次补录真实柜号', transport,
|
||||
responseEvidence(transport), OPTIONS)
|
||||
context.assert(MODULE, '首次补号保持计划与柜次身份且柜号规范化落盘', transport,
|
||||
id(filled?.summary?.planId) === loadingPlanId
|
||||
&& id(filled?.summary?.containerBookingId) === containerBookingId
|
||||
&& filled?.summary?.status === 'LOADING' && filled?.summary?.containerNo === containerNo,
|
||||
`planId=${id(filled?.summary?.planId) || 'missing'}, containerBookingId=${id(filled?.summary?.containerBookingId) || 'missing'}, containerNo=${filled?.summary?.containerNo || 'missing'}`, OPTIONS)
|
||||
|
||||
const afterFillTaskResponse = await context.request('GET', taskDetailPath)
|
||||
const afterFillTask = context.apiSuccess(MODULE, '首次补号后回读原 PDA 任务', afterFillTaskResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '补号不替换 task/plan/cabinet 稳定身份', afterFillTaskResponse,
|
||||
id(afterFillTask?.id) === canonicalTaskId && id(afterFillTask?.loadingPlanId) === loadingPlanId
|
||||
&& id(afterFillTask?.containerBookingId) === containerBookingId
|
||||
&& afterFillTask?.currentContainerNo === containerNo,
|
||||
`taskId=${id(afterFillTask?.id) || 'missing'}, currentContainerNo=${afterFillTask?.currentContainerNo || 'missing'}`, OPTIONS)
|
||||
|
||||
const acceptedOperationId = `${context.marker}-late-container-accepted-complete`.slice(0, 128)
|
||||
const acceptedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||
expectedTaskVersion: Number(afterFillTask?.version), operationId: acceptedOperationId,
|
||||
remark: `${context.marker}-container-filled`,
|
||||
})
|
||||
context.assert(MODULE, '补号后 complete 已越过柜号门禁并受理异步命令', acceptedComplete,
|
||||
acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200'
|
||||
&& ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus),
|
||||
`${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
let terminalTask = acceptedComplete.body?.data
|
||||
let terminalResponse = acceptedComplete
|
||||
for (let attempt = 0; attempt < 10 && terminalTask?.workflowStatus === 'PROCESSING'; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
terminalResponse = await context.request('GET', taskDetailPath)
|
||||
if (terminalResponse.http === 200 && codeOf(terminalResponse) === '200') terminalTask = terminalResponse.body?.data
|
||||
}
|
||||
const nextStage = terminalTask?.completionCommand?.failureStage || null
|
||||
const nextCode = terminalTask?.completionCommand?.failureCode || null
|
||||
const nextMessage = String(terminalTask?.completionCommand?.failureMessage || '')
|
||||
context.assert(MODULE, '补号后已越过柜号门禁并允许进入柜级证据门禁', terminalResponse,
|
||||
!nextMessage.includes('集装箱号')
|
||||
&& (terminalTask?.workflowStatus !== 'PROCESSING'
|
||||
|| (nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409')),
|
||||
`workflow=${terminalTask?.workflowStatus || 'missing'}, stage=${terminalTask?.completionCommand?.failureStage || 'none'}, code=${terminalTask?.completionCommand?.failureCode || 'none'}, message=${terminalTask?.completionCommand?.failureMessage || 'none'}`, OPTIONS)
|
||||
context.createdResources.phase3LateContainerGate = {
|
||||
marker: context.marker, tenantId, inboundId, containerBookingId, cabinetNo: simulation.cabinetNo,
|
||||
oceanBookingId: bookingId, loadingPlanId, taskId: canonicalTaskId, containerNo,
|
||||
missingComplete: responseEvidence(missingComplete), fill: responseEvidence(transport),
|
||||
acceptedComplete: responseEvidence(acceptedComplete), finalWorkflow: terminalTask?.workflowStatus || null,
|
||||
nextFailureStage: nextStage, nextFailureCode: nextCode,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,
|
||||
|
||||
@@ -123,6 +123,25 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
|
||||
assert.match(value, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
|
||||
})
|
||||
|
||||
test('late container gate mode proves the final gate without requiring evidence completion', () => {
|
||||
const value = source('./phase3-loading.mjs')
|
||||
assert.match(value, /LATE_CONTAINER_GATE_ONLY = process\.env\.LATE_CONTAINER_GATE_ONLY === '1'/)
|
||||
assertOrder(value, [
|
||||
'无号模拟柜原地关联三期 RELEASED 订舱需求',
|
||||
'计划级原子确认整份预装并触发唯一 PDA 任务',
|
||||
'领取柜次 PDA 任务',
|
||||
'原子提交柜次 PDA 整批拣货',
|
||||
'无号拣货完成后 PDA complete 精确返回 A0409',
|
||||
'LOADING 阶段通过预装运输信息首次补录真实柜号',
|
||||
'补号后 complete 已越过柜号门禁并受理异步命令',
|
||||
'补号后已越过柜号门禁并允许进入柜级证据门禁',
|
||||
])
|
||||
assert.match(value, /String\(missingComplete\.body\?\.message \|\| ''\)\.includes\('真实集装箱号'\)/)
|
||||
assert.match(value, /nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409'/)
|
||||
assert.match(value, /!nextMessage\.includes\('集装箱号'\)/)
|
||||
assert.match(value, /context\.createdResources\.phase3LateContainerGate/)
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -220,7 +239,8 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
||||
])
|
||||
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
||||
assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/)
|
||||
assert.match(loading, /inventory\?\.currentContainerNo === containerNo/)
|
||||
assert.match(loading,
|
||||
/LATE_CONTAINER_GATE_ONLY \? inventory\?\.currentContainerNo == null : inventory\?\.currentContainerNo === containerNo/)
|
||||
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
|
||||
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
||||
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
||||
|
||||
Reference in New Issue
Block a user