Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
10 changed files with 182 additions and 0 deletions
Showing only changes of commit b0c81e337d - Show all commits
@@ -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,
@@ -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,
@@ -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')
@@ -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;
@@ -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]
@@ -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")
@@ -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",
@@ -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;
}
}
@@ -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);