diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b26c862a2..21db5c891 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18584,6 +18584,17 @@ DROP PROCEDURE IF EXISTS add_finance_p0_column; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)')); PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +-- 跨客户合柜的订舱属于平台技术作用域,船节点任务使用 customer_id=0。 +USE baiyue_firstleg; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_customer' AND constraint_type='CHECK' +), 'ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_customer', 'SELECT 1')); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0); + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 6f950da75..da804e739 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -97,7 +97,7 @@ public class OutboundDispatchService { OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + if (ocean == null || ocean.id() == null) { throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); } carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, @@ -129,7 +129,7 @@ public class OutboundDispatchService { if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + if (ocean == null || ocean.id() == null) { throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); } if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index de6190798..b51d107d1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -14,6 +14,9 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -41,6 +44,7 @@ import java.util.List; @RequestMapping("/api/platform/v1/carrier-schedule-queries") public class PlatformCarrierScheduleQueryController { @Resource private CarrierScheduleQueryService service; + @Resource private OutboundDispatchService outboundDispatchService; @Operation(summary = "查询六家船公司合规适配策略") @GetMapping("/carriers") @@ -97,6 +101,23 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } + @PostMapping("/containers/{containerBookingId}/ensure") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @Operation(summary = "为已确认离仓货柜补建船节点任务后进入人工补录") + @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ENSURE", resourceType = "CONTAINER_BOOKING", + resourceId = "#containerBookingId", description = "补建货柜船节点任务") + public ApiResponse ensureForManualEntry(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key) { + FirstlegScopeGuard.platform(user); + var outbound = outboundDispatchService.detail(containerBookingId).aggregate(); + if (!"WAITING_FOR_LOADING".equals(outbound.dispatchStatus()) || outbound.oceanBookingId() == null) { + throw new BizException(ResultCode.CONFLICT, "货柜尚未确认国内出仓,不能人工补录船节点"); + } + return ApiResponse.success(service.ensureForManualEntry(outbound.oceanBookingId(), containerBookingId, + user.userId(), user.username(), key)); + } + @PostMapping("/{queryId}/manual-results") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 0bbfb2713..38ee6a9d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -13,7 +13,7 @@ import java.util.List; public final class CarrierScheduleQueryDtos { private CarrierScheduleQueryDtos() {} - public record Query(@Positive Long customerId, @Positive Long oceanBookingId, + public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId, @Positive Long containerBookingId, @Size(max = 16) String carrierCode, @Size(max = 24) String status, Boolean expired, @Size(max = 128) String keyword, @Min(1) Long page, @@ -22,7 +22,7 @@ public final class CarrierScheduleQueryDtos { } public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, - @NotNull @Positive Long customerId, + @NotNull @Min(0) Long customerId, @NotNull @Positive Long oceanBookingId, @NotNull @Positive Long containerBookingId, @NotBlank @Size(max = 16) String carrierCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 308f71969..ee7056a33 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -101,15 +101,17 @@ public class CarrierScheduleQueryService { /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ @Transactional - public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId, + public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, long containerBookingId, long operatorId, String operatorName) { - CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, - containerBookingId); OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); - validateLinks(customerId, oceanBookingId, containerBookingId); + long scopeCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId(); + if (customerId != null && customerId != scopeCustomerId) throw badRequest("customerId与订舱客户不一致"); + CarrierScheduleQueryEntity existing = repository.findByBusinessScope(scopeCustomerId, oceanBookingId, + containerBookingId); + validateLinks(scopeCustomerId, oceanBookingId, containerBookingId); String trackingType = "CONTAINER_NO"; String trackingNo = normalizedContainerNo(container); if (existing != null) { @@ -120,7 +122,7 @@ public class CarrierScheduleQueryService { LocalDateTime now = now(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); - value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); + value.setCustomerId(scopeCustomerId); value.setOceanBookingId(oceanBookingId); value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode()); value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); @@ -136,7 +138,7 @@ public class CarrierScheduleQueryService { try { try { repository.insert(value); } catch (DuplicateKeyException exception) { - CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId, containerBookingId); if (concurrent != null) { requireFrozenTrackingIdentity(concurrent, trackingNo); @@ -162,6 +164,13 @@ public class CarrierScheduleQueryService { return value; } + @Transactional + public Detail ensureForManualEntry(long oceanBookingId, long containerBookingId, + long operatorId, String operatorName, String key) { + validateKey(key); + return detail(ensureFromOutboundConfirm(null, oceanBookingId, containerBookingId, operatorId, operatorName)); + } + private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) { if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; String reason = strategy.manualReason(); @@ -424,7 +433,8 @@ public class CarrierScheduleQueryService { private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) { OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在"); - if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致"); + long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId(); + if (expectedCustomerId != customerId) throw badRequest("customerId与订舱客户不一致"); if (containerBookingId != null) { if (portSchedules.containerBooking(containerBookingId) == null) { throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在"); diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 0f47efd54..5689c4fc8 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), - CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), + CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 37dea7a29..77e2f9018 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -34,7 +34,7 @@ paths: summary: Query carrier schedule tasks x-permission: platform:firstleg:port-schedule-query:list parameters: - - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} + - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 0}} - {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}} @@ -65,6 +65,17 @@ paths: '200': description: Stable task detail content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} + /api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure: + post: + summary: Idempotently ensure the carrier task for a departed container before manual entry + x-permission: platform:firstleg:port-schedule-query:manual + parameters: + - {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {$ref: '#/components/parameters/IdempotencyKey'} + responses: + '200': + description: Existing or newly created task detail; platform-owned consolidated bookings use customerId 0 + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: @@ -126,7 +137,7 @@ components: required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo] properties: expectedVersion: {type: integer, minimum: 0, maximum: 0} - customerId: {type: integer, format: int64, minimum: 1} + customerId: {type: integer, format: int64, minimum: 0} oceanBookingId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1} carrierCode: {$ref: '#/components/schemas/CarrierCode'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index bd6b8e660..8eace99fe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -146,6 +146,24 @@ class OutboundDispatchServiceTest { verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); } + @Test + void confirmCreatesPlatformScopedCarrierTaskForConsolidatedBooking() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", null)); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-platform-booking"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-platform-booking")).thenReturn(1); + + service.confirm(22L, 9L, "admin", "confirm-platform-booking", new ActionRequest(0, null)); + + verify(carrierSchedules).ensureFromOutboundConfirm(null, 66L, 22L, 9L, "admin"); + } + @Test void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { OutboundDispatchService service = serviceWithoutCarrier(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 56915fc93..e8b18d538 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -28,6 +28,8 @@ class CarrierScheduleQueryOpenApiContractTest { .contains("Estimated facts are never returned or inferred") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") + .contains("/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:") + .contains("platform-owned consolidated bookings use customerId 0") .contains("x-permission: platform:firstleg:port-schedule-query:manual") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") .contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 64933838e..e92640ef9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -23,6 +23,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", + "ensureForManualEntry", "platform:firstleg:port-schedule-query:manual", "manualResult", "platform:firstleg:port-schedule-query:manual", "manualNode", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { @@ -30,7 +31,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) { + if (name.equals("create") || name.equals("ensureForManualEntry") || name.equals("manualResult") || name.equals("manualNode")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 0c0425841..4bd517f15 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -210,6 +210,24 @@ class CarrierScheduleQueryServiceTest { && "国内确认出仓自动创建".equals(history.getReason()))); } + @Test + void platformOwnedConsolidatedBookingCreatesContainerTaskInTechnicalScope() { + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setCustomerId(null); booking.setCarrierCode("SML"); booking.setShippingCompanyName("SML"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("ZCSU7881970"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(null, 10L, 20L, 0L, "SYSTEM"); + + assertThat(result.getCustomerId()).isZero(); + verify(repository).findByBusinessScope(0L, 10L, 20L); + verify(repository).insert(argThat((CarrierScheduleQueryEntity task) -> task.getCustomerId() == 0L)); + } + @Test void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); @@ -298,7 +316,7 @@ class CarrierScheduleQueryServiceTest { @Test void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception { Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm", - long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class); + Long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class); Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm", long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);