优化
This commit is contained in:
@@ -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;
|
||||
|
||||
+2
-3
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
-9
@@ -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); }
|
||||
}
|
||||
|
||||
+4
-51
@@ -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}}}
|
||||
|
||||
+5
-2
@@ -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]")
|
||||
|
||||
+5
@@ -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}")
|
||||
|
||||
+8
-26
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-17
@@ -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 {
|
||||
|
||||
+169
-350
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user