Test #1
+14
@@ -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<List<ContainerActualFacts>> 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<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+28
@@ -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<CarrierScheduleQu
|
||||
@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
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
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
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
|
||||
</script>
|
||||
""")
|
||||
List<ContainerActualFacts> selectContainerActualFacts(
|
||||
@Param("containerBookingIds") List<Long> containerBookingIds);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED')
|
||||
|
||||
+4
@@ -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> containerActualFacts(List<Long> containerBookingIds) {
|
||||
return queries.selectContainerActualFacts(containerBookingIds);
|
||||
}
|
||||
public List<CarrierScheduleQueryEntity> 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;
|
||||
|
||||
+14
@@ -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> containerActualFacts(List<Long> containerBookingIds) {
|
||||
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
|
||||
throw badRequest("containerBookingIds数量必须在1到200之间");
|
||||
}
|
||||
Set<Long> 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> history(long id) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+12
@@ -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")
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
.containsExactly("/api/platform/v1/carrier-schedule-queries");
|
||||
Map<String, String> 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",
|
||||
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user