From a9061eca546064052a639c46fd2cbc846e5ea598 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 18 Aug 2026 16:39:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 42 +- .../baiyue/firstleg/dto/ContainerDtos.java | 4 +- .../mapper/ContainerBookingMapper.java | 59 +- .../oceanbooking/dto/OceanBookingDtos.java | 28 +- .../entity/OceanBookingEntity.java | 6 - .../mapper/OceanBookingMapper.java | 5 +- .../mapper/OceanBookingOperationMapper.java | 2 +- .../repository/OceanBookingRepository.java | 12 +- .../service/OceanBookingService.java | 55 +- .../repository/ContainerRepository.java | 18 +- .../firstleg/service/ContainerService.java | 8 +- .../firstleg/service/SmartLoadingService.java | 1 + .../db/phase3-ocean-booking-schema.sql | 36 +- .../main/resources/openapi/firstleg-v1.yaml | 14 +- .../resources/openapi/ocean-bookings-v1.yaml | 32 +- .../ContainerControllerContractTest.java | 7 +- ...ormSmartLoadingControllerContractTest.java | 5 + ...ormOceanBookingControllerContractTest.java | 34 +- .../OceanBookingMapperContractTest.java | 6 +- .../OceanBookingSqlContractTest.java | 30 +- .../service/OceanBookingServiceTest.java | 519 ++++++------------ ...BookingTruckPermissionSqlContractTest.java | 8 +- .../Phase3FinalUnifiedSqlContractTest.java | 6 +- .../service/ContainerServiceTest.java | 126 ++--- .../service/SmartLoadingServiceTest.java | 75 ++- scripts/admin-test-env-modules/README.md | 9 +- ...r-simulation-association-contract.test.mjs | 106 +--- .../loading-list-remarks-contract.test.mjs | 56 ++ .../manual-state-loading-pda.mjs | 28 +- ...ual-state-ocean-trucking-contract.test.mjs | 40 +- .../ocean-booking-channel-contract.test.mjs | 10 +- .../ocean-booking-fixture-contract.test.mjs | 182 ++---- .../ocean-booking-prototype-contract.test.mjs | 139 ++--- scripts/admin-test-env-modules/orders.mjs | 3 + .../phase3-container-loading.mjs | 20 - ...se3-container-regression-contract.test.mjs | 26 +- .../admin-test-env-modules/phase3-loading.mjs | 76 ++- .../phase3-order-splitting-contract.test.mjs | 26 +- .../phase3-order-splitting.mjs | 38 +- .../phase3-producer-chain-contract.test.mjs | 49 +- .../truck-appointments-contract.test.mjs | 11 + .../truck-appointments.mjs | 35 +- .../full-regression-entry-contract.test.mjs | 46 +- 43 files changed, 821 insertions(+), 1217 deletions(-) create mode 100644 scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 646f20b6c..cd2e86341 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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', '运输服务条款快照'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 8c21b3827..040aa7f07 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 2d9ca082c..1ae3ac1da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -107,50 +107,6 @@ public interface ContainerBookingMapper extends BaseMapper parties, - @Valid @Size(max = 20) List 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 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 containers() { return containerRequirements.stream().map(v -> new ContainerRequest(null, v.containerType(), v.containerKind(), v.capacityCbm(), v.maxWeightKg())).toList(); } - } + @Valid @Size(max = 20) List 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index 24430c017..f7cc765f5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 62789c794..d806df7a9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -43,13 +43,12 @@ public interface OceanBookingMapper extends BaseMapper { @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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java index dfa936258..bb66e852b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingOperationMapper.java @@ -19,7 +19,7 @@ public interface OceanBookingOperationMapper extends BaseMapper(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().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id)); - requirements.delete(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); - parties.delete(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id)); - histories.delete(new LambdaQueryWrapper().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id)); return bookings.deleteDraft(id, version); } public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().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().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); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 2281da281..41c6b8164 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -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 parties(long id, List 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 requirements(long id, List 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 response = warehouses.active( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 2e5defce6..60fbe9d8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -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() @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 28dfab495..9ff5ed97b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3fe6f86ec..6728b3ef2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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()); diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 12a13283c..a49ede7f6 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -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), diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 7beb6e457..5ce5d1a7d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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]*$'}} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 289fc7f73..e107dda9a 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -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}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 5cc4db4a6..d94c7e2b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -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]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 854959615..89b4cda7e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -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}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index e98397e74..df43c1be5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 66ef25a57..050195e76 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -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"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index fb4510063..9f2df34e0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -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 { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 292c7b499..8b30ace22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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 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 = 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 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 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 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 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 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 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> 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 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 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> 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 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 parties = List.of( - new PartyRequest("SHIPPER", "发货人", null, null, null), - new PartyRequest("CONSIGNEE", "收货人", null, null, null), - new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); - List 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 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 stubSuccessfulCreation() { + private AtomicReference stubCreatedDraft() { AtomicReference 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 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); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java index 2fdc699c7..2e1c0f81a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java @@ -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 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 TRUCK_PERMISSIONS = Set.of( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index a8ba53d71..b8d4f53c8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -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'"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 07ff38cf0..6b2b1d334 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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 activeWarehouse(long warehouseId, String type, String name) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 7612471c5..b716d02c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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 updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); + } + @Test void preservesDownstreamCode() { stubAssignment(); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7e12fdec3..0f2c5e531 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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`,不得回退历史资源。 diff --git a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs index 7aca70ebb..aba78fffd 100644 --- a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs new file mode 100644 index 000000000..1dd225cb0 --- /dev/null +++ b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index e7bb8139e..96eaed373 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -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 } diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index 7d3cd63d0..1caffde46 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -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/) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs index c5ff0cf13..a85575731 100644 --- a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs @@ -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\(['"] { @@ -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/) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 9c91b730c..1eeb5857a 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -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) -}) diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs index 2a7f72ff9..114d4e677 100644 --- a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -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>