优化
This commit is contained in:
+4
@@ -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) { }
|
||||
}
|
||||
|
||||
-48
@@ -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) { }
|
||||
}
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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;
|
||||
|
||||
|
||||
+33
-6
@@ -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) {}
|
||||
}
|
||||
|
||||
+31
@@ -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;
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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))
|
||||
|
||||
+2
-2
@@ -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<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{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<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
""")
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
|
||||
+38
-14
@@ -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) {
|
||||
|
||||
+9
@@ -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) {
|
||||
|
||||
+111
-26
@@ -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'}
|
||||
|
||||
+10
-3
@@ -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");
|
||||
}
|
||||
|
||||
+3
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -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 {
|
||||
|
||||
+8
-6
@@ -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
|
||||
|
||||
+6
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+170
-4
@@ -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);
|
||||
|
||||
+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'")
|
||||
|
||||
Reference in New Issue
Block a user