This commit is contained in:
2026-08-18 16:39:39 +08:00
parent 7fe5b4e31f
commit a9061eca54
43 changed files with 821 additions and 1217 deletions
@@ -8940,12 +8940,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
@@ -8962,10 +8956,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional.
@@ -8973,10 +8964,7 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
-- Reconcile databases created before the direct-CONFIRMED contract. This is
-- intentionally fail-closed: no legacy row is promoted and no transport term
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
-- owning module first, then this block can be rerun safely.
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
@@ -9025,7 +9013,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
@@ -9040,7 +9028,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
@@ -9055,7 +9043,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
@@ -9075,7 +9063,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
@@ -9092,7 +9080,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
@@ -9101,7 +9089,7 @@ DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -9169,8 +9157,8 @@ BEGIN
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete'
WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate'
END)
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
@@ -9191,8 +9179,8 @@ VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
@@ -13199,8 +13187,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'bill_of_lading
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_id', '订舱代理逻辑ID快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'cancellation_reason', '取消订舱原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'change_reason', '船期变更原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_at', '订舱记录创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_by', '创建操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'created_by_name', '创建操作人姓名快照');
@@ -13214,10 +13200,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arri
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_estimated_arrival_at', '待确认变更预计到港时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_estimated_sailing_at', '待确认变更预计开船时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_vessel_name', '待确认变更船名');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_voyage_no', '待确认变更航次号');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平台内部备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照');
@@ -103,9 +103,7 @@ public final class ContainerDtos {
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {
@Deprecated public OceanBookingLinkRequest(Long legacyRealContainerBookingId, Integer expectedVersion) { this(null, null, null, expectedVersion); }
}
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@@ -107,50 +107,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE merged_into_container_booking_id=#{targetId} AND record_status='MERGED' FOR UPDATE")
ContainerBookingEntity selectMergedSourceForUpdate(@Param("targetId") long targetId);
@Update("""
UPDATE fl_container_booking SET record_status='MERGED',
merged_into_container_booking_id=#{targetId}, container_no=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='ACTIVE'
AND merged_into_container_booking_id IS NULL AND container_no IS NOT NULL
""")
int consumeRealSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking target
JOIN fl_container_booking source ON source.id=#{sourceId}
SET target.container_no=#{containerNo}, target.ocean_booking_id=source.ocean_booking_id,
target.ocean_booking_requirement_id=source.ocean_booking_requirement_id,
target.customer_id=source.customer_id, target.customer_name=source.customer_name,
target.bill_of_lading_no=source.bill_of_lading_no,
target.vessel_name=source.vessel_name, target.voyage_no=source.voyage_no,
target.shipping_company_id=source.shipping_company_id,
target.shipping_company_name=source.shipping_company_name,
target.departure_port_code=source.departure_port_code,
target.departure_port_name=source.departure_port_name,
target.destination_port_code=source.destination_port_code,
target.destination_port_name=source.destination_port_name,
target.estimated_sailing_at=source.estimated_sailing_at,
target.estimated_port_arrival_at=source.estimated_port_arrival_at,
target.updated_by=#{operatorId}, target.updated_by_name=#{operatorName},
target.updated_at=CURRENT_TIMESTAMP(3), target.version=target.version+1
WHERE target.id=#{targetId} AND target.version=#{expectedVersion}
AND target.source_type='SIMULATION' AND target.record_status='ACTIVE'
AND target.ocean_booking_id IS NULL AND source.record_status='MERGED'
AND source.merged_into_container_booking_id=target.id
""")
int mergeRealSource(@Param("targetId") long targetId, @Param("sourceId") long sourceId,
@Param("containerNo") String containerNo,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
@@ -164,23 +120,10 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int clearMergedLink(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET record_status='ACTIVE',
merged_into_container_booking_id=NULL, container_no=#{containerNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='MERGED'
AND merged_into_container_booking_id=#{targetId} AND container_no IS NULL
""")
int restoreMergedSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("containerNo") String containerNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
@@ -37,10 +37,6 @@ public final class OceanBookingDtos {
@NotNull @Min(1) @Max(100) Integer quantity,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Deprecated
public record ContainerRequest(String containerNo, String containerType, String containerKind,
BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@Size(max = 64) String channelCode,
@@ -63,20 +59,7 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {
public DraftData(Long customerId, String customerName, String channelCode, Long warehouseId, String billOfLadingNo,
Long shippingCompanyId, String shippingCompanyName, Long bookingAgentId, String bookingAgentName,
String vesselName, String voyageNo, String departurePortCode, String departurePortName,
String destinationPortCode, String destinationPortName, String placeOfDelivery, String serviceTerm,
String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String remark,
List<PartyRequest> parties, java.util.Collection<?> legacyContainers) {
this(customerId, customerName, channelCode, warehouseId, billOfLadingNo, shippingCompanyId, shippingCompanyName,
bookingAgentId, bookingAgentName, vesselName, voyageNo, departurePortCode, departurePortName, destinationPortCode,
destinationPortName, placeOfDelivery, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, remark,
parties, legacyContainers == null ? List.of() : legacyContainers.stream().map(v -> v instanceof ContainerRequirementRequest x ? x : v instanceof ContainerRequest x ? new ContainerRequirementRequest(x.containerType(), x.containerKind(), 1, x.capacityCbm(), x.maxWeightKg()) : null).filter(java.util.Objects::nonNull).toList());
}
public List<ContainerRequest> containers() { return containerRequirements.stream().map(v -> new ContainerRequest(null, v.containerType(), v.containerKind(), v.capacityCbm(), v.maxWeightKg())).toList(); }
}
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -85,14 +68,7 @@ public final class OceanBookingDtos {
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {
public ReleaseRequest(int expectedVersion, String bill, String vessel, String voyage, LocalDateTime etd, LocalDateTime eta, String reason) {
this(expectedVersion, reason);
}
}
@Deprecated
public record ChangeRequest(Integer expectedVersion, String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String reason) {}
@Size(max = 1000) String reason) {}
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
@@ -40,12 +40,6 @@ public class OceanBookingEntity {
private String freightTerm;
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String pendingVesselName;
private String pendingVoyageNo;
private LocalDateTime pendingEstimatedSailingAt;
private LocalDateTime pendingEstimatedArrivalAt;
private String changeReason;
private String cancellationReason;
private String remark;
private Long createdBy;
private String createdByName;
@@ -43,13 +43,12 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion}
""")
int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("cancellationReason") String cancellationReason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -19,7 +19,7 @@ public interface OceanBookingOperationMapper extends BaseMapper<OceanBookingOper
@Update("UPDATE fl_ocean_booking_operation SET ocean_booking_id=#{oceanBookingId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("oceanBookingId") long oceanBookingId, @Param("operatorId") long operatorId,
int complete(@Param("oceanBookingId") Long oceanBookingId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key,
@Param("resultJson") String resultJson);
}
@@ -31,20 +31,14 @@ public class OceanBookingRepository {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
@Deprecated public int release(long id, Object ignored, long operatorId, String operatorName) { return 0; }
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, String cancellationReason,
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName);
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, operatorName);
}
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id));
requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id));
parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id));
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
@@ -86,5 +80,5 @@ public class OceanBookingRepository {
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); }
public int completeOperation(long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
public int completeOperation(Long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
}
@@ -112,18 +112,15 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
@Deprecated public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { throw conflict("订舱仅支持DRAFT和RELEASED,不能取消"); }
@Deprecated public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) { throw conflict("RELEASED订舱不可变更"); }
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
Detail replay = claimOrReplay(null, operatorId, action, key, hash(action, id, request));
if (replay != null) return;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
if (repository.deleteDraft(id, request.expectedVersion()) != 1) throw concurrent();
complete(id, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
@@ -134,7 +131,7 @@ public class OceanBookingService {
requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id));
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
@@ -153,7 +150,7 @@ public class OceanBookingService {
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取订舱幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String action, String key, Detail detail) {
private Detail complete(Long id, long operatorId, String action, String key, Detail detail) {
try {
if (repository.completeOperation(id, operatorId, action, key, objectMapper.writeValueAsString(detail)) != 1) {
throw conflict("订舱幂等请求完成状态冲突");
@@ -259,50 +256,6 @@ public class OceanBookingService {
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
/* real container rows are created only by direct container linking after release. */
/*
for (int index = 0; index < requests.size(); index++) {
ContainerRequirementRequest request = requests.get(index);
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + container.getId());
container.setContainerNo(request.containerNo().trim());
container.setSourceType("OCEAN_BOOKING"); container.setRecordStatus("ACTIVE");
container.setChannelCode(booking.getChannelCode()); container.setChannelName(booking.getChannelName());
container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setBillOfLadingNo(booking.getBillOfLadingNo());
container.setVesselName(booking.getVesselName()); container.setVoyageNo(booking.getVoyageNo());
container.setContainerType(requirement.getContainerType());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setEstimatedSailingAt(booking.getEstimatedSailingAt());
container.setEstimatedPortArrivalAt(booking.getEstimatedArrivalAt());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
try { repository.insertContainer(container); }
catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在:" + request.containerNo().trim());
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId());
allocation.setContainerBookingId(container.getId()); allocation.setAllocatedBy(operatorId);
allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now());
repository.insert(allocation);
}
}
*/
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
@@ -18,9 +18,6 @@ public class ContainerRepository {
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public ContainerBookingEntity mergedSourceForUpdate(long targetId) {
return mapper.selectMergedSourceForUpdate(targetId);
}
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -33,19 +30,8 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int consumeRealSource(long sourceId, long targetId, long operatorId, String operatorName) {
return mapper.consumeRealSource(sourceId, targetId, operatorId, operatorName);
}
public int mergeRealSource(long targetId, long sourceId, String containerNo, int version,
long operatorId, String operatorName) {
return mapper.mergeRealSource(targetId, sourceId, containerNo, version, operatorId, operatorName);
}
public int clearMergedLink(long targetId, int version, long operatorId, String operatorName) {
return mapper.clearMergedLink(targetId, version, operatorId, operatorName);
}
public int restoreMergedSource(long sourceId, long targetId, String containerNo,
long operatorId, String operatorName) {
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
public int unlinkReleasedBooking(long targetId, int version, long operatorId, String operatorName) {
return mapper.unlinkReleasedBooking(targetId, version, operatorId, operatorName);
}
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
@@ -107,14 +107,10 @@ public class ContainerService {
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
ContainerBookingEntity source = repository.mergedSourceForUpdate(id);
if (source == null) throw conflict("真实订舱柜合并来源不存在,不能解除关联");
String containerNo = container.getContainerNo();
if (repository.clearMergedLink(id, request.expectedVersion(), operatorId, operatorName) != 1
|| repository.restoreMergedSource(source.getId(), id, containerNo, operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(id, source.getId()) != 1) {
if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
oceanBookings.deleteAllocation(id);
return required(id);
}
@@ -218,6 +218,7 @@ public class SmartLoadingService {
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String remark = trim(request.warehouseRemark());
if (!StringUtils.hasText(remark)) remark = null;
String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|'
+ Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|'
+ request.expectedItemVersion());
@@ -27,12 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
@@ -49,10 +43,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional.
@@ -60,10 +51,7 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
-- Reconcile databases created before the direct-CONFIRMED contract. This is
-- intentionally fail-closed: no legacy row is promoted and no transport term
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
-- owning module first, then this block can be rerun safely.
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
@@ -112,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
@@ -127,7 +115,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
@@ -142,7 +130,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
@@ -162,7 +150,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
@@ -179,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
@@ -188,7 +176,7 @@ DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -256,8 +244,8 @@ BEGIN
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete'
WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate'
END)
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
@@ -278,8 +266,8 @@ VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
@@ -251,14 +251,14 @@ paths:
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜原地合并为指定真实订舱柜
description: 需要 platform:firstleg:container:edit;请求指定未消费且尚无装柜计划/PDA/证据/单证的 realContainerBookingId。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,将真实柜 containerNo、订舱、运输和范围快照原子复制到模拟柜并把 allocation 改指模拟柜;原真实柜置 MERGED 且不再出现在正常货柜列表。存在跨仓、跨渠道、柜型规格不一致、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 合并后的同一模拟柜 containerBookingId/cabinetNo;列表仅保留这一条 ACTIVE 操作身份}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;原子清除当前柜订舱/运输快照和 allocation,不恢复或创建其他柜记录。任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
@@ -408,7 +408,11 @@ paths:
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
description: EDITING/LOADING 可修改或清空 warehouseRemark;COMPLETED、计划/明细版本冲突返回 A0409,写入历史且与 PDA final complete 使用同一并发门禁。
description: >-
需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark;
COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。
成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -29,7 +29,17 @@ paths:
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/channels:
get: {summary: List ACTIVE channels for an origin warehouse, responses: {'200': {description: Channel options}}}
get:
summary: List ACTIVE channels for an origin warehouse
description: Requires platform:firstleg:ocean-booking:list. Channel names are resolved from the current ACTIVE price book and frozen by the server on save.
parameters:
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: Channel options
content:
application/json:
schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -48,8 +58,15 @@ components:
billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
bookingAgentId: {type: integer, format: int64, nullable: true}
bookingAgentName: {type: string, nullable: true}
bookingAgentId:
type: integer
format: int64
nullable: true
description: Warehouse supplier ID; release validates ACTIVE category BOOKING; booking suppliers do not use domestic/international scope.
bookingAgentName:
type: string
nullable: true
description: Read-only consistency hint; the persisted snapshot always comes from Warehouse.
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
departurePortCode: {type: string, nullable: true}
@@ -68,4 +85,13 @@ components:
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
properties:
priceBookVersionId: {type: integer, format: int64}
channelCode: {type: string}
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
@@ -60,10 +60,12 @@ class ContainerControllerContractTest {
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("realContainerBookingId")
.doesNotContain("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -75,6 +77,7 @@ class ContainerControllerContractTest {
.doesNotContain("tenantId")
.doesNotContain("accountId");
assertThat(openapi)
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
@@ -45,6 +45,11 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("需要 platform:firstleg:preload:edit 权限")
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
.contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -23,10 +23,9 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("release", "platform:firstleg:ocean-booking:release"),
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"));
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
@@ -51,28 +50,11 @@ class PlatformOceanBookingControllerContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
.contains("containerNo: {type: string, maxLength: 64")
.contains("containers: {type: array, minItems: 1, maxItems: 20")
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
.contains("warehouseName is resolved and frozen server-side")
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
"placeOfDelivery:", "remark:", "PartyRequest:")
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
"/{oceanBookingId}/change-confirm:")
.contains("persists the booking directly as CONFIRMED")
.contains("RELEASED remains a separate later action")
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
.contains("DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid")
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
.contains("customerName: {type: string, maxLength: 128, nullable: true")
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
.contains("freightTerm: {type: string, maxLength: 32, description: Required free-text")
.contains("billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed")
.contains("estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA")
.doesNotContain("Must be omitted until release")
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release a DRAFT")
.contains("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
}
private static Method method(String name) {
@@ -33,9 +33,7 @@ class OceanBookingMapperContractTest {
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
.doesNotContain("SUBMITTED")
.doesNotContain("FAILED");
.contains("status='DRAFT' AND version=#{version}")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
}
}
@@ -22,39 +22,34 @@ class OceanBookingSqlContractTest {
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'")
.contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status='CONFIRMED'")
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED')")
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("('CONFIRMED','RELEASED') THEN")
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
.contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
}
@Test
void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception {
assertThat(sql())
.contains("reconcile_ocean_booking_legacy")
.contains("service_term/freight_term 为空的真实行")
.contains("status IN ('DRAFT','SUBMITTED','FAILED')")
.contains("不会伪造 CONFIRMED")
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
.doesNotContain("UPDATE fl_ocean_booking SET status=");
}
@Test
@@ -62,11 +57,12 @@ class OceanBookingSqlContractTest {
assertThat(sql())
.contains("platform:firstleg:ocean-booking:list")
.contains("platform:firstleg:ocean-booking:create")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:delete")
.contains("platform:firstleg:ocean-booking:release")
.contains("platform:firstleg:ocean-booking:cancel")
.contains("platform:firstleg:ocean-booking:change")
.contains("platform:firstleg:ocean-booking:allocate")
.doesNotContain("platform:firstleg:ocean-booking:view");
.doesNotContain("platform:firstleg:ocean-booking:view",
"platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change");
}
private static String sql() throws Exception {
@@ -1,39 +1,27 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.BeanUtils;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -45,392 +33,223 @@ import static org.mockito.Mockito.*;
class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@Mock private PriceBookQueryRepository priceBooks;
@Mock private LoadingPickService loadingPickService;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
field.setAccessible(true);
field.set(service, objectMapper);
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(suppliers.booking(any(), any())).thenReturn(null);
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
anyLong(), anyString())).thenReturn(1);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@Test
void rejectsCancellationAfterRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
void savesBlankDraftWithoutCreatingContainersOrAllocations() {
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-empty",
new CreateRequest(0, partialDraft(null)));
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
.hasMessageContaining("不可逆");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), anyInt(), anyLong(), anyString());
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
verifyNoInteractions(priceBooks, suppliers, warehouses);
}
@Test
void opensAllocatedContainerPdaTasksAfterRelease() {
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
confirmed.setVoyageNo("V001");
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
OceanBookingEntity released = booking("RELEASED", 4);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(12L);
when(repository.find(1L)).thenReturn(confirmed, released);
when(repository.release(eq(1L), any(ReleaseRequest.class), eq(9L), eq("admin"))).thenReturn(1);
when(repository.allocations(1L)).thenReturn(List.of(allocation));
var request = new ReleaseRequest(3, "BL-001", "VESSEL", "V001",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
Detail result = service.release(1L, 9L, "admin", "release-1", request);
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
verify(loadingPickService).beginIfReady(12L, 9L, "admin");
}
@Test
void rejectsReleaseWhenTransportFieldsDifferFromRegisteredSnapshot() {
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
confirmed.setVoyageNo("V001");
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
when(repository.find(1L)).thenReturn(confirmed);
var request = new ReleaseRequest(3, "BL-CHANGED", "VESSEL", "V001",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-mismatch", request))
.isInstanceOf(BizException.class).hasMessageContaining("必须与已登记");
verify(repository, never()).release(anyLong(), any(), anyLong(), anyString());
verifyNoInteractions(loadingPickService);
}
@Test
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
OceanBookingEntity current = booking("CONFIRMED", 5);
current.setChannelCode("CHANNEL-A");
current.setVesselName("OLD"); current.setVoyageNo("V1");
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
when(repository.find(1L)).thenReturn(current, pending);
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
}
@Test
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("状态");
}
verify(repository, never()).page(any());
}
@Test
void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
verify(repository).page(query.capture());
assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
}
@Test
void listsOnlyChannelsFromCurrentActivePriceBook() {
ChannelRow channel = channel("SEA-US", "美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of());
var result = service.channels(701L);
assertThat(result).singleElement().satisfies(value -> {
assertThat(value.channelCode()).isEqualTo("SEA-US");
assertThat(value.channelName()).isEqualTo("美西海运");
assertThat(value.priceBookVersionId()).isEqualTo(51L);
});
}
@Test
void registersOfflineBookingDirectlyAsConfirmedWithCompleteTransportSnapshot() {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
AtomicReference<OceanBookingEntity> inserted = stubSuccessfulCreation();
Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft()));
assertThat(inserted.get().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
assertThat(result.oceanBooking().getEstimatedSailingAt())
.isEqualTo(LocalDateTime.of(2026, 9, 1, 0, 0));
assertThat(result.oceanBooking().getEstimatedArrivalAt())
.isEqualTo(LocalDateTime.of(2026, 9, 20, 0, 0));
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
assertThat(history.getValue().getToStatus()).isEqualTo("CONFIRMED");
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
"admin");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insertContainer(containers.capture());
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
}
@Test
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
stubSuccessfulCreation();
DraftData source = draft();
List<ContainerRequest> requested = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
service.create(9L, "admin", "create-two-containers",
new CreateRequest(0, withContainers(source, requested)));
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
stubMasterData();
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft()));
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(anyLong(), requirements.capture());
assertThat(requirements.getValue()).hasSize(2)
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
.containsExactly("40HC", "20GP");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository, times(2)).insertContainer(containers.capture());
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
.containsExactly("MSCU-001", "MSCU-002");
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository, times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
assertThat(captor.getValue()).singleElement().satisfies(value -> {
assertThat(value.getContainerType()).isEqualTo("40HC");
assertThat(value.getQuantity()).isEqualTo(2);
});
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
}
@Test
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
DraftData source = draft();
List<ContainerRequest> duplicated = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
void updatesOnlyDraftWithExpectedVersion() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3), booking("DRAFT", 4));
when(repository.updateDraft(any(), eq(3), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.updateDraft(1L, 9L, "admin", "update-1",
new UpdateRequest(3, partialDraft("备注")));
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
new CreateRequest(0, withContainers(source, duplicated))))
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
verify(repository, never()).insert(any(OceanBookingEntity.class));
verifyNoInteractions(priceBooks);
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
verify(repository).replaceParties(1L, List.of());
verify(repository).replaceRequirements(1L, List.of());
}
@Test
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
new CreateRequest(0, draft())))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
void rejectsReleaseWhenPersistedAggregateIsIncompleteWithoutWriting() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-missing",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
stubSuccessfulCreation();
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
OceanBookingEntity released = completeBooking("RELEASED", 4);
when(repository.find(1L)).thenReturn(draft, released);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
ReleaseRequest request = new ReleaseRequest(3, "确认放舱");
Detail result = service.create(9L, "admin", "create-without-customer",
new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO")));
assertThat(result.oceanBooking().getCustomerId()).isNull();
assertThat(result.oceanBooking().getCustomerName()).isNull();
assertThat(result.oceanBooking().getServiceTerm()).isEqualTo("CY-CY");
assertThat(result.oceanBooking().getFreightTerm()).isEqualTo("NINGBO");
}
@Test
void rejectsPartialCustomerSnapshot() {
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-id",
new CreateRequest(0, draft(100L, null, null, null))))
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-name",
new CreateRequest(0, draft(null, "客户", null, null))))
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsMissingRequiredTransportTerms() {
assertThatThrownBy(() -> service.create(9L, "admin", "missing-service-term",
new CreateRequest(0, draft(100L, "客户", null, "NINGBO"))))
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
assertThatThrownBy(() -> service.create(9L, "admin", "blank-freight-term",
new CreateRequest(0, draft(100L, "客户", "CY-CY", " "))))
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
DraftData source = draft();
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
DraftData source = draft();
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), null, source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
.hasMessageContaining("提单号、船名、航次、ETD和ETA不能为空");
verifyNoInteractions(priceBooks);
}
@Test
void replaysCreateAfterChannelIsNoLongerActive() throws Exception {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
CreateRequest request = new CreateRequest(0, draft());
when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
stubSuccessfulCreation();
Detail created = service.create(9L, "admin", "create-replay-channel", request);
Detail first = service.release(1L, 9L, "admin", "release-1", request);
assertThat(first.oceanBooking().getStatus()).isEqualTo("RELEASED");
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created));
when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString()))
.thenReturn(false);
when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation);
operation.setRequestHash(hash("RELEASE", 1L, request));
operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.claimOperation(1L, 9L, "RELEASE", "release-1", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "RELEASE", "release-1")).thenReturn(operation);
Detail replay = service.create(9L, "admin", "create-replay-channel", request);
assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
verify(priceBooks, times(1)).activeVersionId();
assertThat(service.release(1L, 9L, "admin", "release-1", request).oceanBooking().getStatus())
.isEqualTo("RELEASED");
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
private static ChannelRow channel(String code, String name) {
ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code);
value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value;
@Test
void releasedAggregateRejectsEditDeleteAndRepeatRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
assertThatThrownBy(() -> service.updateDraft(1L, 9L, "admin", "edit-released",
new UpdateRequest(4, partialDraft(null)))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.deleteDraft(1L, 9L, "admin", "delete-released",
new ActionRequest(4, null))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-again",
new ReleaseRequest(4, null))).isInstanceOf(BizException.class);
verify(repository, never()).deleteDraft(anyLong(), anyInt());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
private static DraftData draft() {
return draft(100L, "客户", "CY-CY", "NINGBO");
@Test
void deleteDraftKeepsIdempotencyDetachedFromDeletedAggregate() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2));
when(repository.deleteDraft(1L, 2)).thenReturn(1);
service.deleteDraft(1L, 9L, "admin", "delete-1", new ActionRequest(2, "作废草稿"));
verify(repository).claimOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
verify(repository).completeOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
}
private static DraftData draft(Long customerId, String customerName, String serviceTerm, String freightTerm) {
List<PartyRequest> parties = List.of(
new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequest> containers = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
@Test
void supportsOnlyDraftAndReleasedListStatuses() {
service.page(new Query(null, "DRAFT", null, 1, 20));
service.page(new Query(null, "RELEASED", null, 1, 20));
assertThatThrownBy(() -> service.page(new Query(null, "CONFIRMED", null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("状态");
verify(repository, times(2)).page(any());
}
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
@Test
void releaseRejectsInvalidScheduleWithoutTransition() {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
draft.setEstimatedArrivalAt(draft.getEstimatedSailingAt().minusDays(1));
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-range",
new ReleaseRequest(3, null))).isInstanceOf(BizException.class).hasMessageContaining("不能早于");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
.when(repository).insert(any(OceanBookingEntity.class));
when(repository.find(anyLong())).thenAnswer(invocation -> {
OceanBookingEntity confirmed = new OceanBookingEntity();
BeanUtils.copyProperties(inserted.get(), confirmed);
confirmed.setStatus("CONFIRMED");
confirmed.setVersion(1);
return confirmed;
});
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
return inserted;
}
private String hashForCreate(CreateRequest request) throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, "CREATE", (Long) null, request);
private void stubMasterData() {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运");
row.setPriceModel("FBA_WAREHOUSE_RATE");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE"));
}
private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
}
private static DraftData completeDraft() {
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
List.of(new ContainerRequirementRequest("40HC", "DRY", 2,
new BigDecimal("76"), new BigDecimal("28000"))));
}
private static OceanBookingEntity completeBooking(String status, int version) {
OceanBookingEntity value = booking(status, version);
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
return value;
}
private static OceanBookingEntity booking(String status, int version) {
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
value.setChannelCode("CHANNEL-A"); value.setVersion(version);
OceanBookingEntity value = new OceanBookingEntity();
value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setVersion(version);
return value;
}
private static List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity> parties() {
return List.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY").stream().map(type -> {
var value = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity();
value.setPartyType(type); value.setPartyName(type); return value;
}).toList();
}
private static OceanBookingContainerRequirementEntity requirement() {
var value = new OceanBookingContainerRequirementEntity();
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY");
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76"));
value.setMaxWeightKg(new BigDecimal("28000")); return value;
}
private String hash(String action, Long id, Object request) throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, action, id, request);
}
}
@@ -26,15 +26,15 @@ class Phase3BookingTruckPermissionSqlContractTest {
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change");
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete");
private static final Set<String> OCEAN_SQL_PERMISSIONS = Set.of(
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete",
"platform:firstleg:ocean-booking:allocate");
private static final Set<String> TRUCK_PERMISSIONS = Set.of(
@@ -62,11 +62,13 @@ class Phase3FinalUnifiedSqlContractTest {
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
String sql = normalise(Files.readString(UNIFIED));
assertThat(sql)
.contains("reconcile_ocean_booking_legacy")
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.contains("不会伪造 CONFIRMED")
.contains("(status IN ('DRAFT','RELEASED'))")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:delete")
.doesNotContain("reconcile_ocean_booking_legacy")
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -32,12 +33,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -282,94 +282,83 @@ class ContainerServiceTest {
@Test
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksConfirmedBookingAfterPreloading() {
void linksReleasedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
when(oceanBookings.allocations(21L)).thenReturn(List.of());
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
verify(oceanBookings).reassignAllocation(41L, 11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
void linksReleasedBooking() {
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
booking.setId(21L); booking.setStatus("DRAFT");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
}
@Test
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setContainerType("40HC");
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
requirement.setId(31L); requirement.setQuantity(1);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
allocation.setRequirementId(31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
verify(oceanBookings).reassignAllocation(41L, 11L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("已合并的真实订舱柜不可再次关联")
void rejectsConsumedRealContainer() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(consumed);
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
void unlinksEmptySimulationDirectly() {
ContainerBookingEntity linked = booking("WAIT_LOADING", 2);
linked.setOceanBookingId(21L);
ContainerBookingEntity detached = booking("WAIT_LOADING", 3);
when(repository.find(11L)).thenReturn(linked, detached);
when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin",
new OceanBookingUnlinkRequest(2, "换绑"));
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
void rejectsRealContainerWithLoadingPlan() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.hasLoadingPlan(41L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
verifyNoInteractions(oceanBookings);
assertThat(result).isSameAs(detached);
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin");
verify(oceanBookings).deleteAllocation(11L);
}
@Test
@@ -379,21 +368,12 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
return value;
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
@@ -255,7 +256,7 @@ class SmartLoadingServiceTest {
assertThat(saved.getProductNameEn()).isEqualTo("English name");
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
@@ -382,7 +383,7 @@ class SmartLoadingServiceTest {
}
@Test
void updatesAndClearsWarehouseRemarkWithBothVersions() {
void updatesAndClearsBlankWarehouseRemarkWithBothVersions() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("旧仓库备注");
@@ -393,7 +394,7 @@ class SmartLoadingServiceTest {
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(null, 2, 0, "warehouse-clear"), 7L, "u");
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
@@ -404,6 +405,74 @@ class SmartLoadingServiceTest {
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "新备注", 7L, "u")).thenReturn(0);
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).item(anyLong(), anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void exactWarehouseRemarkReplayIsReadOnly() {
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
when(repository.operation("warehouse-replay"))
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
assertThat(result.version()).isEqualTo(3);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
}
@Test
void quantityAdjustmentPreservesWarehouseRemark() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("装柜时注意外箱");
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void preservesDownstreamCode() {
stubAssignment();
+5 -4
View File
@@ -49,8 +49,9 @@ export async function run(context) {
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空) | `truck-appointments-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
@@ -63,9 +64,9 @@ export async function run(context) {
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱 DRAFT 完整登记 containerRequirements -> RELEASED -> 港口取得真实 containerNo -> 按 oceanBookingId + requirementId + containerNo + expectedVersion 原地关联并创建 allocation -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
@@ -1,96 +1,50 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java')
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const containerDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java')
const containerService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java')
const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java')
const oceanMapper = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java')
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const loadingFixture = read('phase3-container-loading.mjs')
const fixture = read('phase3-container-loading.mjs')
const fullEntry = read('../admin-test-env-acceptance.mjs')
const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md')
test('operational ocean booking list excludes unconfirmed data', () => {
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*CONFIRMED[\s\S]*CHANGE_PENDING[\s\S]*RELEASED[\s\S]*CANCELLED/)
assert.match(oceanMapper, /status IN \('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED'\)/)
assert.match(oceanOpenApi, /enum: \[CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED\]/)
assert.match(oceanOpenApi, /DRAFT is internal to the create transaction and legacy SUBMITTED\/FAILED values are invalid/)
assert.doesNotMatch(oceanOpenApi.match(/name: status[^\n]*/)?.[0] || '', /DRAFT|SUBMITTED/)
test('booking is an editable DRAFT aggregate and RELEASED is immutable', () => {
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*DRAFT[\s\S]*RELEASED/)
assert.match(oceanOpenApi, /Create an editable DRAFT/)
assert.match(oceanOpenApi, /Replace a DRAFT/)
assert.match(oceanOpenApi, /Delete a DRAFT; RELEASED is immutable/)
assert.doesNotMatch(oceanOpenApi, /CHANGE_PENDING|CANCELLED/)
})
test('simulation creation uses server-owned identity and formal master data', () => {
assert.match(controller, /@PostMapping[\s\S]*SimulationCreateRequest request[\s\S]*createSimulation/)
assert.match(containerDtos, /SimulationCreateRequest\(@NotBlank[\s\S]*String channelCode[\s\S]*Long warehouseId[\s\S]*String containerType[\s\S]*BigDecimal capacityCbm[\s\S]*BigDecimal maxWeightKg/)
assert.match(containerService, /requireDomesticTransitWarehouse\(request\.warehouseId\(\)\)/)
assert.match(containerService, /activeVersionId\(\)[\s\S]*priceBooks\.channels\(versionId, warehouse\.id\(\)\)/)
assert.match(containerService, /setCabinetNo\("CAB-" \+ value\.getId\(\)\)/)
assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/)
test('booking requirements never carry real container numbers or create allocations', () => {
assert.match(oceanOpenApi, /containerRequirements/)
assert.match(oceanOpenApi, /ContainerRequirementRequest/)
assert.match(oceanOpenApi, /quantity/)
assert.match(oceanOpenApi, /no containerNo, container row, or allocation is created/)
assert.doesNotMatch(oceanOpenApi.match(/ContainerRequirementRequest:[\s\S]*?ReleaseRequest:/)?.[0] || '', /containerNo/)
})
test('link consumes one active real booking container through the frozen request', () => {
assert.match(controller, /@PostMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(controller, /@DeleteMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long realContainerBookingId,[\s\S]*expectedVersion/)
assert.match(containerService, /requireFirstLinkMutable\(container\)/)
assert.match(containerService, /repository\.findForUpdate\(request\.realContainerBookingId\(\)\)/)
assert.match(containerService, /"OCEAN_BOOKING"\.equals\(source\.getSourceType\(\)\)/)
assert.match(containerService, /"ACTIVE"\.equals\(source\.getRecordStatus\(\)\)/)
assert.match(containerService, /"CONFIRMED"\.equals\(booking\.getStatus\(\)\)[\s\S]*"RELEASED"\.equals\(booking\.getStatus\(\)\)/)
assert.match(containerService, /hasPickTask[\s\S]*hasEvidence[\s\S]*hasDocuments/)
assert.match(firstlegOpenApi, /realContainerBookingId/)
assert.match(firstlegOpenApi, /原真实柜置 MERGED 且不再出现在正常货柜列表/)
test('port link mutates the simulation through the frozen four-field request', () => {
assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long oceanBookingId,[\s\S]*Long requirementId,[\s\S]*String containerNo,[\s\S]*Integer expectedVersion/)
assert.match(containerService, /request\.oceanBookingId\(\)/)
assert.match(containerService, /request\.requirementId\(\)/)
assert.match(containerService, /request\.containerNo\(\)/)
assert.match(containerService, /["']RELEASED["']\.equals\(booking\.getStatus\(\)\)/)
assert.doesNotMatch(fixture, /realContainerBookingId/)
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
})
test('real booking merge locks scope and changes all identities atomically', () => {
for (const field of ['warehouseId', 'channelCode', 'containerType', 'capacityCbm', 'maxWeightKg']) {
assert.match(containerService, new RegExp(`container\\.get${field[0].toUpperCase()}${field.slice(1)}\\(\\)`), field)
}
assert.match(containerService, /hasMismatchedCustomerItems/)
assert.match(containerService, /consumeRealSource\(source\.getId\(\), id/)
assert.match(containerService, /mergeRealSource\(id, source\.getId\(\), source\.getContainerNo\(\)/)
assert.match(containerService, /reassignAllocation\(source\.getId\(\), id\)/)
assert.doesNotMatch(containerService.match(/linkOceanBooking[\s\S]*?return required\(id\);/)?.[0] || '', /container\.setId\(|container\.setCabinetNo\(/)
test('HTTP fixture keeps simulation and plan identities and creates allocation at link', () => {
assert.match(fixture, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(fixture, /订舱 allocation 原子创建并指向稳定模拟柜身份/)
assert.match(fixture, /已消费订舱需求拒绝再次关联/)
assert.match(fixture, /RELEASED 订舱进入正式列表/)
})
test('booking creation atomically registers one real operational container per input row', () => {
assert.match(oceanOpenApi, /containers: \{type: array, minItems: 1, maxItems: 20/)
assert.match(oceanOpenApi, /required: \[containerNo, containerType, containerKind, capacityCbm, maxWeightKg\]/)
assert.match(oceanService, /request\.data\(\)\.containers\(\)\.forEach/)
assert.match(oceanService, /同一订舱内集装箱号不能重复/)
assert.match(oceanService, /createRealContainers\(value, requirements, request\.data\(\)\.containers\(\)/)
assert.match(oceanService, /setSourceType\("OCEAN_BOOKING"\)/)
assert.match(oceanService, /setRecordStatus\("ACTIVE"\)/)
assert.match(oceanService, /setQuantity\(1\)/)
})
test('HTTP fixture follows simulation arrange link PDA sequence without auto-container fallback', () => {
const ordered = [
'新增内部模拟柜',
'人工分配本轮候选货物',
'首次关联真实已确认订舱柜',
'查询本轮柜次 PDA 拣货任务',
].map((label) => loadingFixture.indexOf(label))
assert.ok(ordered.every((value) => value >= 0), `missing sequence label: ${ordered.join(',')}`)
assert.ok(ordered.every((value, index) => index === 0 || value > ordered[index - 1]), `invalid sequence: ${ordered.join(',')}`)
assert.match(loadingFixture, /ocean-booking-link/)
assert.match(loadingFixture, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(loadingFixture, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/)
assert.match(loadingFixture, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(loadingFixture, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/)
})
test('default full acceptance preserves the linked simulation chain before downstream completion', () => {
test('default full acceptance keeps the linked simulation before PDA and finalize', () => {
const loading = fullEntry.indexOf("'phase3-container-loading'")
const pda = fullEntry.indexOf("'phase3-pda-convergence'")
const finalize = fullEntry.indexOf("'phase3-loading-finalize'")
const outbound = fullEntry.indexOf("'phase3-outbound-dispatch'")
assert.ok(loading >= 0 && pda > loading && finalize > pda && outbound > finalize)
assert.match(matrix, /内部模拟柜关联真实订舱增量/)
assert.doesNotMatch(matrix, /CONFIRMED` 自动建柜[\s\S]*既有冻结/)
assert.ok(loading >= 0 && pda > loading && finalize > pda)
})
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const loading = read('phase3-loading.mjs')
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const outbox = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java')
const event = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java')
const admin = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue')
test('fixture locks customer and latest internal note sources without cargoRemark leakage', () => {
assert.match(loading, /assignedItem\?\.customerRemark === orderRemarks\.customerRemark/)
assert.match(loading, /assignedItem\?\.internalRemark === orderRemarks\.internalRemark/)
assert.match(loading, /assignedItem\?\.internalRemark !== orderRemarks\.cargoRemark/)
assert.match(service, /item\.setCustomerRemark\(cargo\.customerRemark\(\)\)/)
assert.match(service, /item\.setInternalRemark\(cargo\.latestInternalNote\(\)\)/)
})
test('warehouse remark PATCH covers save clear stale version and source preservation', () => {
assert.match(openapi, /items\/\{itemId\}\/warehouse-remark/)
assert.match(openapi, /expectedPlanVersion/)
assert.match(openapi, /expectedItemVersion/)
assert.match(openapi, /null\/空字符串均表示清空/)
assert.match(openapi, /COMPLETED、计划\/明细版本冲突返回 A0409 且事务零写入/)
assert.match(openapi, /数量调整或动态增量不会覆盖已有 warehouseRemark/)
assert.match(loading, /EDITING 装柜明细保存仓库备注/)
assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/)
assert.match(loading, /仓库备注精确重放不重复递增版本或历史事实/)
assert.match(loading, /仓库备注旧版本 A0409 且零写入/)
assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/)
assert.match(loading, /EDITING 装柜明细空白仓库备注归一化为 null/)
assert.match(loading, /恢复真实仓库备注供装柜完成事件下发/)
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
})
test('completed event V2 carries all three remarks with stable event payload', () => {
assert.match(outbox, /FirstLegLoadingPlanCompletedV2/)
for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field))
assert.match(outbox, /row\.getCustomerRemark\(\)/)
assert.match(outbox, /row\.getInternalRemark\(\)/)
assert.match(outbox, /row\.getWarehouseRemark\(\)/)
assert.match(outbox, /HANDOVER_VERSION = 2L/)
assert.match(outbox, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/)
assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/)
assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 2/)
})
test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => {
assert.match(admin, /备注(客户)/)
assert.match(admin, /备注(内部)/)
assert.match(admin, /备注(仓库)/)
assert.match(admin, /编辑备注/)
assert.match(admin, /COMPLETED/)
assert.doesNotMatch(admin, /v-model="[^\"]*customerRemark|v-model="[^\"]*internalRemark/)
})
@@ -113,7 +113,7 @@ async function createConfirmedContainer(context, source, label) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
@@ -127,28 +127,24 @@ async function createConfirmedContainer(context, source, label) {
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
const bookingId = id(created?.oceanBooking?.id)
const confirmed = created
context.assert(MODULE, `${label} 一次登记直接返回 CONFIRMED`, createdResponse,
confirmed?.oceanBooking?.status === 'CONFIRMED',
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker,
}, `${label}-booking-release`)
const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS)
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0),
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
const requirement = confirmed?.containers?.find?.((item) => item.containerNo === containerNo)
if (!requirement?.containerBookingId) throw new Error(`${label} 确认订舱未返回真实柜身份`)
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS)
if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
}
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmed?.oceanBooking?.version),
billOfLadingNo: confirmed?.oceanBooking?.billOfLadingNo,
vesselName: confirmed?.oceanBooking?.vesselName, voyageNo: confirmed?.oceanBooking?.voyageNo,
estimatedSailingAt: confirmed?.oceanBooking?.estimatedSailingAt,
estimatedArrivalAt: confirmed?.oceanBooking?.estimatedArrivalAt,
reason: context.marker,
}, `${label}-booking-release`)
context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS)
context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS)
return containerId
}
@@ -1,35 +1,21 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8')
test('retains only operational ocean booking states from direct CONFIRMED registration', () => {
assert.match(source, /const OCEAN_BASE = ['"]\/api\/platform\/v1\/ocean-bookings['"]/)
assert.doesNotMatch(source, /\/api\/platform\/v1\/first-leg\/ocean-bookings/)
for (const status of ['CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED',
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
assert.match(source, /change-request/)
assert.match(source, /const OCEAN_LIST_STATUSES = \['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'\]/)
assert.match(source, /const OCEAN_STATUSES = \[\.\.\.OCEAN_LIST_STATUSES\]/)
assert.doesNotMatch(source, /OCEAN_HIDDEN_STATUSES|hiddenFixtures|verifyHiddenOcean/)
assert.match(source, /一次创建直接返回 CONFIRMED/)
assert.doesNotMatch(source, /ocean-bookings\/\$\{[^}]+\}\/submit|ocean-bookings\/\$\{[^}]+\}\/confirm/)
const draftPayload = source.slice(source.indexOf('function oceanPayload'), source.indexOf('async function createOcean'))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(draftPayload, new RegExp(`${field}:`))
test('manual matrix retains only DRAFT and RELEASED ocean booking states', () => {
assert.match(source, /const OCEAN_LIST_STATUSES = \['DRAFT', 'RELEASED'\]/)
assert.match(source, /containerRequirements:/)
assert.doesNotMatch(source, /change-request|\/cancel/)
assert.doesNotMatch(source, /realContainerBookingId|containerNo:\s*`MAN-/)
assert.match(source, /status === 'DRAFT'/)
assert.match(source, /status === 'RELEASED'/)
})
test('truck matrix remains independent while retaining its own lifecycle', () => {
for (const status of ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS',
'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) {
assert.match(source, new RegExp(`['\"]${status}['\"]`))
}
const lifecycle = source.slice(source.indexOf('async function buildOcean'), source.indexOf('function truckPayload'))
assert.doesNotMatch(lifecycle, /'submit'|'confirm'|'fail'/)
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
assert.match(source, /page=1&size=100&status=/)
assert.match(source, /customerId: oceanBooking\.customerId/)
assert.match(source, /warehouseId: oceanBooking\.warehouseId/)
assert.doesNotMatch(source, /customerId: ['"]10001['"]/)
assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/)
assert.match(source, /carrierId: null, carrierName: null/)
assert.doesNotMatch(source, /人工验收车队-/)
assert.match(source, /billOfLadingNo: value\.oceanBooking\.billOfLadingNo/)
assert.doesNotMatch(source, /DELETE/)
})
@@ -23,8 +23,7 @@ test('Admin ocean booking selects a warehouse-scoped logistics channel instead o
assert.match(page, /shippingCompanyId/)
assert.match(page, /channelCode/)
assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/)
assert.match(pageTest, /expect\(source\)\.toContain\(['"]:model-value="form\.data\.channelCode" filterable['"]\)/)
assert.match(pageTest, /expect\(source\)\.not\.toContain\(['"]<el-input v-model="form\.data\.channelCode"['"]\)/)
assert.match(pageTest, /listWarehouseLogisticsChannels|channelCode/)
})
test('backend OpenAPI freezes a platform channel query scoped by originWarehouseId', () => {
@@ -33,15 +32,16 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse
.map((name) => source(new URL(name, openapiUrl)))
const platformChannelContract = documents.find((value) =>
/\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value)
&& /server validates it against the current ACTIVE price book/.test(value))
&& /current ACTIVE price book/.test(value))
assert.ok(platformChannelContract,
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
})
test('explicit real booking association locks the selected channel onto the simulation', () => {
test('released requirement association locks the selected channel onto the simulation', () => {
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
assert.match(fixture, /ocean-booking-link/)
assert.match(fixture, /一次登记直接返回 CONFIRMED 且尚未占用模拟柜/)
assert.match(fixture, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
assert.match(fixture, /selectPlatformChannelIsolationPair/)
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
@@ -4,161 +4,73 @@ import test from 'node:test'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => {
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' }
test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => {
const steps = []
const context = {
marker: 'ADMIN-RUN-1',
createdResources: { orchestrationFixtures: { phase3Container: {
marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31',
} } },
request: async () => ({ body: { code: '200', data: { summary: {
tenantId: 21, customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: 41, warehouseName: '深圳仓',
} } } }),
addStep: (...args) => steps.push(args),
}
const master = await resolveOceanBookingMaster(context, 'test')
assert.deepEqual(master, { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US',
channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' })
const context = { marker: 'ADMIN-RUN-1', createdResources: { orchestrationFixtures: { phase3Container: { marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31' } } },
request: async () => ({ body: { code: '200', data: { summary: { tenantId: 21, customerName: master.customerName, channelCode: master.channelCode, channelName: master.channelName, warehouseId: 41, warehouseName: master.warehouseName } } } }),
addStep: (...args) => steps.push(args) }
assert.deepEqual(await resolveOceanBookingMaster(context, 'test'), master)
assert.equal(steps.length, 0)
const blocked = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) }
assert.equal(await resolveOceanBookingMaster(blocked, 'test'), null)
assert.match(steps.at(-1)[4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('ocean booking fixture blocks creation instead of inventing missing master data', async () => {
const steps = []
const context = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) }
assert.equal(await resolveOceanBookingMaster(context, 'test'), null)
assert.equal(steps.length, 1)
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('shared booking data omits server-owned names and registers unique real containers', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId']) {
assert.ok(draft[key], `${key} is required`)
}
test('shared DRAFT uses requirements and never registers a real container number', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master)
assert.equal(draft.channelName, undefined)
assert.equal(draft.warehouseName, undefined)
assert.equal(draft.shippingCompanyId, null)
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentId, null)
assert.equal(draft.bookingAgentName, null)
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }])
assert.equal(Object.hasOwn(draft, 'containers'), false)
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.deepEqual(draft.containers, [{ containerNo: 'TST-3-01', containerType: '40HC',
containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }])
assert.equal(Object.hasOwn(draft, 'containerRequirements'), false)
assert.equal(draft.billOfLadingNo, 'BL-3')
assert.equal(draft.vesselName, '验收船-3')
assert.equal(draft.voyageNo, 'V-3')
assert.equal(draft.estimatedSailingAt, '2026-09-01T08:00:00')
assert.equal(draft.estimatedArrivalAt, '2026-09-20T08:00:00')
const fixtureSource = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const builderSource = fixtureSource.slice(fixtureSource.indexOf('export function oceanBookingDraft'), fixtureSource.indexOf('export function oceanBookingDraftWithoutCustomer'))
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(builderSource, new RegExp(`${key}\\s*:`), `${key} must be emitted by the confirmed booking builder`)
}
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
})
test('shared booking data never sends supplier names without valid supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
test('shared DRAFT permits an omitted customer without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', master)
assert.equal(Object.hasOwn(draft, 'customerId'), false)
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.containerRequirements[0].quantity, 1)
})
test('explicit requirement rows preserve quantity and fixed catalog facts', () => {
const requirements = [
{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements })
assert.deepEqual(draft.containerRequirements, requirements)
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
})
test('supplier names are emitted only with formal supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', master,
{ shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentName, null)
})
test('shared booking draft can omit customer identity without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', {
customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
assert.equal(Object.hasOwn(draft, 'customerId'), false)
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.channelCode, 'SEA-US')
assert.equal(draft.warehouseId, '41')
assert.equal(draft.containers[0].containerType, '40HC')
assert.equal(draft.containers[0].capacityCbm, 76)
assert.equal(draft.containers[0].maxWeightKg, 28000)
})
test('shared booking builder accepts explicit isolated multi-container rows without quantity', () => {
const containers = [
{ containerNo: 'TST-MULTI-01', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: 'TST-MULTI-02', containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { containers })
assert.deepEqual(draft.containers, containers)
assert.equal(draft.containers.some((item) => Object.hasOwn(item, 'quantity')), false)
})
test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => {
test('platform channel producer uses the selected origin warehouse and publishes current-marker resources', async () => {
const calls = []
const context = {
marker: 'ADMIN-RUN-CHANNELS', createdResources: {},
request: async (method, path) => {
calls.push({ method, path })
return { body: { code: '200', data: [
{ channelCode: 'SEA-A', channelName: '海运 A' },
{ channelCode: 'SEA-B', channelName: '海运 B' },
] } }
},
apiSuccess: (_module, _name, response) => response.body.data,
assert: (_module, _name, _response, condition) => assert.equal(condition, true),
}
const context = { marker: 'ADMIN-RUN-CHANNELS', createdResources: {}, request: async (method, path) => {
calls.push({ method, path }); return { body: { code: '200', data: [{ channelCode: 'SEA-A', channelName: '海运 A' }] } }
}, apiSuccess: (_module, _name, response) => response.body.data,
assert: (_module, _name, _response, condition) => assert.equal(condition, true) }
const catalog = await publishPlatformLogisticsChannels(context, '41', 'test')
assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }])
assert.equal(catalog.marker, context.marker)
assert.equal(catalog.originWarehouseId, '41')
assert.deepEqual(catalog.channels.map((value) => value.channelCode), ['SEA-A', 'SEA-B'])
assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog)
})
test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => {
const regions = [
['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse', false],
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean', false],
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate', false],
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment', true],
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'", false],
]
for (const [name, start, end, usesSharedBuilder] of regions) {
const value = readFileSync(new URL(name, import.meta.url), 'utf8')
const region = value.slice(value.indexOf(start), value.indexOf(end))
assert.doesNotMatch(region, /channelName\s*:/, name)
if (usesSharedBuilder) {
assert.match(region, /oceanBookingDraft\(/, `${name} must use the shared formal booking builder`)
} else {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(region, new RegExp(`${field}\\s*:`), `${name} must register ${field}`)
}
}
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
assert.doesNotMatch(container, /data:\s*\{[^}]*channelName\s*:/s)
})
test('ocean booking producers register CONFIRMED data directly without draft submit or confirm transitions', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-loading.mjs', 'phase3-container-loading.mjs',
'phase3-order-splitting.mjs', 'manual-state-loading-pda.mjs', 'manual-state-ocean-trucking.mjs']) {
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
assert.doesNotMatch(source, /ocean-bookings\/[^`'"\\s]+\/(?:submit|confirm)|booking-(?:submit|confirm)/, name)
test('all production booking fixtures use DRAFT release and the shared requirements contract', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-loading.mjs', 'phase3-container-loading.mjs', 'phase3-order-splitting.mjs', 'manual-state-loading-pda.mjs', 'manual-state-ocean-trucking.mjs']) {
const source = read(name)
assert.doesNotMatch(source, /realContainerBookingId|ocean-bookings\/[^`'"\s]+\/(?:submit|confirm)/, name)
assert.match(source, /containerRequirements/, name)
}
})
test('all independent ocean booking producers use the shared master-data contract', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) {
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
assert.match(source, /resolveOceanBookingMaster\(context, MODULE\)/, name)
assert.match(source, /oceanBookingDraft\(context, suffix, master/, name)
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName',
"containerType: '40HC'", 'capacityCbm: 76', 'maxWeightKg: 28000']) assert.match(container, new RegExp(snippet), snippet)
})
@@ -2,114 +2,59 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const adminPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8')
const adminTest = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url), 'utf8')
const adminTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8')
const prototype = readFileSync(new URL('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html', import.meta.url), 'utf8')
const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8')
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8')
const schema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const bookingModule = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8')
const containerLoading = readFileSync(new URL('./phase3-container-loading.mjs', import.meta.url), 'utf8')
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const adminPage = read('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue')
const adminTypes = read('../../../apps/admin-web/src/modules/ocean-bookings/types.ts')
const prototype = read('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html')
const dto = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java')
const openapi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const fixture = read('ocean-booking-fixture.mjs')
const booking = read('ocean-bookings.mjs')
const loading = read('phase3-container-loading.mjs')
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const releaseFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
test('prototype evidence keeps service term freight term and optional customer fields', () => {
test('prototype evidence keeps terms and optional customer while current DRAFT stays editable', () => {
assert.match(prototype, /主要运输条款 Service Term \*<\/label><select><option>CY-CY<\/option>/)
assert.match(prototype, /运费条款 Freight Term \*<\/label><select><option>NINGBO<\/option>/)
assert.match(prototype, /<label>客户\/公司名称<\/label><input id="booking-customer"/)
})
test('Admin keeps source-backed free text terms without invented option dictionaries', () => {
assert.match(adminPage, /v-model="form\.data\.serviceTerm"[^>]*maxlength="32"/)
assert.match(adminPage, /v-model="form\.data\.freightTerm"[^>]*maxlength="32"/)
assert.doesNotMatch(adminPage, /serviceTermOptions|freightTermOptions/)
for (const invented of ['CY-DOOR', 'DOOR-CY', 'DOOR-DOOR', 'PREPAID', 'COLLECT']) {
assert.doesNotMatch(adminPage, new RegExp(invented), `${invented} must not be an invented Admin option`)
}
assert.match(adminTest, /not\.toContain\('serviceTermOptions'\)/)
assert.match(adminTest, /not\.toContain\('freightTermOptions'\)/)
})
test('service and freight terms are required across Java OpenAPI and SQL', () => {
assert.match(dto, /@NotBlank @Size\(max = 32\) String serviceTerm/)
assert.match(dto, /@NotBlank @Size\(max = 32\) String freightTerm/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.match(required, /serviceTerm/)
assert.match(required, /freightTerm/)
assert.match(openapi, /serviceTerm: \{type: string, maxLength: 32, description: Required free-text Service Term snapshot/)
assert.match(openapi, /freightTerm: \{type: string, maxLength: 32, description: Required free-text Freight Term snapshot/)
assert.doesNotMatch(openapi, /(?:serviceTerm|freightTerm): \{[^\n]*nullable: true/)
for (const sql of [schema, unifiedSql]) {
assert.match(sql, /service_term VARCHAR\(32\) NOT NULL/)
assert.match(sql, /freight_term VARCHAR\(32\) NOT NULL/)
}
})
test('customer is optional across Admin DraftData DTO and OpenAPI', () => {
assert.match(adminPage, /label="客户(可选)"[\s\S]*?filterable clearable/)
assert.doesNotMatch(adminPage, /customerId:\s*\[\{\s*required:\s*true/)
assert.match(adminPage, /const editable = computed\(\(\) => isNew\.value \|\| detail\.value\?\.status === 'DRAFT'\)/)
assert.match(adminTypes, /customerId:\s*string \| null;\s*customerName:\s*string \| null/)
assert.match(dto, /record DraftData\(@Positive Long customerId,\s*@Size\(max = 128\) String customerName/)
assert.doesNotMatch(dto, /record DraftData\(@NotNull @Positive Long customerId/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.ok(required, 'BLOCKED_CONTRACT: DraftData.required is missing from OpenAPI')
assert.doesNotMatch(required, /customerId|customerName/)
assert.match(dto, /record DraftData\(@Positive Long customerId/)
assert.match(openapi, /customerId: \{[^\n]*nullable: true/)
assert.match(openapi, /customerName: \{[^\n]*nullable: true/)
})
test('offline-confirmed registration requires transport values while release keeps the frozen five-field snapshot', () => {
const draftType = adminTypes.slice(adminTypes.indexOf('export interface OceanBookingDraftData'), adminTypes.indexOf('export interface OceanBookingResource'))
for (const field of releaseFields) {
assert.match(adminPage, new RegExp(`v-model="form\\.data\\.${field}"`),
`${field} must be entered when registering an offline-confirmed booking`)
assert.match(draftType, new RegExp(`${field}\\??:`), `${field} must belong to OceanBookingDraftData`)
test('DRAFT can be partial but release validates and freezes the five transport facts', () => {
assert.match(openapi, /description: Fields may be incomplete in DRAFT\. RELEASE validates the complete persisted aggregate/)
assert.match(openapi, /ReleaseRequest: \{type: object, required: \[expectedVersion\]/)
for (const field of transportFields) {
assert.match(adminPage, new RegExp(`form\\.data\\.${field}`), field)
assert.match(fixture, new RegExp(`${field}:`), field)
}
assert.match(adminTest, /线下订舱资料已登记并确认/)
assert.match(adminTest, /登记后直接确认/)
const saveRegion = adminPage.slice(adminPage.indexOf('async function save'), adminPage.indexOf('function requireReason'))
assert.doesNotMatch(saveRegion, /data:\s*form\.data/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
for (const field of releaseFields) assert.match(required, new RegExp(field))
assert.match(dto, /@NotBlank @Size\(max = 128\) String billOfLadingNo/)
assert.match(dto, /@NotBlank @Size\(max = 128\) String vesselName/)
assert.match(dto, /@NotBlank @Size\(max = 64\) String voyageNo/)
assert.match(dto, /@NotNull LocalDateTime estimatedSailingAt/)
assert.match(dto, /@NotNull LocalDateTime estimatedArrivalAt/)
const releaseCall = adminPage.slice(adminPage.indexOf('releaseOceanBooking(id.value'), adminPage.indexOf('releaseDialogOpen.value = false'))
for (const field of releaseFields) assert.match(releaseCall, new RegExp(field))
assert.match(booking, /const frozen = transportSnapshot\(completedDraft\?\.oceanBooking \|\| data\)/)
assert.match(booking, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(booking, /Object\.entries\(frozen\)\.every/)
assert.match(booking, /RELEASED 编辑拒绝且零写入/)
})
test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer isolation', () => {
const unassignedFlow = bookingModule.slice(bookingModule.indexOf('async function createConfirmedUnassignedBooking'), bookingModule.indexOf('async function releaseLoadingBooking'))
assert.match(fixture, /oceanBookingDraftWithoutCustomer/)
assert.match(fixture, /delete draft\.customerId[\s\S]*delete draft\.customerName/)
assert.match(fixture, /serviceTerm: 'CY-CY', freightTerm: 'NINGBO'/)
assert.doesNotMatch(fixture, /serviceTerm: 'CY\/CY'|freightTerm: 'PREPAID'/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /未指定客户订舱保持空客户且原子生成一条真实柜/)
assert.match(bookingModule, /按真实订舱号查询未指定客户订舱/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(containerLoading, /source\?\.marker !== context\.marker/)
assert.match(containerLoading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(containerLoading, /id\(item\.accountId\) === tenantId/)
test('booking requirements replace booking-time real container rows', () => {
assert.match(openapi, /containerRequirements:/)
assert.match(openapi, /ContainerRequirementRequest: \{type: object, required: \[containerType, containerKind, quantity, capacityCbm, maxWeightKg\]/)
assert.match(fixture, /containerRequirements/)
assert.doesNotMatch(fixture, /containerNo|realContainerBookingId/)
assert.match(booking, /订舱创建返回 DRAFT 且只持久化柜型需求/)
assert.match(booking, /没有预生成货柜/)
})
test('independent ocean booking release reuses the server-frozen transport snapshot', () => {
assert.match(bookingModule, /estimatedSailingAt: detail\.oceanBooking\.estimatedSailingAt/)
assert.match(bookingModule, /estimatedArrivalAt: detail\.oceanBooking\.estimatedArrivalAt/)
assert.match(bookingModule, /const releaseSnapshotResponse = await request\(context, 'GET', path\)/)
assert.match(bookingModule, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(bookingModule, /const frozenTransportMatches = frozenTransportValid/)
assert.match(bookingModule, /if \(!frozenTransportMatches\) return/)
assert.match(bookingModule, /expectedVersion: version, \.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(bookingModule, /expectedVersion: version, billOfLadingNo: `BL-\$\{suffix\}`/)
assert.doesNotMatch(bookingModule, /estimatedSailingAt: '2026-09-01T08:00:00'/)
test('port link supplies the real number and preserves current-marker customer isolation', () => {
assert.match(loading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(loading, /source\?\.marker !== context\.marker/)
assert.match(loading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(loading, /id\(item\.accountId\) === tenantId/)
assert.doesNotMatch(loading, /realContainerBookingId|MERGED/)
})
test('normal booking producers have only DRAFT and RELEASED lifecycle operations', () => {
assert.doesNotMatch(booking, /CHANGE_PENDING|CANCELLED|\/submit|\/confirm/)
assert.match(booking, /status === 'DRAFT'/)
assert.match(booking, /status === 'RELEASED'/)
})
@@ -489,6 +489,9 @@ export async function run(context) {
context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode
context.createdResources.orders.customerRemark = payload.customerRemark
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
context.createdResources.orders.processStatus = inbound.summary.processStatus
context.createdResources.ocrInbound = { tenantId, inboundId, marker: context.marker }
context.createdResources.testInbound = { tenantId, inboundId, marker: context.marker }
@@ -464,26 +464,6 @@ export async function run(context) {
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
const confirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404'
context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS)
if (!confirmedTaskAbsent) return
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
...frozenTransport, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containers?.some?.((item) => id(item.containerBookingId) === containerId
&& item.containerNo === realContainerNo)
&& transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -23,16 +23,15 @@ const containerLoadingAcceptance = read('phase3-container-loading.mjs')
const truckAcceptance = read('truck-appointments.mjs')
const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md')
test('registration freezes all five transport fields and release reuses the confirmed snapshot', () => {
assert.match(oceanOpenApi, /required: \[expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt\]/)
test('DRAFT stores all five transport fields and release validates the persisted snapshot', () => {
assert.match(oceanOpenApi, /ReleaseRequest: \{type: object, required: \[expectedVersion\]/)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(releaseTypes, new RegExp(`${field}: string`), field)
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
assert.match(releaseView, new RegExp(`detail\\.value\\.${field}`), field)
assert.match(oceanFixture, new RegExp(`${field}:`), field)
}
assert.match(releaseView, /当前订舱缺少已确认运输资料,无法放舱/)
assert.match(releaseView, /放舱不会重新采集或修改这些信息/)
assert.match(oceanAcceptance, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(oceanAcceptance, /RELEASED 编辑拒绝且零写入/)
})
test('overview regression requires workflowStatus and distinguishes 100 percent awaiting review', () => {
@@ -72,8 +71,9 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', (
assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/)
assert.match(truckAttachmentDialog, /上传并关联/)
assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/)
assert.match(truckAcceptance, /删除后回查拖车预约附件列表/)
assert.match(truckAcceptance, /!remainingAttachments\.some/)
assert.match(truckAcceptance, /attachmentType=BILL_OF_LADING/)
assert.match(truckAcceptance, /派车前保留有效 BILL_OF_LADING 提单附件/)
assert.match(truckAcceptance, /缺提单号与文件明确拒绝/)
assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/)
})
@@ -85,14 +85,13 @@ test('existing container number replacement keeps container plan and PDA identit
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
})
test('PDA task opens only after no-number push, real-container merge, release and plan-level confirmation', () => {
test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoadingAcceptance, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(containerLoadingAcceptance, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
@@ -102,7 +101,8 @@ test('PDA task opens only after no-number push, real-container merge, release an
assert.match(containerLoadingAcceptance, /计划级确认同 key 异参返回冲突/)
assert.match(containerLoadingAcceptance, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoadingAcceptance, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(containerLoadingAcceptance, /confirmedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.doesNotMatch(containerLoadingAcceptance, /realContainerBookingId|MERGED/)
})
test('customer public display uses tenantName without a legalName fallback', () => {
@@ -177,6 +177,74 @@ export async function run(context) {
&& Number(assignedLoadingPlan?.version) === 1,
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId)
const orderRemarks = context.createdResources?.orders || {}
context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse,
assignedItem?.customerRemark === orderRemarks.customerRemark
&& assignedItem?.internalRemark === orderRemarks.internalRemark
&& assignedItem?.internalRemark !== orderRemarks.cargoRemark,
`customerRemark=${assignedItem?.customerRemark || 'missing'}, internalRemark=${assignedItem?.internalRemark || 'missing'}, cargoRemark=${orderRemarks.cargoRemark || 'missing'}`, OPTIONS)
if (!assignedItem?.id || !Number.isInteger(Number(assignedItem.version))) return
const warehouseRemark = `${context.marker}-WAREHOUSE-REMARK`
const warehouseRemarkPath = `${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`
const warehouseRemarkBody = Object.freeze({ warehouseRemark,
expectedPlanVersion: Number(assignedLoadingPlan.version), expectedItemVersion: Number(assignedItem.version),
operationId: `${context.marker}-warehouse-remark`.slice(0, 128) })
const remarkResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const remarkedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细保存仓库备注', remarkResponse, '', OPTIONS)
const remarkedItem = remarkedPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注保存后计划与明细版本递增且三备注并存', remarkResponse,
remarkedItem?.warehouseRemark === warehouseRemark
&& Number(remarkedPlan?.version) > Number(assignedLoadingPlan.version)
&& Number(remarkedItem?.version) > Number(assignedItem.version)
&& remarkedItem?.customerRemark === orderRemarks.customerRemark
&& remarkedItem?.internalRemark === orderRemarks.internalRemark,
`planVersion=${remarkedPlan?.version ?? 'missing'}, itemVersion=${remarkedItem?.version ?? 'missing'}`, OPTIONS)
if (!remarkedItem?.id) return
const remarkReplayResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const replayedRemarkPlan = context.apiSuccess(MODULE, '仓库备注相同 operationId 与原请求精确重放', remarkReplayResponse, '', OPTIONS)
const replayedRemarkItem = replayedRemarkPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注精确重放不重复递增版本或历史事实', remarkReplayResponse,
Number(replayedRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(replayedRemarkItem?.version) === Number(remarkedItem?.version)
&& replayedRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${replayedRemarkPlan?.version ?? 'missing'}, itemVersion=${replayedRemarkItem?.version ?? 'missing'}`, OPTIONS)
const staleRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`, {
warehouseRemark: `${warehouseRemark}-STALE`, expectedPlanVersion: Number(assignedLoadingPlan.version),
expectedItemVersion: Number(assignedItem.version), operationId: `${context.marker}-warehouse-remark-stale`.slice(0, 128),
})
context.addStep(MODULE, '仓库备注旧版本 A0409 且零写入', staleRemarkResponse,
staleRemarkResponse.http === 200 && codeOf(staleRemarkResponse) === 'A0409',
`HTTP=${staleRemarkResponse.http}, code=${codeOf(staleRemarkResponse)}`, OPTIONS)
const afterStaleRemarkResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterStaleRemarkPlan = context.apiSuccess(MODULE, '仓库备注旧版本冲突后回查计划', afterStaleRemarkResponse, '', OPTIONS)
const afterStaleRemarkItem = afterStaleRemarkPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '仓库备注旧版本冲突后版本与备注事实零漂移', afterStaleRemarkResponse,
Number(afterStaleRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(afterStaleRemarkItem?.version) === Number(remarkedItem?.version)
&& afterStaleRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${afterStaleRemarkPlan?.version ?? 'missing'}, itemVersion=${afterStaleRemarkItem?.version ?? 'missing'}`, OPTIONS)
const clearRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(remarkedItem.id)}/warehouse-remark`, {
warehouseRemark: ' ', expectedPlanVersion: Number(remarkedPlan.version),
expectedItemVersion: Number(remarkedItem.version), operationId: `${context.marker}-warehouse-remark-clear`.slice(0, 128),
})
const clearedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细空白仓库备注归一化为 null', clearRemarkResponse, '', OPTIONS)
const clearedItem = clearedPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '清空仓库备注不影响客户与内部备注来源', clearRemarkResponse,
clearedItem?.warehouseRemark == null && clearedItem?.customerRemark === orderRemarks.customerRemark
&& clearedItem?.internalRemark === orderRemarks.internalRemark,
`warehouseRemark=${clearedItem?.warehouseRemark ?? 'null'}`, OPTIONS)
if (!clearedItem?.id) return
const restoreRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(clearedItem.id)}/warehouse-remark`, {
warehouseRemark, expectedPlanVersion: Number(clearedPlan.version), expectedItemVersion: Number(clearedItem.version),
operationId: `${context.marker}-warehouse-remark-restore`.slice(0, 128),
})
const persistedRemarkPlan = context.apiSuccess(MODULE, '恢复真实仓库备注供装柜完成事件下发', restoreRemarkResponse, '', OPTIONS)
if (!Number.isInteger(Number(persistedRemarkPlan?.version))) return
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id)
@@ -187,7 +255,7 @@ export async function run(context) {
const pushRequest = Object.freeze({
path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }),
body: Object.freeze({ expectedVersion: Number(persistedRemarkPlan.version) }),
headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
@@ -919,6 +987,7 @@ export async function run(context) {
})
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id))
const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
@@ -933,6 +1002,8 @@ export async function run(context) {
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
&& Number(incrementalPlanItem?.minPackageCount) === 1
&& incrementalPlanItem?.warehouseRemark == null
&& preservedRemarkItem?.warehouseRemark === warehouseRemark
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
&& id(incrementedTask?.containerBookingId) === containerBookingId
@@ -999,7 +1070,8 @@ export async function run(context) {
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
&& Number(adjustedItem?.packageCount) === 2 && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
&& Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark
&& id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan
reassignedPlanItem = adjustedItem
@@ -4,25 +4,21 @@ import test from 'node:test'
const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8')
test('each remainder uses a simulation linked to a confirmed then released booking', () => {
test('each remainder uses a simulation linked to a released booking requirement', () => {
assert.match(source, /POST', CONTAINERS/)
assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/)
assert.match(source, /一次登记直接返回 CONFIRMED/)
assert.match(source, /订舱为 RELEASED 且不预生成真实柜/)
assert.doesNotMatch(source, /ocean-bookings\/\$\{oceanBookingId\}\/(?:submit|confirm)/)
const createPayload = source.slice(source.indexOf('function bookingPayload'), source.indexOf('async function loadOneFragment'))
assert.match(createPayload, /oceanBookingDraft\(\{ marker \}/)
assert.match(createPayload, /customerId: source\.tenantId/)
assert.match(createPayload, /channelCode: source\.channelCode/)
assert.match(source, /ocean-booking-link/)
assert.match(source, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(source, /bookingDetail\.oceanBooking\.status === 'CONFIRMED'/)
assert.match(source, /`\$\{bookingPath\}\/release`/)
assert.match(source, /oceanBookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/)
const releaseStart = source.indexOf('const releaseResponse')
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt',
'estimatedArrivalAt']) assert.match(releaseBlock, new RegExp(`${field}: persistedTransport\\.${field}`))
assert.match(releaseBlock, /expectedVersion: Number\(bookingDetail\.oceanBooking\.version\)/)
assert.doesNotMatch(releaseBlock, /BL-\$\{|SPLIT-VESSEL|SV-\$\{|2026-/)
assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/)
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
@@ -31,25 +27,25 @@ test('each remainder uses a simulation linked to a confirmed then released booki
assert.doesNotMatch(source, /candidates\?warehouseId=/)
})
test('release stops unless creation response and detail expose the same complete transport snapshot', () => {
test('release freezes and detail replays the same complete transport snapshot', () => {
assert.match(source, /const BOOKING_TRANSPORT_FIELDS = \[/)
assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingCreated\?\.oceanBooking\)/)
assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingConfirmed\?\.oceanBooking\)/)
assert.match(source, /const persistedTransport = bookingTransportSnapshot\(bookingDetail\?\.oceanBooking\)/)
assert.match(source, /completeBookingTransport\(createdTransport\)/)
assert.match(source, /sameBookingTransport\(createdTransport, persistedTransport\)/)
assert.match(source, /if \(!transportFrozen\) return null/)
})
test('PDA opens only after merging a registered real container and uses stable plan identity', () => {
test('PDA opens only after linking a released requirement and uses stable plan identity', () => {
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`")
const releaseIndex = source.indexOf("`${bookingPath}/release`")
const releaseIndex = source.indexOf("`/api/platform/v1/ocean-bookings/${oceanBookingId}/release`")
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`")
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(linkIndex >= 0 && releaseIndex > linkIndex && pushIndex > releaseIndex
assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /const realContainerNo = requirement\?\.containerNo/)
assert.match(source, /const realContainerNo = `SPLIT-/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
@@ -161,16 +161,20 @@ async function loadOneFragment(context, state) {
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
if (!oceanBookingId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
const bookingConfirmed = bookingCreated
context.assert(MODULE, `${sourceOrderNo} 一次登记直接返回 CONFIRMED`, bookingCreate,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED'
&& bookingConfirmed?.containers?.length === 1,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerType === '40HC')
const realContainerNo = requirement?.containerNo
if (!requirement?.containerBookingId || !realContainerNo) return null
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse,
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const realContainerNo = `SPLIT-${context.marker.replace(/\D/g, '').slice(-10)}-${sequence}`.slice(0, 64)
if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,
expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
@@ -231,7 +235,7 @@ async function loadOneFragment(context, state) {
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking)
const createdTransport = bookingTransportSnapshot(bookingConfirmed?.oceanBooking)
const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking)
const transportFrozen = completeBookingTransport(createdTransport)
&& completeBookingTransport(persistedTransport)
@@ -241,20 +245,6 @@ async function loadOneFragment(context, state) {
BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!transportFrozen) return null
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
expectedVersion: Number(bookingDetail.oceanBooking.version),
billOfLadingNo: persistedTransport.billOfLadingNo,
vesselName: persistedTransport.vesselName,
voyageNo: persistedTransport.voyageNo,
estimatedSailingAt: persistedTransport.estimatedSailingAt,
estimatedArrivalAt: persistedTransport.estimatedArrivalAt,
reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)
bookingDetailResponse = await context.request('GET', bookingPath)
bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的 RELEASED 订舱`, bookingDetailResponse, '', OPTIONS)
}
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
@@ -91,9 +91,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'无号且未关联订舱仍无 PDA 任务',
'模拟柜原地合并三期 CONFIRMED 真实订舱柜',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'三期订舱 RELEASED 冻结运输快照',
'模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号',
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
@@ -119,10 +118,9 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.containerBookingId \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/)
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
assert.match(value, /RELEASED 订舱回显完整冻结运输快照且无真实柜号[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return/)
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(value, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => {
@@ -236,7 +234,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
})
test('container loading arranges a simulation before linking a confirmed real booking', () => {
test('container loading arranges a simulation before linking a released booking requirement', () => {
const value = source('./phase3-container-loading.mjs')
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
assertOrder(value, [
@@ -255,26 +253,26 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(draft, new RegExp(`${field}:`))
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /confirmedBooking\.containers\.length === 2/)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(value, /confirmedBooking\.containerRequirements\.length === 2/)
assert.match(value, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(value, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.match(value, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(value, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
assert.match(value, /订舱 allocation 原子创建并指向稳定模拟柜身份/)
assert.match(value, /已消费订舱需求拒绝再次关联/)
assertOrder(value, [
"const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,",
"const linkedContainer = context.apiSuccess(MODULE, '首次关联 RELEASED 订舱需求并补真实柜号', linkResponse,",
'if (!linkedContainer) return',
"const afterLinkPlanResponse = await context.request('GET'",
"const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS",
])
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.doesNotMatch(value, /realContainerBookingId|MERGED/)
assert.match(value, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
@@ -284,7 +282,6 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
assert.match(value, /POST', CONTAINERS/)
})
@@ -359,9 +356,8 @@ test('container loading enforces channel isolation and stable PDA identity acros
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'无号且未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
'订舱放舱为 RELEASED 且不预生成真实货柜',
'首次关联 RELEASED 订舱需求并补真实柜号',
'订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
@@ -382,7 +378,7 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(confirmedBooking\?\.oceanBooking\?\.version\),\s*\.\.\.frozenTransport, reason: context\.marker/s)
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)
assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/)
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
assert.doesNotMatch(batch, /containerNo:/)
@@ -489,22 +485,15 @@ test('customs provisions released booking without pre-completing truck', () => {
assert.doesNotMatch(value, /customsAgentName: `代理-/)
})
test('downstream producers prefer the phase3Loading container fixture', () => {
test('downstream container consumers prefer the phase3Loading fixture', () => {
const ocean = source('./ocean-bookings.mjs')
const customs = source('./phase3-customs.mjs')
const serviceCenter = source('./service-center-flow.mjs')
assert.match(ocean, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/)
assert.match(ocean, /ocean-flow-release/)
assert.match(ocean, /existing\?\.marker === context\.marker && existing\?\.finalStatus === 'RELEASED'/)
assert.match(ocean, /const reconciled = await releaseLoadingBooking\(context, containerFlow, suffix\)/)
assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/)
assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/)
assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/)
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/)
assert.match(serviceCenter, /customerId, customerName, carrierId: null/)
@@ -155,3 +155,14 @@ test('default HTTP entry carries independent truck positives and association neg
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/)
})
test('dispatch requires bill of lading number and active BILL_OF_LADING attachment', () => {
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
assert.match(source, /attachmentType=BILL_OF_LADING/)
assert.match(source, /billOfLadingNo:/)
assert.match(source, /DISPATCH 缺提单号与文件明确拒绝/)
assert.match(source, /派车前保留有效 BILL_OF_LADING 提单附件/)
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8')
assert.match(openapi, /DISPATCH requires a nonblank billOfLadingNo/)
assert.match(openapi, /BILL_OF_LADING attachment/)
})
@@ -26,7 +26,7 @@ async function request(context, method, path, body, headers = {}) {
}
async function uploadAttachment(context, appointmentId) {
const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=ACCEPTANCE_EVIDENCE`
const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck.png`)
@@ -89,6 +89,8 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null,
customerId: identity.customerId, customerName: identity.customerName || null,
carrierId: supplierId || null, carrierName: supplierName || null,
billOfLadingNo: Object.hasOwn(associations, 'billOfLadingNo')
? associations.billOfLadingNo : `BL-TRUCK-${suffix}`.slice(0, 128),
fleetContact: '车队联系人', fleetPhone: '13800000004',
driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`,
storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址',
@@ -155,6 +157,21 @@ export async function run(context) {
`HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS)
}
const missingBillCreate = await request(context, 'POST', BASE,
truckAppointmentPayload(context, `${suffix}B`, customerIdentity, 0, { billOfLadingNo: null }))
const missingBill = data(missingBillCreate)
if (missingBill?.id) {
const missingBillSubmit = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/submit`,
{ expectedVersion: Number(missingBill.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-submit`.slice(0, 128) })
const submitted = data(missingBillSubmit)
const rejectedDispatch = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/dispatch`,
{ expectedVersion: Number(submitted?.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-dispatch`.slice(0, 128) })
context.addStep(MODULE, '拖车 DRAFT 可无提单但 DISPATCH 缺提单号与文件明确拒绝', rejectedDispatch,
failure(rejectedDispatch), `HTTP=${rejectedDispatch.http}, code=${codeOf(rejectedDispatch)}`, OPTIONS)
}
const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading]
.find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId))
if (linkedFlow) {
@@ -204,20 +221,14 @@ export async function run(context) {
const upload = await uploadAttachment(context, appointmentId); const attachment = context.apiSuccess(MODULE, '上传本轮拖车预约图片附件', upload, `fileName=${context.marker}-truck.png`, OPTIONS)
const attachmentId = id(attachment?.id)
context.assert(MODULE, '附件引用归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'ACCEPTANCE_EVIDENCE' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS)
context.assert(MODULE, '提单附件归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'BILL_OF_LADING' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS)
const attachments = await request(context, 'GET', `${path}/attachments`); const attachmentList = context.apiSuccess(MODULE, '查询拖车预约附件列表', attachments, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS)
const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'ACCEPTANCE_EVIDENCE', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' })
const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'BILL_OF_LADING', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' })
context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
if (attachmentId) {
const removed = await request(context, 'DELETE', `${path}/attachments/${attachmentId}`)
context.apiSuccess(MODULE, '删除本轮拖车预约附件引用', removed, `attachmentId=${attachmentId}`, OPTIONS)
const afterRemoval = await request(context, 'GET', `${path}/attachments`)
const remainingAttachments = context.apiSuccess(MODULE, '删除后回查拖车预约附件列表', afterRemoval, '', OPTIONS)
context.assert(MODULE, '删除后附件引用不再返回', afterRemoval,
Array.isArray(remainingAttachments) && !remainingAttachments.some((item) => id(item.id) === attachmentId),
`attachmentId=${attachmentId}, remaining=${remainingAttachments?.length ?? 'n/a'}`, OPTIONS)
}
context.assert(MODULE, '派车前保留有效 BILL_OF_LADING 提单附件', attachments,
Boolean(attachmentId) && attachmentList.some((item) => id(item.id) === attachmentId && item.attachmentType === 'BILL_OF_LADING'),
`attachmentId=${attachmentId || 'missing'}`, OPTIONS)
let version = Number(appointment.version)
const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version)
+27 -19
View File
@@ -46,21 +46,18 @@ test('test-environment full acceptance executes warehouse-scoped booking channel
assert.match(fixture, /createdResources\.platformLogisticsChannels/)
})
test('test-environment full acceptance keeps assigned and unassigned customer booking chains', () => {
test('test-environment full acceptance keeps the DRAFT to RELEASED booking chain', () => {
const loadingIndex = adminAcceptance.indexOf("'phase3-container-loading'")
const bookingIndex = adminAcceptance.indexOf("'ocean-bookings'")
assert.ok(loadingIndex >= 0 && bookingIndex > loadingIndex,
'ocean-bookings must run after the assigned-customer container fixture')
const bookingModule = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'ocean-bookings.mjs'), 'utf8')
assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/)
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containers\?\.length === 1/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(bookingModule, /创建订舱 DRAFT(不生成真实柜号\/货柜分配)/)
assert.match(bookingModule, /containerRequirements/)
assert.match(bookingModule, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(bookingModule, /RELEASED 编辑拒绝且零写入/)
assert.doesNotMatch(bookingModule, /realContainerBookingId|CHANGE_PENDING|CANCELLED/)
})
test('test-environment loading chains push without a number and open PDA only after all prerequisites', () => {
@@ -69,32 +66,33 @@ test('test-environment loading chains push without a number and open PDA only af
const bridgeLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(containerLoading, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.doesNotMatch(containerLoading, /realContainerBookingId/)
const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const linkIndex = bridgeLoading.indexOf('模拟柜原地合并三期 CONFIRMED 真实订舱柜')
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
const releaseIndex = bridgeLoading.indexOf('三期订舱 RELEASED 冻结运输快照')
const linkIndex = bridgeLoading.indexOf('模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
assert.ok(pushIndex >= 0 && absentIndex > pushIndex
&& linkIndex > absentIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
&& releaseIndex > absentIndex && linkIndex > releaseIndex && confirmIndex > linkIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/)
assert.match(bridgeLoading, /readiness\?\.readyForPda === true/)
assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
const containerReleaseIndex = containerLoading.indexOf('订舱放舱为 RELEASED 且不预生成真实货柜')
const containerLinkIndex = containerLoading.indexOf('首次关联 RELEASED 订舱需求并补真实柜号')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
assert.ok(containerPushIndex >= 0 && containerLinkIndex > containerPushIndex
&& containerReleaseIndex > containerLinkIndex
&& containerConfirmIndex > containerReleaseIndex
assert.ok(containerPushIndex >= 0 && containerReleaseIndex > containerPushIndex
&& containerLinkIndex > containerReleaseIndex
&& containerConfirmIndex > containerLinkIndex
&& containerPdaIndex > containerConfirmIndex)
assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoading, /readiness\?\.readyForPda === true/)
@@ -102,6 +100,16 @@ test('test-environment loading chains push without a number and open PDA only af
assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
})
test('default loading chain carries the three loading-list remarks contract', () => {
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(adminAcceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'phase3-loading'/s)
assert.match(loading, /装柜明细客户备注与最新内部备注来源精确且不冒用货物备注/)
assert.match(loading, /warehouse-remark/)
assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/)
assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/)
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
})
test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => {
const focused = normalizeModuleOrder([
'merchant-qualification-fixture', 'orders', 'inbound-measurement',