This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient {
}
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
record QueryResult(String providerCode, String outcome, List<Observation> observations,
List<ExceptionObservation> exceptions,
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
record Observation(String providerEventId, String providerNodeCode, String standardNode,
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode,
boolean active, LocalDateTime observedAt, String locationCode,
String locationName, String sourceReference, String rawPayloadHash) { }
}
@@ -1,48 +0,0 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries")
public class InternalCarrierScheduleQueryController {
private final CarrierScheduleQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; }
@PostMapping("/{queryId}/stop")
public ApiResponse<Detail> stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable @Positive long queryId,
@Valid @RequestBody StopRequest request) {
authenticate(token);
return ApiResponse.success(service.stop(queryId, request.reason()));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
public record StopRequest(@NotBlank @Size(max = 500) String reason) { }
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
@@ -93,4 +94,16 @@ public class PlatformCarrierScheduleQueryController {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ManualNodeRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.portschedule.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
public enum CarrierScheduleExceptionCode {
CUSTOMS_HOLD,
TERMINAL_HOLD,
DOCUMENT_HOLD,
UNKNOWN_HOLD;
public static CarrierScheduleExceptionCode require(String value) {
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value);
}
}
}
@@ -9,10 +9,14 @@ import java.util.Locale;
public enum CarrierScheduleNode {
DEPARTED_WAREHOUSE(10),
AT_ORIGIN_PORT(20),
DEPARTED_ORIGIN_PORT(30),
IN_TRANSIT(40),
ARRIVED(50),
PICKUP_READY(60);
LOADED_ON_VESSEL(30),
DEPARTED_ORIGIN_PORT(40),
IN_TRANSIT(50),
ARRIVED_DESTINATION_PORT(60),
DISCHARGED_FROM_VESSEL(70),
PICKUP_READY(80),
PICKED_UP(90),
EMPTY_RETURNED(100);
private final int sequence;
@@ -16,9 +16,9 @@ public final class CarrierScheduleQueryDtos {
public record Query(@Positive 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,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
@Size(max = 128) String keyword, @Min(1) Long page,
@Min(1) @Max(200) Long size) {
public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; }
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@@ -34,6 +34,15 @@ public final class CarrierScheduleQueryDtos {
@NotNull @Positive Long portScheduleId,
@NotBlank @Size(max = 1000) String reason) {}
public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@@ -46,6 +55,17 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 128) String providerStatus,
@NotBlank @Size(max = 32) String exceptionCode,
boolean active,
@NotNull LocalDateTime observedAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
String accessMode, boolean automated, boolean coverageGap,
String coverageGapCode, String manualReason) {}
@@ -53,21 +73,28 @@ public final class CarrierScheduleQueryDtos {
public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId,
Long containerBookingId, String carrierCode, String trackingType, String trackingNo,
String officialUrl, String accessMode, String status, String triggerSource,
String latestStandardNode, LocalDateTime latestNodeObservedAt,
String latestActualNode, LocalDateTime latestActualAt,
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
Long resultPortScheduleId, String manualReason, String lastErrorCode,
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
public record QueryView(Task query, boolean expired) {}
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
public record NodeFact(Long id, String providerCode, String providerEventId,
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
/** Frontend detail intentionally excludes scheduler leases and raw query attempts. */
public record Detail(QueryView query, List<NodeFact> nodes, List<History> histories) {}
public record Detail(QueryView query, List<NodeFact> nodes, List<ExceptionFact> exceptions,
List<History> histories) {}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_exception_fact")
public class CarrierScheduleExceptionFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerStatus;
private String exceptionCode;
private Boolean active;
private LocalDateTime observedAt;
private String locationCode;
private String locationName;
private String sourceReference;
private String rawPayloadHash;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -18,6 +18,7 @@ public class CarrierScheduleNodeFactEntity {
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerNodeCode;
private String standardNode;
private Integer nodeSequence;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CarrierScheduleExceptionFactMapper extends BaseMapper<CarrierScheduleExceptionFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_exception_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active,
observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},
#{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value);
}
@@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param;
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at,
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode},
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
@@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
WHERE status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
ORDER BY next_allowed_at,id LIMIT #{limit}
@@ -58,7 +58,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Update("""
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
""")
@@ -10,11 +10,13 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleExceptionFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -28,6 +30,7 @@ public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
@Resource private CarrierScheduleNodeFactMapper nodes;
@Resource private CarrierScheduleExceptionFactMapper exceptions;
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
@@ -67,6 +70,15 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
.orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt)
.orderByAsc(CarrierScheduleExceptionFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
return exceptions.insertIfAbsent(value) == 1;
}
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -16,15 +17,27 @@ import java.util.Map;
@Component
public class CarrierScheduleAdapterRegistry {
private static final Map<String, Definition> DEFINITIONS = definitions();
@Value("${baiyue.carrier-schedule.providers.matson-automated:false}")
private boolean matsonAutomated;
@Value("${baiyue.carrier-schedule.providers.hede-automated:false}")
private boolean hedeAutomated;
@Value("${baiyue.carrier-schedule.providers.sml-automated:false}")
private boolean smlAutomated;
@Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}")
private boolean zimOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}")
private boolean coscoOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.emc-automated:false}")
private boolean emcAutomated;
public List<CarrierDescriptor> descriptors() {
return DEFINITIONS.values().stream().map(Definition::descriptor).toList();
return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList();
}
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
Definition definition = required(carrierCode);
return new PreparedAttempt(definition.code(), definition.accessMode(),
officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED",
officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED",
definition.coverageGapCode(), definition.manualReason());
}
@@ -52,9 +65,6 @@ public class CarrierScheduleAdapterRegistry {
private static String officialUrl(Definition definition, String trackingType, String trackingNo) {
String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8);
if ("ZIM".equals(definition.code())) {
return definition.officialUrl() + "?consnumber=" + encoded;
}
if ("EMC".equals(definition.code())) {
String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl";
return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN";
@@ -65,26 +75,40 @@ public class CarrierScheduleAdapterRegistry {
private static Map<String, Definition> definitions() {
Map<String, Definition> values = new LinkedHashMap<>();
add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果"));
"PUBLIC_WEB_PROXY", null, null));
add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment",
"HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/",
"DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询"));
"PUBLIC_FORM", null, null));
add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询"));
add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入"));
add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
"PUBLIC_HTML", null, null));
return Collections.unmodifiableMap(values);
}
private static void add(Map<String, Definition> values, Definition value) { values.put(value.code(), value); }
private boolean automated(Definition definition) {
return switch (definition.code()) {
case "MATSON" -> matsonAutomated;
case "SML" -> smlAutomated;
case "ZIM" -> zimOfficialApiEnabled;
case "COSCO" -> coscoOfficialApiEnabled;
case "EMC" -> emcAutomated;
case "HEDE" -> hedeAutomated;
default -> false;
};
}
private record Definition(String code, String name, String officialUrl, String accessMode,
String coverageGapCode, String manualReason) {
CarrierDescriptor descriptor() {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
CarrierDescriptor descriptor(boolean automated) {
return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated,
coverageGapCode, manualReason);
}
boolean matches(String value) {
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.springframework.beans.factory.annotation.Value;
@@ -61,6 +62,14 @@ public class CarrierScheduleQueryScheduler {
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
? List.of() : result.exceptions();
for (var exception : exceptions) {
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
} catch (RuntimeException exception) {
@@ -9,17 +9,23 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ExceptionFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
@@ -128,7 +134,6 @@ public class CarrierScheduleQueryService {
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
@@ -136,40 +141,83 @@ public class CarrierScheduleQueryService {
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if (current == null || node.sequence() >= current.sequence()) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt());
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt());
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED"
: node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY");
value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
}
CarrierScheduleQueryEntity after = required(id);
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
0L, "carrier-scheduler");
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
after, 0L, "carrier-scheduler");
return detail(after);
}
@Transactional
public Detail stop(long id, String reason) {
public Detail applyProviderException(long id, ProviderExceptionRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode());
if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code,
request.active(), request.observedAt(), request.locationCode(), request.locationName(),
request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value,
0L, "carrier-scheduler");
return detail(value);
}
@Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) {
String operation = "MANUAL_NODE";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason));
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L,
"carrier-scheduler");
return detail(after);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request))) {
return complete(id, operatorId, key, detail(value));
}
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
operatorId, operatorName);
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt, LocalDateTime observedAt,
long operatorId, String operatorName) {
if (actualAt == null || "STOPPED".equals(value.getStatus())) return false;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
boolean advances = current == null || node.sequence() > current.sequence()
|| node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null
|| actualAt.isAfter(value.getLatestNodeObservedAt()));
if (advances) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt);
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt);
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (node == CarrierScheduleNode.EMPTY_RETURNED) {
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜");
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
} else {
value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
}
if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent();
return true;
}
return false;
}
@Transactional
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
CarrierScheduleQueryEntity value = requiredLocked(id);
insertAttempt(value, outcome, errorCode, errorMessage);
if ("STOPPED".equals(value.getStatus())) return;
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
@@ -177,6 +225,7 @@ public class CarrierScheduleQueryService {
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
value.setLastErrorMessage(trim(errorMessage));
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
value.setStatus("READY");
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
@@ -189,9 +238,7 @@ public class CarrierScheduleQueryService {
value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts));
value.setConsecutiveFailures(failures);
if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) {
if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) {
value.setStatus("MANUAL_REQUIRED");
}
value.setStatus("MANUAL_REQUIRED");
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
@@ -208,6 +255,20 @@ public class CarrierScheduleQueryService {
"carrier-scheduler");
}
private void insertAttempt(CarrierScheduleQueryEntity value, String outcome,
String errorCode, String errorMessage) {
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId());
attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1)));
attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode());
attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome);
boolean manual = "MANUAL_REQUIRED".equals(outcome);
attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null);
attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode));
attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L);
attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt);
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
@@ -309,16 +370,24 @@ public class CarrierScheduleQueryService {
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
repository.exceptions(value.getId()).stream().map(this::exception).toList(),
repository.histories(value.getId()).stream().map(this::history).toList());
}
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(),
value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()),
value.getObservedAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private History history(CarrierScheduleQueryHistoryEntity value) {
return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(),
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
@@ -332,6 +401,7 @@ public class CarrierScheduleQueryService {
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim());
fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name());
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
@@ -339,6 +409,21 @@ public class CarrierScheduleQueryService {
return repository.insertIfAbsent(fact);
}
private boolean addException(long queryId, String providerCode, String providerEventId,
String providerStatus, CarrierScheduleExceptionCode code, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim());
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|| !value.getExpiresAt().isAfter(now);
@@ -25,6 +25,14 @@ spring:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
sml-automated: ${SML_TRACKING_ENABLED:false}
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
@@ -12,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API',
status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间',
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
@@ -58,12 +58,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT',
'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
@@ -74,7 +75,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败',
attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果',
coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是',
coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码',
manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因',
@@ -88,13 +89,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3),
CONSTRAINT chk_carrier_schedule_attempt_status CHECK
(attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED'))
(attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED'))
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果',
action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作',
from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本',
reason VARCHAR(1000) NULL COMMENT '操作原因或说明',
@@ -107,7 +108,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED',
'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'))
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
@@ -115,6 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
@@ -131,10 +134,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100))
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态',
exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码',
active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除',
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
location_code VARCHAR(32) NULL COMMENT '地点编码',
location_name VARCHAR(128) NULL COMMENT '地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key),
KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_exception_code CHECK
(exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')),
CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1))
) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
@@ -177,6 +205,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
@@ -190,6 +220,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
@@ -253,7 +287,7 @@ BEGIN
WHERE table_schema='baiyue_firstleg' AND table_name IN
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
'fl_carrier_schedule_node_fact')) <> 5 THEN
'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
@@ -1,23 +0,0 @@
openapi: 3.0.3
info:
title: Carrier Schedule Internal Handoff API
version: 1.0.0
description: Internal-only handoff contract; it is not a platform or merchant frontend API.
paths:
/internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop:
post:
summary: Stop tracking after the overseas pickup handoff has completed
parameters:
- {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 500}
responses:
'200': {description: Idempotent STOPPED task detail}
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Carrier Schedule Query API
version: 1.0.0
description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness.
description: Phase-4 carrier tracking from domestic outbound confirmation through actual empty return.
paths:
/api/platform/v1/carrier-schedule-queries/carriers:
get:
@@ -43,18 +43,21 @@ paths:
summary: Get task, standardized node facts and immutable business history
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: Stable task detail}
'200':
description: Stable task detail
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/history:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
summary: Get immutable task history
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: CREATE, ATTEMPT and MANUAL_RESULT history}
'200': {description: Immutable business history; scheduler attempts are not exposed}
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Link a validated manual port-schedule observation without pretending automated success
description: This legacy port-schedule association does not append a standard node and never stops tracking; phase-4 manual facts use /manual-nodes.
x-permission: platform:firstleg:port-schedule-query:manual
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
@@ -63,7 +66,24 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ManualResultRequest'}
responses:
'200': {description: Successful manual fallback and next query time}
'200':
description: Successful manual fallback and next query time
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Append one immutable manual estimated or actual standard-node fact
x-permission: platform:firstleg:port-schedule-query:manual
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
responses:
'200':
description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
components:
parameters:
QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -71,8 +91,11 @@ components:
schemas:
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]}
StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode:
type: string
enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED]
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
CreateRequest:
type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
@@ -92,9 +115,112 @@ components:
expectedVersion: {type: integer, minimum: 0}
portScheduleId: {type: integer, format: int64, minimum: 1}
reason: {type: string, minLength: 1, maxLength: 1000}
ManualNodeRequest:
type: object
required: [expectedVersion, standardNode, observedAt, reason]
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node.
properties:
expectedVersion: {type: integer, minimum: 0}
standardNode: {$ref: '#/components/schemas/StandardNode'}
observedAt: {type: string, format: date-time}
estimatedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
reason: {type: string, minLength: 1, maxLength: 1000}
Task:
type: object
required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version]
properties:
id: {type: integer, format: int64}
queryNo: {type: string}
customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string}
officialUrl: {type: string, nullable: true}
accessMode: {type: string, nullable: true}
status: {$ref: '#/components/schemas/QueryStatus'}
triggerSource: {type: string}
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
lastSuccessAt: {type: string, format: date-time, nullable: true}
sourceUpdatedAt: {type: string, format: date-time, nullable: true}
expiresAt: {type: string, format: date-time, nullable: true}
resultPortScheduleId: {type: integer, format: int64, nullable: true}
manualReason: {type: string, nullable: true}
lastErrorCode: {type: string, nullable: true}
lastErrorMessage: {type: string, nullable: true}
stoppedAt: {type: string, format: date-time, nullable: true}
stopReason: {type: string, nullable: true}
version: {type: integer, minimum: 0}
QueryView:
type: object
required: [query, expired]
properties:
query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed}
query: {$ref: '#/components/schemas/Task'}
expired: {type: boolean}
NodeFact:
type: object
required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt]
properties:
id: {type: integer, format: int64}
providerCode: {type: string, maxLength: 32}
providerEventId: {type: string, maxLength: 128}
providerNodeCode: {type: string, maxLength: 64}
standardNode: {$ref: '#/components/schemas/StandardNode'}
nodeSequence: {type: integer, enum: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
observedAt: {type: string, format: date-time}
estimatedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time}
ExceptionFact:
type: object
required: [id, providerCode, providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash, createdAt]
properties:
id: {type: integer, format: int64}
providerCode: {type: string, maxLength: 32}
providerEventId: {type: string, maxLength: 128}
providerStatus: {type: string, maxLength: 128}
exceptionCode: {$ref: '#/components/schemas/ExceptionCode'}
active: {type: boolean, description: true means hold occurred; false means released}
observedAt: {type: string, format: date-time}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time}
History:
type: object
required: [id, action, createdAt]
properties:
id: {type: integer, format: int64}
action: {type: string, enum: [CREATE, AUTO_CREATE, MANUAL_RESULT, MANUAL_NODE, NODE_OBSERVED, EXCEPTION_OBSERVED, PROVIDER_QUERY]}
fromVersion: {type: integer, nullable: true}
toVersion: {type: integer, nullable: true}
reason: {type: string, nullable: true}
operatorId: {type: integer, format: int64, nullable: true}
operatorName: {type: string, nullable: true}
createdAt: {type: string, format: date-time}
Detail:
type: object
required: [query, nodes, exceptions, histories]
description: Scheduler lease, attempt and internal execution fields are intentionally absent.
properties:
query: {$ref: '#/components/schemas/QueryView'}
nodes: {type: array, items: {$ref: '#/components/schemas/NodeFact'}}
exceptions: {type: array, items: {$ref: '#/components/schemas/ExceptionFact'}}
histories: {type: array, items: {$ref: '#/components/schemas/History'}}
DetailEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/Detail'}
@@ -15,10 +15,17 @@ class CarrierScheduleQueryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/carrier-schedule-queries:")
.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")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY")
.contains("MANUAL_REQUIRED")
.contains("scheduler leases and raw attempts are never exposed")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED")
.contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD")
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
.contains("latestActualNode:")
.contains("latestActualAt:")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt");
}
@@ -22,13 +22,14 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create",
"manualResult", "platform:firstleg:port-schedule-query:manual");
"manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("manualResult")) {
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.portschedule.dto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryDtosTest {
@Test
void omittedPageAndSizeBindToDocumentedDefaults() {
var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null);
assertThat(query.page()).isEqualTo(1L);
assertThat(query.size()).isEqualTo(20L);
}
}
@@ -18,15 +18,25 @@ class CarrierScheduleQuerySqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除")
.contains("标准船节点事实不可修改")
.contains("ARRIVED','PICKUP_READY','STOPPED")
.contains("船节点异常事实不可修改")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("LOADED_ON_VESSEL")
.contains("ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL")
.contains("PICKED_UP','EMPTY_RETURNED")
.contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD")
.contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("lease_owner")
.contains("consecutive_failures");
.contains("consecutive_failures")
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
}
@Test
@@ -36,7 +46,8 @@ class CarrierScheduleQuerySqlContractTest {
.contains("allocation.container_booking_id=NEW.container_booking_id")
.contains("id IN (5102184,5102186,5102188)")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("'platform:firstleg:port-schedule-query:attempt'")
.contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@@ -50,7 +61,9 @@ class CarrierScheduleQuerySqlContractTest {
.contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
}
private static String moduleSql() throws Exception {
@@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest {
}
@Test
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() {
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/");
assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/");
}
@Test
@@ -26,16 +26,20 @@ class CarrierScheduleQuerySchedulerTest {
task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567");
LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED",
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL",
now, null, now, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD",
"CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation),
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception),
null, null, now)));
scheduler.execute(task, now);
verify(service).applyProviderObservation(anyLong(), any());
verify(service).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
}
@@ -5,8 +5,12 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -15,7 +19,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
@@ -28,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,6 +54,7 @@ class CarrierScheduleQueryServiceTest {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.nodes(anyLong())).thenReturn(List.of());
when(repository.exceptions(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@@ -86,7 +94,7 @@ class CarrierScheduleQueryServiceTest {
}
@Test
void providerObservationAdvancesToArrivedWithoutExposingAttempts() {
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
@@ -96,14 +104,172 @@ class CarrierScheduleQueryServiceTest {
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1",
"DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(result.query().query().status()).isEqualTo("ARRIVED");
assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED");
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty();
}
@Test
void estimatedObservationIsSavedWithoutAdvancingActualNode() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL",
"ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void lateActualFactIsSavedButCannotRegressLatestNode() {
CarrierScheduleQueryEntity task = task(0, 5);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED",
"DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void onlyActualEmptyReturnedStopsTask() {
CarrierScheduleQueryEntity task = task(0, 2);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED",
"EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"));
assertThat(task.getStatus()).isEqualTo("STOPPED");
assertThat(task.getStopReason()).isEqualTo("已还柜");
assertThat(task.getNextAllowedAt()).isNull();
}
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1",
new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜"));
assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1",
"CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX",
"Long Beach", "MATSON:9974749",
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"));
ArgumentCaptor<CarrierScheduleExceptionFactEntity> fact =
ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class);
verify(repository).insertIfAbsent(fact.capture());
assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD");
assertThat(fact.getValue().getActive()).isTrue();
assertThat(task.getLatestStandardNode()).isNull();
}
@Test
void everySchedulerCompletionWritesImmutableAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.completeScheduledAttempt(1L, "NO_CHANGE", null, null);
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE");
assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler");
assertThat(task.getStatus()).isEqualTo("READY");
}
@Test
void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() {
CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isFalse();
assertThat(value.coverageGap()).isTrue();
});
ReflectionTestUtils.setField(registry, "matsonAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY");
});
ReflectionTestUtils.setField(registry, "hedeAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML");
});
ReflectionTestUtils.setField(registry, "smlAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true);
assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("OFFICIAL_API");
});
ReflectionTestUtils.setField(registry, "emcAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
}
@Test
void manualResultMustBelongToSameCustomerBookingAndContainer() {
CarrierScheduleQueryEntity task = task(1, 1);
@@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")