diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 84e695663..5628b33dd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8664,7 +8664,7 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', - status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', @@ -8688,7 +8688,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间', pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间', change_reason VARCHAR(1000) NULL COMMENT '船期变更原因', - failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因', cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因', remark VARCHAR(1000) NULL COMMENT '平台内部备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', @@ -8704,7 +8703,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN - ('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')), + ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')), 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 @@ -8806,11 +8805,9 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改'; END IF; IF OLD.status <> NEW.status AND NOT ( - (OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')) - OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) + (OLD.status='DRAFT' AND NEW.status='CONFIRMED') OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')) - OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN + OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法'; END IF; END$$ @@ -8848,7 +8845,7 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱'; END IF; IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN - ('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN + ('CONFIRMED','CHANGE_PENDING') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次'; END IF; END$$ @@ -8875,9 +8872,6 @@ BEGIN WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit' - WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm' WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel' WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change' @@ -8900,9 +8894,6 @@ INSERT INTO iam_permission 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), - (5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0), - (5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0), - (5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'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), @@ -8916,6 +8907,9 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); + USE baiyue_firstleg; @@ -12869,7 +12863,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'failure_reason', '订舱失败原因'); 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', '待确认变更预计到港时间'); @@ -12881,7 +12874,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平 CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消'); +CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_at', '订舱记录最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by_name', '最后修改操作人姓名快照'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java index 50e22037f..0c208dc1e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java @@ -29,7 +29,7 @@ public interface CustomerTransitStatisticsMapper { JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id WHERE c.actual_sailing_at IS NOT NULL AND c.actual_warehouse_arrival_at IS NULL - AND b.status NOT IN ('CANCELLED', 'FAILED') + AND b.status <> 'CANCELLED' AND NOT EXISTS ( SELECT 1 FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id @@ -43,7 +43,7 @@ public interface CustomerTransitStatisticsMapper { JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 WHERE c.actual_sailing_at IS NOT NULL AND c.actual_warehouse_arrival_at IS NULL - AND b.status NOT IN ('CANCELLED', 'FAILED') + AND b.status <> 'CANCELLED' AND i.account_id IS NOT NULL AND i.account_id > 0 ) x WHERE x.customer_id = #{customerId} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index fa6914065..5acefdc3b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -36,23 +36,11 @@ public class PlatformOceanBookingController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); } @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')") - @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false) + @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="登记已确认海运订舱", captureBefore=false) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } - @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") - @BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") - public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')") - @BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱") - public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')") - @BusinessAudit(event="OCEAN_BOOKING_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认海运订舱") - public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); } @PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')") @BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱") public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')") - @BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败") - public ApiResponse fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); } @PostMapping("/{oceanBookingId}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:cancel')") @BusinessAudit(event="OCEAN_BOOKING_CANCEL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="取消海运订舱") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(oceanBookingId, user.userId(), user.username(), key, request)); } @@ -62,8 +50,5 @@ public class PlatformOceanBookingController { @PostMapping("/{oceanBookingId}/change-confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") @BusinessAudit(event="OCEAN_BOOKING_CHANGE_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认变更船期") public ApiResponse confirmChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmChange(oceanBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") - @BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败") - public ApiResponse failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); } private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java index 8ad0e7374..24aabee24 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/domain/OceanBookingStatus.java @@ -2,10 +2,8 @@ package com.baiyue.firstleg.oceanbooking.domain; public enum OceanBookingStatus { DRAFT, - SUBMITTED, CONFIRMED, RELEASED, - FAILED, CHANGE_PENDING, CANCELLED } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 18caf303a..92fdc55a7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -44,13 +44,13 @@ public final class OceanBookingDtos { @NotBlank @Size(max = 64) String channelCode, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 128) String warehouseName, - @Size(max = 128) String billOfLadingNo, + @NotBlank @Size(max = 128) String billOfLadingNo, @Positive Long shippingCompanyId, @Size(max = 128) String shippingCompanyName, @Positive Long bookingAgentId, @Size(max = 128) String bookingAgentName, - @Size(max = 128) String vesselName, - @Size(max = 64) String voyageNo, + @NotBlank @Size(max = 128) String vesselName, + @NotBlank @Size(max = 64) String voyageNo, @Size(max = 32) String departurePortCode, @Size(max = 128) String departurePortName, @Size(max = 32) String destinationPortCode, @@ -58,16 +58,14 @@ public final class OceanBookingDtos { @Size(max = 128) String placeOfDelivery, @NotBlank @Size(max = 32) String serviceTerm, @NotBlank @Size(max = 32) String freightTerm, - LocalDateTime estimatedSailingAt, - LocalDateTime estimatedArrivalAt, + @NotNull LocalDateTime estimatedSailingAt, + @NotNull LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, @Valid @NotEmpty @Size(max = 3) List parties, @Valid @NotEmpty @Size(max = 20) List containerRequirements) {} public record CreateRequest(@NotNull @Min(0) Integer expectedVersion, @Valid @NotNull DraftData data) {} - public record UpdateDraftRequest(@NotNull @Min(0) Integer expectedVersion, - @Valid @NotNull DraftData data) {} public record ActionRequest(@NotNull @Min(0) Integer expectedVersion, @Size(max = 1000) String reason) {} public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion, 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 07a8c84c3..b1bc04e9c 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 @@ -45,7 +45,6 @@ public class OceanBookingEntity { private LocalDateTime pendingEstimatedSailingAt; private LocalDateTime pendingEstimatedArrivalAt; private String changeReason; - private String failureReason; private String cancellationReason; private String remark; private Long createdBy; 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 da0e9ff79..5de26857e 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 @@ -30,31 +30,13 @@ public interface OceanBookingMapper extends BaseMapper { Page selectPage(Page page, @Param("query") Query query); @Update(""" - UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName}, - channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName}, - bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId}, - shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId}, - booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo}, - departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName}, - destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName}, - place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm}, - estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt}, - remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{value.id} AND status='DRAFT' AND version=#{expectedVersion} - """) - int updateDraft(@Param("value") OceanBookingEntity value, @Param("expectedVersion") int expectedVersion, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status=#{toStatus},failure_reason=#{failureReason}, + UPDATE fl_ocean_booking SET status=#{toStatus}, cancellation_reason=#{cancellationReason},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("failureReason") String failureReason, - @Param("cancellationReason") String cancellationReason, + @Param("toStatus") String toStatus, @Param("cancellationReason") String cancellationReason, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index beb615403..10c17d410 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -28,12 +28,9 @@ public class OceanBookingRepository { } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } - public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) { - return bookings.updateDraft(value, version, operatorId, operatorName); - } - public int transition(long id, String from, String to, String failureReason, String cancellationReason, + public int transition(long id, String from, String to, String cancellationReason, int version, long operatorId, String operatorName) { - return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName); + return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName); } public int release(long id, ReleaseRequest request, long operatorId, String operatorName) { return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(), 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 a403cd69f..c01a5ffda 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 @@ -67,6 +67,7 @@ public class OceanBookingService { public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { if (request.expectedVersion() != 0) throw concurrent(); validateDraftStructure(request.data()); + validateRange(request.data().estimatedSailingAt(), request.data().estimatedArrivalAt()); String action = "CREATE"; String hash = hash(action, null, request); Detail replay = claimOrReplay(null, operatorId, action, key, hash); @@ -75,6 +76,8 @@ public class OceanBookingService { OceanBookingEntity value = new OceanBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("OB" + value.getId()); + // Child-row triggers still require DRAFT while the aggregate is assembled. + // The status transition is in the same transaction, so callers only observe CONFIRMED. value.setStatus(OceanBookingStatus.DRAFT.name()); copy(value, request.data(), channel.channelName()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); @@ -82,46 +85,16 @@ public class OceanBookingService { repository.insert(value); repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties())); repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements())); - addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, value, operatorId, operatorName); - return complete(value.getId(), operatorId, action, key, detail(value.getId())); + if (repository.transition(value.getId(), OceanBookingStatus.DRAFT.name(), + OceanBookingStatus.CONFIRMED.name(), null, 0, operatorId, operatorName) != 1) { + throw concurrent(); + } + OceanBookingEntity confirmed = required(value.getId()); + addHistory(value.getId(), OceanBookingStatus.DRAFT.name(), OceanBookingStatus.CONFIRMED.name(), action, + null, value, confirmed, operatorId, operatorName); + return complete(value.getId(), operatorId, action, key, detail(confirmed)); } - @Transactional - public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key, - UpdateDraftRequest request) { - validateDraftStructure(request.data()); - String action = "UPDATE_DRAFT"; - Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.DRAFT); - ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); - OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data(), channel.channelName()); - if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); - repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties())); - repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements())); - return complete(oceanBookingId, operatorId, action, key, detail(oceanBookingId)); - } - - @Transactional public Detail submit(long id, long operatorId, String name, String key, ActionRequest request) { - return transition(id, operatorId, name, key, "SUBMIT", OceanBookingStatus.DRAFT, - OceanBookingStatus.SUBMITTED, request, false); - } - @Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) { - String action = "CONFIRM"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.SUBMITTED); - requireActiveChannel(before.getWarehouseId(), before.getChannelCode()); - validateConfirmation(before, repository.requirements(id)); - if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), - null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, - operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } @Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) { validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt()); String action = "RELEASE"; @@ -129,6 +102,7 @@ public class OceanBookingService { if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); requireStatus(before, OceanBookingStatus.CONFIRMED); + validateRegisteredTransport(before, request); if (repository.release(id, request, operatorId, name) != 1) throw concurrent(); repository.syncReleasedTransport(id, request, operatorId, name); repository.allocations(id).forEach(allocation -> @@ -139,28 +113,6 @@ public class OceanBookingService { return complete(id, operatorId, action, key, detail(after)); } - @Transactional - public Detail fail(long id, long operatorId, String name, String key, ActionRequest request) { - if (!StringUtils.hasText(request.reason())) throw badRequest("失败原因不能为空"); - String action = "FAIL"; - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, OceanBookingStatus.SUBMITTED); - if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.FAILED.name(), trim(request.reason()), null, - request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, OceanBookingStatus.SUBMITTED.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } - - @Transactional - public Detail failChange(long id, long operatorId, String name, String key, ActionRequest request) { - if (!StringUtils.hasText(request.reason())) throw badRequest("改船失败原因不能为空"); - return transition(id, operatorId, name, key, "FAIL_CHANGE", OceanBookingStatus.CHANGE_PENDING, - OceanBookingStatus.FAILED, request, true); - } - @Transactional public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { if (!StringUtils.hasText(request.reason())) throw badRequest("取消原因不能为空"); @@ -172,7 +124,7 @@ public class OceanBookingService { if (from == OceanBookingStatus.RELEASED || from == OceanBookingStatus.CANCELLED) { throw conflict("订舱已越过不可逆节点或已经取消"); } - if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(), null, + if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(), trim(request.reason()), request.expectedVersion(), operatorId, name) != 1) throw concurrent(); OceanBookingEntity after = required(id); addHistory(id, from.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name); @@ -211,20 +163,6 @@ public class OceanBookingService { return complete(id, operatorId, action, key, detail(after)); } - private Detail transition(long id, long operatorId, String name, String key, String action, - OceanBookingStatus from, OceanBookingStatus to, ActionRequest request, - boolean failure) { - Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); - if (replay != null) return replay; - OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); - requireStatus(before, from); - if (from == OceanBookingStatus.DRAFT && to == OceanBookingStatus.SUBMITTED) validateSubmission(id); - if (repository.transition(id, from.name(), to.name(), failure ? trim(request.reason()) : null, null, - request.expectedVersion(), operatorId, name) != 1) throw concurrent(); - OceanBookingEntity after = required(id); - addHistory(id, from.name(), to.name(), action, request.reason(), before, after, operatorId, name); - return complete(id, operatorId, action, key, detail(after)); - } private Detail claimOrReplay(Long id, long operatorId, String action, String key, String hash) { validateKey(key); @@ -266,10 +204,10 @@ public class OceanBookingService { if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) { throw badRequest("Service Term和Freight Term不能为空"); } - if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null - || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) - || StringUtils.hasText(data.voyageNo())) { - throw badRequest("真实运输数据请在确认放舱时录入"); + if (!StringUtils.hasText(data.billOfLadingNo()) || !StringUtils.hasText(data.vesselName()) + || !StringUtils.hasText(data.voyageNo()) || data.estimatedSailingAt() == null + || data.estimatedArrivalAt() == null) { + throw badRequest("提单号、船名、航次、ETD和ETA不能为空"); } Set types = new HashSet<>(); for (PartyRequest party : data.parties()) { @@ -278,14 +216,16 @@ public class OceanBookingService { } if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY"); } - private void validateSubmission(long id) { - Set types = repository.parties(id).stream().map(OceanBookingPartyEntity::getPartyType) - .collect(java.util.stream.Collectors.toSet()); - if (!types.equals(PARTY_TYPES) || repository.requirements(id).isEmpty()) { - throw conflict("订舱方或箱型需求不完整,不能提交"); + private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } + private static void validateRegisteredTransport(OceanBookingEntity booking, ReleaseRequest request) { + if (!java.util.Objects.equals(trim(booking.getBillOfLadingNo()), trim(request.billOfLadingNo())) + || !java.util.Objects.equals(trim(booking.getVesselName()), trim(request.vesselName())) + || !java.util.Objects.equals(trim(booking.getVoyageNo()), trim(request.voyageNo())) + || !java.util.Objects.equals(booking.getEstimatedSailingAt(), request.estimatedSailingAt()) + || !java.util.Objects.equals(booking.getEstimatedArrivalAt(), request.estimatedArrivalAt())) { + throw conflict("放舱运输字段必须与已登记的提单、船名、航次、ETD和ETA一致"); } } - private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); String normalized = normalizeChannel(channelCode); @@ -312,26 +252,15 @@ public class OceanBookingService { v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); - v.setBillOfLadingNo(null); + v.setBillOfLadingNo(d.billOfLadingNo().trim()); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName())); - v.setVesselName(null); v.setVoyageNo(null); + v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim()); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm())); - v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark())); - } - - private static void validateConfirmation(OceanBookingEntity booking, - List requirements) { - if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName()) - || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getWarehouseName())) { - throw conflict("订舱缺少渠道或国内中转仓快照,不能确认"); - } - if (requirements.isEmpty() || requirements.stream().anyMatch(requirement -> - requirement.getCapacityCbm() == null || requirement.getMaxWeightKg() == null)) { - throw conflict("箱型需求缺少容量或最大承重快照,不能确认"); - } + v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); + v.setRemark(trim(d.remark())); } private static List parties(long id, List requests) { 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(); } 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 c07ecd080..8d5221b64 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -4,7 +4,7 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', - status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照', @@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间', pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间', change_reason VARCHAR(1000) NULL COMMENT '船期变更原因', - failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因', cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因', remark VARCHAR(1000) NULL COMMENT '平台内部备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', @@ -48,7 +47,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN - ('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')), + ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')), 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 @@ -150,11 +149,9 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改'; END IF; IF OLD.status <> NEW.status AND NOT ( - (OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')) - OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) + (OLD.status='DRAFT' AND NEW.status='CONFIRMED') OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')) - OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')) - OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN + OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法'; END IF; END$$ @@ -192,7 +189,7 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱'; END IF; IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN - ('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN + ('CONFIRMED','CHANGE_PENDING') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次'; END IF; END$$ @@ -219,9 +216,6 @@ BEGIN WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit' - WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm' WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel' WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change' @@ -244,9 +238,6 @@ INSERT INTO iam_permission 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), - (5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0), - (5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0), - (5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'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), @@ -263,5 +254,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102141; UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 487373f50..f965b590a 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -4,14 +4,14 @@ paths: /api/platform/v1/ocean-bookings/channels: get: summary: List logistics channels from the current ACTIVE price book for an origin warehouse - description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side on draft save.' + description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side when the offline-confirmed booking is registered.' parameters: - {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}} /api/platform/v1/ocean-bookings: get: summary: List only real confirmed booking records - description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT, SUBMITTED and FAILED are excluded even when a status query is supplied.' + description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid.' parameters: - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: status, in: query, schema: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}} @@ -20,20 +20,15 @@ paths: - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} responses: {'200': {description: Operational real booking page}} post: - summary: Create an ocean booking draft with immutable channel and warehouse snapshots + summary: Register an offline-confirmed ocean booking with complete transport snapshots + description: 'Requires platform:firstleg:ocean-booking:create. The booking has already been completed offline, so registration persists it directly as CONFIRMED. RELEASED remains a separate later action.' parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}} - responses: {'200': {description: Draft detail}} - /api/platform/v1/ocean-bookings/{oceanBookingId}/confirm: - post: - summary: Confirm a real booked container record without creating internal cabinets - parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} - responses: {'200': {description: CONFIRMED real booking detail; internal cabinets are linked explicitly from the container API}} + responses: {'200': {description: CONFIRMED real booking detail}} /api/platform/v1/ocean-bookings/{oceanBookingId}/release: post: - summary: Confirm released transport data and synchronize associated cabinet transport snapshots - description: Does not create cabinets, change channels, replace cabinetNo, or recreate loading plans. + summary: Release an already registered booking and synchronize associated cabinet transport snapshots + description: 'The transport fields were registered at create time. The request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected. This action does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.' parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} @@ -50,7 +45,7 @@ components: data: {$ref: '#/components/schemas/DraftData'} DraftData: type: object - required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements] + required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements] properties: customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.} customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.} @@ -59,11 +54,11 @@ components: warehouseName: {type: string, maxLength: 128} serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.} freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.} - billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} - estimatedSailingAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release} - estimatedArrivalAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release} + billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.} + vesselName: {type: string, maxLength: 128, description: Required offline-confirmed vessel snapshot.} + voyageNo: {type: string, maxLength: 64, description: Required offline-confirmed voyage snapshot.} + estimatedSailingAt: {type: string, format: date-time, description: Required offline-confirmed ETD snapshot.} + estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA snapshot; cannot be earlier than ETD.} parties: {type: array, minItems: 3, items: {type: object}} containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}} ContainerRequirement: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 179db670f..33463262f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -30,7 +30,7 @@ class CustomerTransitStatisticsContractTest { .findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value(); var statement = String.join("\n", sql); assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL", - "status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id", + "status <> 'CANCELLED'", "DISTINCT x.customer_id, x.container_booking_id", "b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS", "x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0", "LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL"); 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 128cbe312..b1ec85cbc 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,15 +23,10 @@ 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("updateDraft", "platform:firstleg:ocean-booking:edit"), - Map.entry("submit", "platform:firstleg:ocean-booking:submit"), - Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"), - Map.entry("fail", "platform:firstleg:ocean-booking:confirm"), 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("failChange", "platform:firstleg:ocean-booking:change")); + Map.entry("confirmChange", "platform:firstleg:ocean-booking:change")); permissions.forEach((name, permission) -> { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) @@ -56,13 +51,19 @@ 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, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]") + .contains("required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]") + .contains("registration persists it 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, SUBMITTED and FAILED are excluded") + .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"); } 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 fa744e503..a177011a8 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 @@ -30,9 +30,10 @@ class OceanBookingMapperContractTest { .flatMap(annotation -> Arrays.stream(annotation.value())) .reduce("", (left, right) -> left + '\n' + right); assertThat(sql) - .contains("status='DRAFT' AND version=#{expectedVersion}") .contains("status=#{fromStatus} AND version=#{expectedVersion}") .contains("status='CONFIRMED' AND version=#{expectedVersion}") - .contains("status='CHANGE_PENDING' AND version=#{expectedVersion}"); + .contains("status='CHANGE_PENDING' AND version=#{expectedVersion}") + .doesNotContain("SUBMITTED") + .doesNotContain("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 f37a3a951..99462012f 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 @@ -32,9 +32,9 @@ class OceanBookingSqlContractTest { @Test void enforcesStateIdempotencyAndAuditContracts() throws Exception { assertThat(sql()) - .contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')") + .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','FAILED','CANCELLED')") + .contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED')") .contains("OLD.status IN ('RELEASED','CANCELLED')") .contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)") .contains("request_hash CHAR(64) NOT NULL") @@ -47,9 +47,6 @@ 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:submit") - .contains("platform:firstleg:ocean-booking:confirm") .contains("platform:firstleg:ocean-booking:release") .contains("platform:firstleg:ocean-booking:cancel") .contains("platform:firstleg:ocean-booking:change") 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 23a831a66..be53482d9 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 @@ -10,7 +10,6 @@ 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.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest; import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -27,6 +26,7 @@ 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; @@ -55,27 +55,8 @@ class OceanBookingServiceTest { when(repository.histories(anyLong())).thenReturn(List.of()); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1); - } - - @Test - void advancesDraftToSubmittedWithVersionAndHistory() { - when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3)); - var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER"); - var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE"); - var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY"); - when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify)); - when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity())); - when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1); - - Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交")); - - assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED"); - ArgumentCaptor history = - ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class); - verify(repository).insert(history.capture()); - assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT"); - assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED"); - assertThat(history.getValue().getAction()).isEqualTo("SUBMIT"); + when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0), + anyLong(), anyString())).thenReturn(1); } @Test @@ -86,12 +67,16 @@ class OceanBookingServiceTest { new ActionRequest(4, "错误"))).isInstanceOf(BizException.class) .hasMessageContaining("不可逆"); - verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), anyInt(), anyLong(), anyString()); } @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); @@ -107,6 +92,25 @@ class OceanBookingServiceTest { 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); @@ -126,27 +130,11 @@ class OceanBookingServiceTest { assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW"); } - @Test - void replaysCompletedRequestBeforeVersionValidation() throws Exception { - OceanBookingEntity submitted = booking("SUBMITTED", 3); - Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of()); - OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); - operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS"); - operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay)); - when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false); - when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation); - - Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交")); - - assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED"); - verify(repository, never()).find(anyLong()); - } - @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("仅支持"); + .isInstanceOf(BizException.class).hasMessageContaining("状态"); } verify(repository, never()).page(any()); } @@ -176,19 +164,33 @@ class OceanBookingServiceTest { } @Test - void freezesAuthoritativeChannelNameWhenCreatingDraft() { + void registersOfflineBookingDirectlyAsConfirmedWithCompleteTransportSnapshot() { ChannelRow channel = channel("SEA-US", "价格簿美西海运"); when(priceBooks.activeVersionId()).thenReturn(51L); when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); - 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 -> inserted.get()); + 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().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"); } @Test @@ -196,10 +198,7 @@ class OceanBookingServiceTest { ChannelRow channel = channel("SEA-US", "价格簿美西海运"); when(priceBooks.activeVersionId()).thenReturn(51L); when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); - 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 -> inserted.get()); + stubSuccessfulCreation(); Detail result = service.create(9L, "admin", "create-without-customer", new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO"))); @@ -234,16 +233,48 @@ class OceanBookingServiceTest { verifyNoInteractions(priceBooks); } + @Test + void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() { + DraftData source = draft(); + DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(), + source.warehouseId(), source.warehouseName(), 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.containerRequirements()); + + 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(), source.warehouseName(), 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.containerRequirements()); + + 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)); - 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 -> inserted.get()); + stubSuccessfulCreation(); Detail created = service.create(9L, "admin", "create-replay-channel", request); @@ -260,40 +291,6 @@ class OceanBookingServiceTest { verify(priceBooks, times(1)).activeVersionId(); } - @Test - void replaysDraftUpdateAfterChannelIsNoLongerActive() throws Exception { - UpdateDraftRequest request = new UpdateDraftRequest(5, draft()); - Detail expected = new Detail(booking("DRAFT", 6), List.of(), List.of(), List.of(), List.of()); - OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); - operation.setRequestHash(hashForUpdate(request)); operation.setStatus("SUCCESS"); - operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected)); - when(repository.claimOperation(eq(1L), eq(9L), eq("UPDATE_DRAFT"), eq("update-replay-channel"), anyString())) - .thenReturn(false); - when(repository.operation(9L, "UPDATE_DRAFT", "update-replay-channel")).thenReturn(operation); - - Detail replay = service.updateDraft(1L, 9L, "admin", "update-replay-channel", request); - - assertThat(replay.oceanBooking().getVersion()).isEqualTo(6); - verifyNoInteractions(priceBooks); - verify(repository, never()).find(anyLong()); - } - - @Test - void rejectsConfirmationWhenFrozenChannelIsNoLongerActive() { - OceanBookingEntity submitted = booking("SUBMITTED", 3); - submitted.setWarehouseId(701L); submitted.setWarehouseName("深圳仓"); submitted.setChannelName("美西海运"); - OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setCapacityCbm(java.math.BigDecimal.ONE); requirement.setMaxWeightKg(java.math.BigDecimal.ONE); - when(repository.find(1L)).thenReturn(submitted); - when(repository.requirements(1L)).thenReturn(List.of(requirement)); - when(priceBooks.activeVersionId()).thenReturn(null); - - assertThatThrownBy(() -> service.confirm(1L, 9L, "admin", "confirm-channel", new ActionRequest(3, "确认"))) - .isInstanceOf(BizException.class).hasMessageContaining("ACTIVE"); - - verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); - } - 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; @@ -310,15 +307,24 @@ class OceanBookingServiceTest { new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); List requirements = List.of( new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1)); - return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", null, - 81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶", - null, serviceTerm, freightTerm, null, null, null, parties, requirements); + 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, requirements); } - private String hashForSubmit() throws Exception { - var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); - method.setAccessible(true); - return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交")); + private AtomicReference stubSuccessfulCreation() { + 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; + }); + return inserted; } private String hashForCreate(CreateRequest request) throws Exception { @@ -327,12 +333,6 @@ class OceanBookingServiceTest { return (String) method.invoke(service, "CREATE", (Long) null, request); } - private String hashForUpdate(UpdateDraftRequest request) throws Exception { - var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); - method.setAccessible(true); - return (String) method.invoke(service, "UPDATE_DRAFT", 1L, request); - } - 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("客户"); 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 8f4556b2a..0dda3408e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java @@ -25,9 +25,6 @@ class Phase3BookingTruckPermissionSqlContractTest { private static final Set OCEAN_CONTROLLER_PERMISSIONS = Set.of( "platform:firstleg:ocean-booking:list", "platform:firstleg:ocean-booking:create", - "platform:firstleg:ocean-booking:edit", - "platform:firstleg:ocean-booking:submit", - "platform:firstleg:ocean-booking:confirm", "platform:firstleg:ocean-booking:release", "platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change"); @@ -35,9 +32,6 @@ class Phase3BookingTruckPermissionSqlContractTest { private static final Set OCEAN_SQL_PERMISSIONS = Set.of( "platform:firstleg:ocean-booking:list", "platform:firstleg:ocean-booking:create", - "platform:firstleg:ocean-booking:edit", - "platform:firstleg:ocean-booking:submit", - "platform:firstleg:ocean-booking:confirm", "platform:firstleg:ocean-booking:release", "platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change", 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 a669bfe65..b7233529c 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 @@ -18,7 +18,7 @@ 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, SUBMITTED and FAILED are excluded/) + 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/) }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java new file mode 100644 index 000000000..975003125 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot; +import com.baiyue.warehouse.service.SupplierService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/warehouse/v1/suppliers") +public class InternalSupplierController { + @Resource private SupplierService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expected; + @PostConstruct void initializeToken() { expected = internalToken.getBytes(StandardCharsets.UTF_8); } + @GetMapping("/{supplierId}") + public ApiResponse get(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable long supplierId, @RequestParam(defaultValue="false") boolean requireActive, + @RequestParam(required=false) String category, @RequestParam(required=false) String scope) { + if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + if (category != null && !category.matches("BOOKING|CUSTOMS|TRUCKING|CERTIFICATE")) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效"); + if (scope != null && !scope.matches("DOMESTIC|INTERNATIONAL")) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围无效"); + if (scope != null && !"BOOKING".equals(category)) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于BOOKING"); + return ApiResponse.success(service.internalSnapshot(supplierId, requireActive, category, scope)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java index c6fbc696c..c96a682af 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java @@ -42,4 +42,5 @@ public final class SupplierDtos { public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {} public record Stats(long booking, long customs, long trucking, long certificate) {} + public record InternalSnapshot(long supplierId, String name, String category, String scope, String status) {} } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java index 366047af0..0403580c7 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java @@ -17,7 +17,11 @@ public class SupplierService { private static final long PUBLIC_TENANT_ID = 0L; @Resource private SupplierRepository repository; - public PageResult page(PageQuery query) { return repository.page(PUBLIC_TENANT_ID, query); } + public PageResult page(PageQuery query) { + if (query.category() == null && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商"); + if (query.category() != null && !"BOOKING".equals(query.category()) && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商"); + return repository.page(PUBLIC_TENANT_ID, query); + } public SupplierEntity detail(long id) { return owned(id); } public Stats stats() { return new Stats(repository.count(0, "BOOKING"), repository.count(0, "CUSTOMS"), repository.count(0, "TRUCKING"), repository.count(0, "CERTIFICATE")); } @@ -40,7 +44,15 @@ public class SupplierService { @Transactional @BusinessAudit(event = "SUPPLIER_STATUS_CHANGE", resourceType = "SUPPLIER", resourceId = "#id", description = "启停供应商") - public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); e.setStatus(request.status()); repository.save(e); } + public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); if (!request.status().equals(e.getStatus())) { e.setStatus(request.status()); repository.save(e); } } + + public InternalSnapshot internalSnapshot(long id, boolean requireActive, String category, String scope) { + SupplierEntity e = owned(id); + if (requireActive && !"ACTIVE".equals(e.getStatus())) throw new BizException(ResultCode.CONFLICT, "供应商未启用"); + if (category != null && !category.equals(e.getCategory())) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别不匹配"); + if (scope != null && (!"BOOKING".equals(e.getCategory()) || !scope.equals(e.getScope()))) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围不匹配"); + return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getScope(), e.getStatus()); + } private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; } private void ensureName(String name, Long id) { if (repository.existsName(0L, name, id)) throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); } diff --git a/warehouse/src/main/resources/db/warehouse-supplier.sql b/warehouse/src/main/resources/db/warehouse-supplier.sql index 03465d5e3..337e7e451 100644 --- a/warehouse/src/main/resources/db/warehouse-supplier.sql +++ b/warehouse/src/main/resources/db/warehouse-supplier.sql @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( UNIQUE KEY uk_wh_supplier_name (tenant_id,name), KEY idx_wh_supplier_filter (tenant_id,category,status,region), CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')), + CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0), CONSTRAINT chk_wh_supplier_scope CHECK ((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)), CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')), CONSTRAINT chk_wh_supplier_settlement CHECK (settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')) @@ -71,6 +72,7 @@ BEGIN END$$ DELIMITER ; CALL supplier_add_check_if_missing('chk_wh_supplier_category', "category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')"); +CALL supplier_add_check_if_missing('chk_wh_supplier_tenant', "tenant_id = 0"); CALL supplier_add_check_if_missing('chk_wh_supplier_scope', "(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)"); CALL supplier_add_check_if_missing('chk_wh_supplier_status', "status IN ('ACTIVE','INACTIVE')"); CALL supplier_add_check_if_missing('chk_wh_supplier_settlement', "settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')"); @@ -82,7 +84,7 @@ CREATE PROCEDURE assert_supplier_schema() BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier') <> 1 OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3 - OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN + OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整'; END IF; END$$