From b0c81e337d16a015aa650df71c666dc89fcde3a6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 19:09:08 +0800 Subject: [PATCH] feat(firstleg): expose batch carrier actual facts --- ...latformCarrierScheduleQueryController.java | 14 ++++++ .../dto/CarrierScheduleQueryDtos.java | 11 +++++ .../mapper/CarrierScheduleQueryMapper.java | 28 ++++++++++++ .../CarrierScheduleQueryRepository.java | 4 ++ .../service/CarrierScheduleQueryService.java | 14 ++++++ .../openapi/carrier-schedule-query-v1.yaml | 43 +++++++++++++++++++ ...rrierScheduleQueryOpenApiContractTest.java | 12 ++++++ ...erScheduleQueryControllerContractTest.java | 1 + .../CarrierScheduleQuerySqlContractTest.java | 25 +++++++++++ .../CarrierScheduleQueryServiceTest.java | 30 +++++++++++++ 10 files changed, 182 insertions(+) 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 4b76ab3a0..de6190798 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 @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.carriers()); } + @Operation(summary = "按货柜批量查询实际船节点摘要", + description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。") + @GetMapping("/container-actual-facts") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") + public ApiResponse> containerActualFacts( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam("containerBookingIds") @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.containerActualFacts(containerBookingIds)); + } + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, 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 6ff80c834..0bbfb2713 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 @@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos { public record QueryView(Task query, boolean expired) {} + /** Actual-only summary used by container workbenches; estimated facts are intentionally excluded. */ + public record ContainerActualFacts(Long containerBookingId, Long queryId, Integer queryVersion, + String queryStatus, String latestActualNode, + LocalDateTime latestActualAt, + LocalDateTime departedWarehouseAt, + LocalDateTime departedOriginPortAt, + LocalDateTime arrivedDestinationPortAt, + LocalDateTime pickupReadyAt, + LocalDateTime pickedUpAt, + LocalDateTime emptyReturnedAt) {} + public record NodeFact(Long id, String providerCode, String providerEventId, String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 274bff0d6..283efa2e0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper + SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, + q.status AS queryStatus,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt, + MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt, + MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt, + MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt, + MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt, + MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt, + MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt + FROM fl_carrier_schedule_query q + JOIN ( + SELECT container_booking_id,MAX(id) AS query_id + FROM fl_carrier_schedule_query + WHERE container_booking_id IN + #{id} + GROUP BY container_booking_id + ) latest ON latest.query_id=q.id + LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL + GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at + ORDER BY q.container_booking_id + + """) + List selectContainerActualFacts( + @Param("containerBookingIds") List containerBookingIds); + @Select(""" SELECT * FROM fl_carrier_schedule_query WHERE status IN ('READY','MANUAL_REQUIRED') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 7df760374..c83cb6239 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; @@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository { public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); } + public List containerActualFacts(List containerBookingIds) { + return queries.selectContainerActualFacts(containerBookingIds); + } public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { return queries.claimLease(id, owner, now, leaseUntil) == 1; 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 9a0bc0916..308f71969 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 @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -48,6 +49,7 @@ import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HexFormat; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -76,6 +78,18 @@ public class CarrierScheduleQueryService { source.page(), source.size(), source.total(), source.pages()); } + public List containerActualFacts(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) { + throw badRequest("containerBookingIds数量必须在1到200之间"); + } + Set unique = new HashSet<>(); + for (Long id : containerBookingIds) { + if (id == null || id <= 0) throw badRequest("containerBookingIds必须全部为正数"); + if (!unique.add(id)) throw badRequest("containerBookingIds不能包含重复值"); + } + return repository.containerActualFacts(containerBookingIds); + } + public Detail detail(long id) { return detail(required(id)); } public List history(long id) { 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 bbc2d4d3f..37dea7a29 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -10,6 +10,25 @@ paths: x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} + /api/platform/v1/carrier-schedule-queries/container-actual-facts: + get: + summary: Batch query actual carrier-node facts for container workbenches + description: Returns at most one row for each container that has a carrier-schedule query. Missing rows mean that no query exists. Estimated facts are never returned or inferred. + x-permission: platform:firstleg:port-schedule-query:list + parameters: + - name: containerBookingIds + in: query + required: true + schema: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: {type: integer, format: int64, minimum: 1} + responses: + '200': + description: Actual-only summaries ordered by containerBookingId + content: {application/json: {schema: {$ref: '#/components/schemas/ContainerActualFactsListEnvelope'}}} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks @@ -175,6 +194,30 @@ components: properties: query: {$ref: '#/components/schemas/Task'} expired: {type: boolean} + ContainerActualFacts: + type: object + required: [containerBookingId, queryId, queryVersion, queryStatus] + description: Actual facts only. All node times are null until an immutable fact with actualAt exists; estimatedAt is intentionally absent. + properties: + containerBookingId: {type: integer, format: int64} + queryId: {type: integer, format: int64} + queryVersion: {type: integer, minimum: 0} + queryStatus: {$ref: '#/components/schemas/QueryStatus'} + latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} + latestActualAt: {type: string, format: date-time, nullable: true} + departedWarehouseAt: {type: string, format: date-time, nullable: true} + departedOriginPortAt: {type: string, format: date-time, nullable: true} + arrivedDestinationPortAt: {type: string, format: date-time, nullable: true} + pickupReadyAt: {type: string, format: date-time, nullable: true} + pickedUpAt: {type: string, format: date-time, nullable: true} + emptyReturnedAt: {type: string, format: date-time, nullable: true} + ContainerActualFactsListEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {type: array, items: {$ref: '#/components/schemas/ContainerActualFacts'}} NodeFact: type: object required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] 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 581673ddc..56915fc93 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 @@ -14,6 +14,18 @@ class CarrierScheduleQueryOpenApiContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") + .contains("/api/platform/v1/carrier-schedule-queries/container-actual-facts:") + .contains("name: containerBookingIds") + .contains("maxItems: 200") + .contains("uniqueItems: true") + .contains("ContainerActualFacts:") + .contains("departedWarehouseAt:") + .contains("departedOriginPortAt:") + .contains("arrivedDestinationPortAt:") + .contains("pickupReadyAt:") + .contains("pickedUpAt:") + .contains("emptyReturnedAt:") + .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("x-permission: platform:firstleg:port-schedule-query:manual") 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 b113eaaac..64933838e 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 @@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( "carriers", "platform:firstleg:port-schedule-query:list", + "containerActualFacts", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 1c8463923..4e783961c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -9,10 +9,31 @@ import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception { + String sql = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("selectContainerActualFacts", List.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("container_booking_id IN") + .contains("f.actual_at IS NOT NULL") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'") + .contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'") + .contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'") + .contains("MAX(CASE WHEN f.standard_node='PICKED_UP'") + .contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'") + .contains("ORDER BY q.container_booking_id") + .doesNotContain("estimated_at"); + assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1); + } + @Test void annotationSqlUsesExecutableComparisonOperators() throws Exception { String selectDue = String.join("\n", CarrierScheduleQueryMapper.class @@ -149,4 +170,8 @@ class CarrierScheduleQuerySqlContractTest { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); } + + private static int count(String source, String needle) { + return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } } 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 8ad349e24..0c0425841 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 @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; @@ -72,6 +73,35 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.containerBooking(20L)).thenReturn(container); } + @Test + void returnsBatchActualFactsFromOneRepositoryCall() { + ContainerActualFacts facts = new ContainerActualFacts(20L, 30L, 2, "READY", + "PICKUP_READY", NOW, NOW.minusDays(12), NOW.minusDays(10), NOW.minusDays(2), + NOW, null, null); + when(repository.containerActualFacts(List.of(20L, 21L))).thenReturn(List.of(facts)); + + assertThat(service.containerActualFacts(List.of(20L, 21L))).containsExactly(facts); + verify(repository).containerActualFacts(List.of(20L, 21L)); + } + + @Test + void rejectsInvalidActualFactBatchBeforeRepositoryAccess() { + assertThatThrownBy(() -> service.containerActualFacts(null)) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(List.of())) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Collections.nCopies(201, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Arrays.asList(1L, null))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 0L))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("重复"); + + verify(repository, never()).containerActualFacts(any()); + } + @Test void createsTaskOnlyForBookingCustomerAndAllocatedContainer() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);