优化
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient",
|
||||
url = "${baiyue.integration-service.url:}")
|
||||
public interface CarrierScheduleIntegrationClient {
|
||||
@PostMapping("/internal/integrations/carrier-schedules/query")
|
||||
Envelope<QueryResult> query(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody QueryRequest request);
|
||||
|
||||
record Envelope<T>(String code, String message, T data) {
|
||||
public boolean successful() { return "200".equals(code); }
|
||||
}
|
||||
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
|
||||
record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
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) { }
|
||||
}
|
||||
@@ -14,6 +14,14 @@ public final class ContainerOverviewDtos {
|
||||
List<TimelineEntry> timeline) {}
|
||||
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
|
||||
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
|
||||
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
|
||||
String departurePortCode, String departurePortName, String destinationPortCode,
|
||||
String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
|
||||
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
|
||||
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
|
||||
Long emptyReportSourceVersion,
|
||||
Integer version) {}
|
||||
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
||||
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
|
||||
|
||||
@@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
actual_empty_reported_at=#{actualEmptyReportedAt}, empty_report_source_version=#{sourceVersion},
|
||||
updated_by=0, updated_by_name='OVERSEAS_EVENT', updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{id} AND record_status='ACTIVE'
|
||||
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
|
||||
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
|
||||
""")
|
||||
int applyEmptyReport(@Param("id") long id,
|
||||
@Param("unloadingCompletedAt") java.time.LocalDateTime unloadingCompletedAt,
|
||||
|
||||
+16
-1
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntit
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -26,10 +28,14 @@ public class OutboundDispatchService {
|
||||
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
private final CarrierScheduleQueryService carrierSchedules;
|
||||
|
||||
@Autowired
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors) {
|
||||
List<OutboundDispatchGateContributor> contributors,
|
||||
CarrierScheduleQueryService carrierSchedules) {
|
||||
this.repository = repository;
|
||||
this.carrierSchedules = carrierSchedules;
|
||||
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
|
||||
for (OutboundDispatchGateContributor contributor : contributors) {
|
||||
if (indexed.put(contributor.code(), contributor) != null) {
|
||||
@@ -39,6 +45,11 @@ public class OutboundDispatchService {
|
||||
this.contributors = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors) {
|
||||
this(repository, contributors, null);
|
||||
}
|
||||
|
||||
public List<Aggregate> list(Query query) {
|
||||
requireSelector(query);
|
||||
return repository.bindings(query).stream().map(this::aggregate).toList();
|
||||
@@ -73,6 +84,10 @@ public class OutboundDispatchService {
|
||||
}
|
||||
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
||||
if (carrierSchedules != null) {
|
||||
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(),
|
||||
binding.containerBookingId(), operatorId, operatorName);
|
||||
}
|
||||
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
|
||||
return detail(query);
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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) { }
|
||||
}
|
||||
+2
-15
@@ -4,14 +4,13 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
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.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController {
|
||||
|
||||
@GetMapping("/{queryId}/history")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
|
||||
public ApiResponse<List<History>> history(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
|
||||
FirstlegScopeGuard.platform(user, service.customerId(queryId));
|
||||
return ApiResponse.success(service.history(queryId));
|
||||
@@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController {
|
||||
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{queryId}/attempts")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
resourceId = "#queryId", description = "执行船司官网查询策略")
|
||||
public ApiResponse<Detail> attempt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long queryId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody AttemptRequest request) {
|
||||
FirstlegScopeGuard.platform(user, service.customerId(queryId));
|
||||
return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{queryId}/manual-results")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.portschedule.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Provider-independent maritime milestones in monotonic business order. */
|
||||
public enum CarrierScheduleNode {
|
||||
DEPARTED_WAREHOUSE(10),
|
||||
AT_ORIGIN_PORT(20),
|
||||
DEPARTED_ORIGIN_PORT(30),
|
||||
IN_TRANSIT(40),
|
||||
ARRIVED(50),
|
||||
PICKUP_READY(60);
|
||||
|
||||
private final int sequence;
|
||||
|
||||
CarrierScheduleNode(int sequence) { this.sequence = sequence; }
|
||||
|
||||
public int sequence() { return sequence; }
|
||||
|
||||
public static CarrierScheduleNode require(String value) {
|
||||
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
|
||||
catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-10
@@ -1,8 +1,5 @@
|
||||
package com.baiyue.firstleg.portschedule.dto;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
@@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos {
|
||||
@NotBlank @Size(max = 128) String trackingNo,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 1000) String reason) {}
|
||||
|
||||
public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull @Positive Long portScheduleId,
|
||||
@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,
|
||||
@NotBlank @Size(max = 32) String standardNode,
|
||||
@NotNull LocalDateTime observedAt,
|
||||
LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt,
|
||||
@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) {}
|
||||
|
||||
public record QueryView(CarrierScheduleQueryEntity query, boolean expired,
|
||||
int maxAttempts, LocalDateTime nextAllowedAt) {}
|
||||
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,
|
||||
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
|
||||
Long resultPortScheduleId, String manualReason, String lastErrorCode,
|
||||
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
|
||||
|
||||
public record Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
|
||||
List<CarrierScheduleQueryHistoryEntity> histories) {}
|
||||
public record QueryView(Task query, boolean expired) {}
|
||||
|
||||
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
|
||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, 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) {}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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_node_fact")
|
||||
public class CarrierScheduleNodeFactEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long queryId;
|
||||
private String eventKey;
|
||||
private String providerCode;
|
||||
private String providerNodeCode;
|
||||
private String standardNode;
|
||||
private Integer nodeSequence;
|
||||
private LocalDateTime observedAt;
|
||||
private LocalDateTime estimatedAt;
|
||||
private LocalDateTime actualAt;
|
||||
private String locationCode;
|
||||
private String locationName;
|
||||
private String sourceReference;
|
||||
private String rawPayloadHash;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
}
|
||||
+8
@@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity {
|
||||
private String officialUrl;
|
||||
private String accessMode;
|
||||
private String status;
|
||||
private String triggerSource;
|
||||
private String latestStandardNode;
|
||||
private LocalDateTime latestNodeObservedAt;
|
||||
private LocalDateTime stoppedAt;
|
||||
private String stopReason;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private Integer consecutiveFailures;
|
||||
private LocalDateTime windowStartedAt;
|
||||
private Integer attemptCount;
|
||||
private LocalDateTime nextAllowedAt;
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
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,
|
||||
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.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
||||
CURRENT_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
""")
|
||||
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||
}
|
||||
+36
@@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
|
||||
@Select("""
|
||||
@@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
|
||||
CarrierScheduleQueryEntity lockById(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
|
||||
AND container_booking_id=#{containerBookingId}
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""")
|
||||
CarrierScheduleQueryEntity findByBusinessScope(@Param("customerId") long customerId,
|
||||
@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
|
||||
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}
|
||||
""")
|
||||
List<CarrierScheduleQueryEntity> selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit);
|
||||
|
||||
@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')
|
||||
AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
""")
|
||||
int claimLease(@Param("id") long id, @Param("owner") String owner,
|
||||
@Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_carrier_schedule_query
|
||||
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
|
||||
trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode},
|
||||
latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt},
|
||||
stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil},
|
||||
consecutive_failures=#{value.consecutiveFailures},
|
||||
window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount},
|
||||
next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt},
|
||||
last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt},
|
||||
|
||||
+19
@@ -9,14 +9,17 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity
|
||||
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.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 jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public class CarrierScheduleQueryRepository {
|
||||
@@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository {
|
||||
@Resource private CarrierScheduleQueryAttemptMapper attempts;
|
||||
@Resource private CarrierScheduleQueryHistoryMapper histories;
|
||||
@Resource private CarrierScheduleQueryOperationMapper operations;
|
||||
@Resource private CarrierScheduleNodeFactMapper nodes;
|
||||
|
||||
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
|
||||
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); }
|
||||
public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); }
|
||||
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
|
||||
}
|
||||
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
|
||||
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
|
||||
return queries.claimLease(id, owner, now, leaseUntil) == 1;
|
||||
}
|
||||
public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); }
|
||||
public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) {
|
||||
return queries.updateState(value, version, operatorId, operatorName);
|
||||
@@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository {
|
||||
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
|
||||
}
|
||||
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
|
||||
public List<CarrierScheduleNodeFactEntity> nodes(long id) {
|
||||
return nodes.selectList(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, id)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
|
||||
}
|
||||
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(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;
|
||||
}
|
||||
|
||||
+17
@@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry {
|
||||
return required(carrierCode).code();
|
||||
}
|
||||
|
||||
public String resolve(String carrierName) {
|
||||
String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT);
|
||||
return DEFINITIONS.values().stream().filter(value -> value.matches(normalized))
|
||||
.findFirst().map(Definition::code)
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
|
||||
"出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName));
|
||||
}
|
||||
|
||||
private static Definition required(String carrierCode) {
|
||||
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
|
||||
Definition value = DEFINITIONS.get(normalized);
|
||||
@@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry {
|
||||
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
|
||||
coverageGapCode, manualReason);
|
||||
}
|
||||
boolean matches(String value) {
|
||||
return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT))
|
||||
|| "MATSON".equals(code) && value.contains("美森")
|
||||
|| "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE"))
|
||||
|| "ZIM".equals(code) && value.contains("以星")
|
||||
|| "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING"))
|
||||
|| "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN"))
|
||||
|| "HEDE".equals(code) && value.contains("合德");
|
||||
}
|
||||
}
|
||||
|
||||
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
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.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class CarrierScheduleQueryScheduler {
|
||||
private final CarrierScheduleQueryRepository repository;
|
||||
private final CarrierScheduleQueryService service;
|
||||
private final CarrierScheduleIntegrationClient integration;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
private Clock clock = Clock.systemDefaultZone();
|
||||
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20;
|
||||
@Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}")
|
||||
private Duration leaseDuration = Duration.ofMinutes(5);
|
||||
|
||||
public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository,
|
||||
CarrierScheduleQueryService service,
|
||||
CarrierScheduleIntegrationClient integration) {
|
||||
this.repository = repository; this.service = service; this.integration = integration;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}")
|
||||
public void poll() {
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now));
|
||||
}
|
||||
|
||||
/** Executes only after this worker wins the database lease. */
|
||||
void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) {
|
||||
if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return;
|
||||
try {
|
||||
var response = integration.query(internalToken,
|
||||
new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE",
|
||||
"轨迹供应商返回空响应");
|
||||
return;
|
||||
}
|
||||
var result = response.data();
|
||||
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
|
||||
? List.of() : result.observations();
|
||||
for (var observation : observations) {
|
||||
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
|
||||
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
|
||||
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
|
||||
observation.actualAt(), observation.locationCode(), observation.locationName(),
|
||||
observation.sourceReference(), observation.rawPayloadHash()));
|
||||
}
|
||||
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
|
||||
result.errorMessage());
|
||||
} catch (RuntimeException exception) {
|
||||
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
-53
@@ -5,17 +5,22 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
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.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
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.Task;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
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.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
@@ -61,13 +66,148 @@ public class CarrierScheduleQueryService {
|
||||
|
||||
public Detail detail(long id) { return detail(required(id)); }
|
||||
|
||||
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
|
||||
public List<History> history(long id) {
|
||||
required(id);
|
||||
return repository.histories(id);
|
||||
return repository.histories(id).stream().map(this::history).toList();
|
||||
}
|
||||
|
||||
public long customerId(long id) { return required(id).getCustomerId(); }
|
||||
|
||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
||||
@Transactional
|
||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
|
||||
long containerBookingId,
|
||||
long operatorId, String operatorName) {
|
||||
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (existing != null) return existing;
|
||||
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
|
||||
validateLinks(customerId, oceanBookingId, containerBookingId);
|
||||
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
|
||||
String trackingType;
|
||||
String trackingNo;
|
||||
if (StringUtils.hasText(container.getContainerNo())) {
|
||||
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim();
|
||||
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) {
|
||||
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
|
||||
} else if (StringUtils.hasText(booking.getBookingNo())) {
|
||||
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
|
||||
} else {
|
||||
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
|
||||
}
|
||||
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
|
||||
LocalDateTime now = now();
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
|
||||
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
|
||||
value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode);
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM");
|
||||
value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
|
||||
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0);
|
||||
value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
return value;
|
||||
}
|
||||
|
||||
@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,
|
||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||
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);
|
||||
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) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
if ("STOPPED".equals(value.getStatus())) return detail(value);
|
||||
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);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
if ("STOPPED".equals(value.getStatus())) return;
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
LocalDateTime now = now();
|
||||
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now);
|
||||
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
|
||||
value.setLastErrorMessage(trim(errorMessage));
|
||||
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
|
||||
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
|
||||
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
} else {
|
||||
int failures = count(value.getConsecutiveFailures()) + 1;
|
||||
LocalDateTime windowStart = value.getWindowStartedAt();
|
||||
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
windowStart = now; value.setAttemptCount(0);
|
||||
}
|
||||
int attempts = count(value.getAttemptCount()) + 1;
|
||||
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.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
} else {
|
||||
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
|
||||
LocalDateTime retryAt = now.plusMinutes(minutes);
|
||||
if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
retryAt = windowStart.plusDays(WINDOW_DAYS);
|
||||
}
|
||||
value.setNextAllowedAt(retryAt);
|
||||
}
|
||||
}
|
||||
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L,
|
||||
"carrier-scheduler");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
|
||||
String operation = "CREATE";
|
||||
@@ -84,7 +224,8 @@ public class CarrierScheduleQueryService {
|
||||
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim());
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0);
|
||||
value.setStatus("READY"); value.setTriggerSource("MANUAL");
|
||||
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0);
|
||||
value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark()));
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
@@ -94,51 +235,6 @@ public class CarrierScheduleQueryService {
|
||||
return complete(value.getId(), operatorId, key, detail(value));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) {
|
||||
String operation = "ATTEMPT";
|
||||
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
|
||||
if (replay != null) return replay;
|
||||
CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion());
|
||||
LocalDateTime now = now();
|
||||
LocalDateTime windowStart = before.getWindowStartedAt();
|
||||
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
windowStart = now;
|
||||
before.setAttemptCount(0);
|
||||
}
|
||||
if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null
|
||||
&& now.isBefore(before.getNextAllowedAt())) {
|
||||
throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt());
|
||||
}
|
||||
int attemptCount = count(before.getAttemptCount());
|
||||
LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS);
|
||||
if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) {
|
||||
throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd);
|
||||
}
|
||||
PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo());
|
||||
CarrierScheduleQueryEntity snapshot = copy(before);
|
||||
int nextCount = attemptCount + 1;
|
||||
before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode());
|
||||
before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount);
|
||||
before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status());
|
||||
before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode());
|
||||
before.setLastErrorMessage(strategy.manualReason());
|
||||
before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now);
|
||||
if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
|
||||
attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount);
|
||||
attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode());
|
||||
attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status());
|
||||
attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode());
|
||||
attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode());
|
||||
attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName);
|
||||
repository.insert(attempt);
|
||||
addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after,
|
||||
operatorId, operatorName);
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail attachManualResult(long id, long operatorId, String operatorName, String key,
|
||||
ManualResultRequest request) {
|
||||
@@ -155,7 +251,7 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
LocalDateTime now = now();
|
||||
value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId());
|
||||
value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId());
|
||||
value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt());
|
||||
value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED");
|
||||
@@ -212,13 +308,48 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
|
||||
private Detail detail(CarrierScheduleQueryEntity value) {
|
||||
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
|
||||
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
|
||||
repository.histories(value.getId()).stream().map(this::history).toList());
|
||||
}
|
||||
|
||||
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
||||
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
|
||||
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
||||
value.getActualAt(), 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());
|
||||
}
|
||||
|
||||
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
|
||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
|
||||
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));
|
||||
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);
|
||||
return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt());
|
||||
Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(),
|
||||
value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(),
|
||||
value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(),
|
||||
value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(),
|
||||
value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(),
|
||||
value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(),
|
||||
value.getVersion());
|
||||
return new QueryView(task, expired);
|
||||
}
|
||||
|
||||
private CarrierScheduleQueryEntity required(long id) {
|
||||
@@ -234,6 +365,12 @@ public class CarrierScheduleQueryService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private CarrierScheduleQueryEntity requiredLocked(long id) {
|
||||
CarrierScheduleQueryEntity value = repository.lock(id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
|
||||
@@ -241,6 +378,10 @@ public class CarrierScheduleQueryService {
|
||||
value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType());
|
||||
value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl());
|
||||
value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus());
|
||||
value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode());
|
||||
value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt());
|
||||
value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner());
|
||||
value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures());
|
||||
value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
|
||||
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
|
||||
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
|
||||
@@ -259,6 +400,12 @@ public class CarrierScheduleQueryService {
|
||||
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
if (value == null) return null;
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
|
||||
+15
-5
@@ -15,17 +15,27 @@ import java.util.List;
|
||||
public class LoadingCompletedOutboxRepository {
|
||||
@Resource private InboundOutboxMapper mapper;
|
||||
|
||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
|
||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
|
||||
.or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
|
||||
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
|
||||
}
|
||||
public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING")
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id)
|
||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
||||
}
|
||||
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
|
||||
|
||||
@@ -44,7 +44,14 @@ public class ContainerOverviewService {
|
||||
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
|
||||
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
|
||||
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
|
||||
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()),
|
||||
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
|
||||
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
|
||||
booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(),
|
||||
booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(),
|
||||
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
|
||||
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
|
||||
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
|
||||
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
|
||||
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
|
||||
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
|
||||
latestException == null ? null : latestException.reason(),
|
||||
|
||||
+16
-4
@@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay {
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
|
||||
@Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
LocalDateTime now=LocalDateTime.now();
|
||||
for (InboundOutboxEntity candidate:repository.pending(50,now)) {
|
||||
if(!repository.claim(candidate.getId(),workerId,now)) continue;
|
||||
LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10));
|
||||
for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) {
|
||||
if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue;
|
||||
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
|
||||
if(row==null) continue;
|
||||
try {
|
||||
@@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay {
|
||||
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
|
||||
repository.published(row.getId(),workerId,LocalDateTime.now());
|
||||
} catch(Exception exception) {
|
||||
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage());
|
||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
|
||||
String error=safeError(exception);
|
||||
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
|
||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String safeError(Exception exception) {
|
||||
String value=exception.getMessage();
|
||||
if(value==null || value.isBlank()) value=exception.getClass().getSimpleName();
|
||||
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
|
||||
value=value.replaceAll("[\\r\\n\\t]+"," ");
|
||||
return value.length()>500?value.substring(0,500):value;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-9
@@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer {
|
||||
public ContainerBookingEntity consume(EventEnvelope event) {
|
||||
validateEnvelope(event);
|
||||
OverseasContainerUnloadedV1 payload = payload(event);
|
||||
if (payload.containerNo() == null || payload.containerNo().isBlank()
|
||||
|| !payload.containerNo().equals(event.aggregateId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致");
|
||||
if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
|
||||
|| !payload.sourceContainerBookingId().equals(event.aggregateId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致");
|
||||
}
|
||||
if (payload.containerNo() == null || payload.containerNo().isBlank()) {
|
||||
throw new IllegalArgumentException("货柜号不能为空");
|
||||
}
|
||||
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
|
||||
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
|
||||
@@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
|
||||
}
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
ContainerEventInboxEntity row = inbox(event);
|
||||
try {
|
||||
@@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer {
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
|
||||
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
|
||||
ContainerBookingEntity container = requiredContainer(payload.containerNo());
|
||||
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
|
||||
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
|
||||
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
||||
row.setStatus("PROCESSED");
|
||||
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
inbox.updateById(row);
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
|
||||
private void validateEnvelope(EventEnvelope event) {
|
||||
@@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer {
|
||||
return row;
|
||||
}
|
||||
|
||||
private ContainerBookingEntity requiredContainer(String containerNo) {
|
||||
ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo);
|
||||
private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) {
|
||||
long bookingId;
|
||||
try {
|
||||
bookingId = Long.parseLong(sourceContainerBookingId);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception);
|
||||
}
|
||||
ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId);
|
||||
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
|
||||
if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) {
|
||||
throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user