diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 19a57649a..84e695663 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8712,6 +8712,11 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( OR pending_estimated_arrival_at >= pending_estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. +ALTER TABLE fl_ocean_booking + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱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 d2c6fa205..854c17c7b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -228,13 +228,16 @@ public class SmartLoadingService { String key = trim(idempotencyKey); if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + if (!StringUtils.hasText(booking.getContainerNo())) { + throw validation("录入真实柜号后才能推送预装清单并开启PDA作业"); + } if (key.equals(plan.getPushIdempotencyKey())) { loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); - return view(plan, requireBooking(plan.getContainerBookingId())); + return view(plan, booking); } if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); - ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List items = repository.items(planId); if (items.isEmpty()) throw validation("装柜明细不能为空"); checkPlanCapacity(booking, items); 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 e7ff8ae84..c07ecd080 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -56,6 +56,11 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( OR pending_estimated_arrival_at >= pending_estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. +ALTER TABLE fl_ocean_booking + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e9af54777..24c826170 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -357,6 +357,21 @@ paths: responses: '200': {description: 成功、计划不存在或计划已进入预装流程} '403': {description: 无智能装柜查询权限} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload: + post: + summary: 推送智能装柜计划到预装清单 + description: >- + 需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入, + 否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPushRequest'}}} + responses: + '200': {description: 推送成功或业务失败,按响应体 code 判断} + '403': {description: 无智能装柜推送权限} /openapi/v1/inbound-measurements: post: summary: 提交一条第三方箱规测量结果和一张图片 @@ -1273,6 +1288,11 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500} + SmartLoadingPushRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} ContainerDocumentAttachRequest: type: object required: [fileId, expectedVersion] 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 8c46f1a37..6ef23e5ed 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -26,5 +26,11 @@ class PlatformSmartLoadingControllerContractTest { authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); } + @Test void openApiRequiresRealContainerNumberBeforePreloadPush() throws Exception { + String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:") + .contains("真实柜号必须已录入") + .contains("PDA作业任务"); + } private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} } 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 ae728915a..f37a3a951 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 @@ -20,6 +20,8 @@ class OceanBookingSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") .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("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") 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 85c3e1487..b60034b89 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -387,10 +387,9 @@ class SmartLoadingServiceTest { } @Test - void rejectsOverweightPlanBeforePushEvenWithoutContainerNumber() { + void rejectsOverweightPlanBeforePush() { LoadingPlanEntity editing = plan("EDITING", 1); ContainerBookingEntity booking = booking(); - booking.setContainerNo(null); booking.setMaxWeightKg(new BigDecimal("1")); LoadingPlanItemEntity overweight = item(8, 9, 20, 1); overweight.setGrossWeightKg(new BigDecimal("2")); @@ -409,21 +408,39 @@ class SmartLoadingServiceTest { } @Test - void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() { + void rejectsPushWithoutContainerNumberBeforePdaOpening() { LoadingPlanEntity editing = plan("EDITING", 1); - LoadingPlanEntity pushed = plan("EDITING", 2); ContainerBookingEntity booking = booking(); booking.setContainerNo(null); - when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.plan(1)).thenReturn(editing); when(repository.booking(99)).thenReturn(booking); - when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); - when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1); - Plan result = service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"); + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"), + BizException.class); - assertThat(result.status()).isEqualTo("EDITING"); - verify(repository).push(1, "no-container-key", 1, 7, "u"); - verify(loadingPickService).beginIfReady(99L, 7L, "u"); + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error.getMessage()).contains("真实柜号"); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void rejectsReplayWhenTheContainerNumberIsStillMissing() { + LoadingPlanEntity pushed = plan("EDITING", 2); + pushed.setPushIdempotencyKey("replay-key"); + ContainerBookingEntity booking = booking(); + booking.setContainerNo(" "); + when(repository.plan(1)).thenReturn(pushed); + when(repository.booking(99)).thenReturn(booking); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"), + BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } private void stubAssignment() {