fix(firstleg): decouple manual container nodes from tracking
This commit is contained in:
+1
-23
@@ -14,9 +14,6 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -44,7 +41,6 @@ import java.util.List;
|
||||
@RequestMapping("/api/platform/v1/carrier-schedule-queries")
|
||||
public class PlatformCarrierScheduleQueryController {
|
||||
@Resource private CarrierScheduleQueryService service;
|
||||
@Resource private OutboundDispatchService outboundDispatchService;
|
||||
|
||||
@Operation(summary = "查询六家船公司合规适配策略")
|
||||
@GetMapping("/carriers")
|
||||
@@ -101,23 +97,6 @@ public class PlatformCarrierScheduleQueryController {
|
||||
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/containers/{containerBookingId}/ensure")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@Operation(summary = "为已确认离仓货柜补建船节点任务后进入人工补录")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ENSURE", resourceType = "CONTAINER_BOOKING",
|
||||
resourceId = "#containerBookingId", description = "补建货柜船节点任务")
|
||||
public ApiResponse<Detail> ensureForManualEntry(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var outbound = outboundDispatchService.detail(containerBookingId).aggregate();
|
||||
if (!"WAITING_FOR_LOADING".equals(outbound.dispatchStatus()) || outbound.oceanBookingId() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "货柜尚未确认国内出仓,不能人工补录船节点");
|
||||
}
|
||||
return ApiResponse.success(service.ensureForManualEntry(outbound.oceanBookingId(), containerBookingId,
|
||||
user.userId(), user.username(), key));
|
||||
}
|
||||
|
||||
@PostMapping("/{queryId}/manual-results")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
@@ -132,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
|
||||
|
||||
@PostMapping("/{queryId}/manual-nodes")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@Operation(summary = "人工补录预计或实际标准船节点",
|
||||
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
|
||||
@Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
resourceId = "#queryId", description = "人工补录船节点事实")
|
||||
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/container-shipping-nodes")
|
||||
public class PlatformContainerShippingNodeController {
|
||||
@Resource private CarrierScheduleQueryService service;
|
||||
|
||||
@GetMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<ContainerNodeDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.containerNodeDetail(containerBookingId));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@BusinessAudit(event = "CONTAINER_SHIPPING_NODE_MANUAL", resourceType = "CONTAINER_BOOKING",
|
||||
resourceId = "#containerBookingId", description = "人工修改货柜当前船节点")
|
||||
public ApiResponse<ContainerNodeDetail> add(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ContainerManualNodeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.addContainerManualNode(containerBookingId, user.userId(),
|
||||
user.username(), key, request));
|
||||
}
|
||||
}
|
||||
+11
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
|
||||
@Size(max = 128) String locationName,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
|
||||
public record ContainerManualNodeRequest(@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,
|
||||
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
|
||||
|
||||
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
||||
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
||||
|
||||
public record ExceptionFact(Long id, String providerCode, String providerEventId,
|
||||
String providerStatus, String exceptionCode, boolean active,
|
||||
LocalDateTime observedAt, String locationCode, String locationName,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
|
||||
public class CarrierScheduleNodeFactEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long queryId;
|
||||
private Long containerBookingId;
|
||||
private String eventKey;
|
||||
private String providerCode;
|
||||
private String providerEventId;
|
||||
|
||||
+12
-2
@@ -5,19 +5,29 @@ 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;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||
@Insert("""
|
||||
INSERT INTO fl_carrier_schedule_node_fact
|
||||
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
||||
(id,query_id,container_booking_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.providerEventId},#{value.providerNodeCode},
|
||||
(#{value.id},#{value.queryId},#{value.containerBookingId},#{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))
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
""")
|
||||
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||
|
||||
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} ORDER BY observed_at,id")
|
||||
List<CarrierScheduleNodeFactEntity> selectByContainer(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} AND event_key=#{eventKey} LIMIT 1")
|
||||
CarrierScheduleNodeFactEntity selectByContainerEvent(@Param("containerBookingId") long containerBookingId,
|
||||
@Param("eventKey") String eventKey);
|
||||
}
|
||||
|
||||
+14
-14
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,
|
||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
q.status AS queryStatus,current.standard_node AS latestActualNode,
|
||||
current.actual_at AS latestActualAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
FROM fl_carrier_schedule_query q
|
||||
JOIN (
|
||||
SELECT container_booking_id,MAX(id) AS query_id
|
||||
FROM fl_carrier_schedule_query
|
||||
WHERE container_booking_id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY container_booking_id
|
||||
) latest ON latest.query_id=q.id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
|
||||
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
|
||||
ORDER BY q.container_booking_id
|
||||
FROM fl_container_booking b
|
||||
JOIN fl_carrier_schedule_node_fact current ON current.id=(
|
||||
SELECT f2.id FROM fl_carrier_schedule_node_fact f2
|
||||
WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
|
||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
List<ContainerActualFacts> selectContainerActualFacts(
|
||||
|
||||
+6
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
|
||||
}
|
||||
public List<CarrierScheduleNodeFactEntity> nodesByContainer(long containerBookingId) {
|
||||
return nodes.selectByContainer(containerBookingId);
|
||||
}
|
||||
public CarrierScheduleNodeFactEntity nodeByContainer(long containerBookingId, String eventKey) {
|
||||
return nodes.selectByContainerEvent(containerBookingId, eventKey);
|
||||
}
|
||||
public boolean hasActualNode(long queryId, String standardNode) {
|
||||
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
|
||||
+18
-6
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|
||||
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
|
||||
}
|
||||
publish(query.getId(), query.getOceanBookingId(), query.getContainerBookingId(), query.getVersion(),
|
||||
fact, normalizedContainerNo, "CARRIER_NODE:" + query.getId() + ':' + fact.getId());
|
||||
}
|
||||
|
||||
public void publishContainerNode(Long oceanBookingId, long containerBookingId,
|
||||
CarrierScheduleNodeFactEntity fact, String containerNo) {
|
||||
if (fact.getActualAt() == null) return;
|
||||
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
|
||||
publish(null, oceanBookingId, containerBookingId, 0, fact, normalizedContainerNo,
|
||||
"CONTAINER_NODE:" + containerBookingId + ':' + fact.getId());
|
||||
}
|
||||
|
||||
private void publish(Long queryId, Long oceanBookingId, Long containerBookingId, Integer version,
|
||||
CarrierScheduleNodeFactEntity fact, String normalizedContainerNo, String eventKey) {
|
||||
ZoneId zone = ZoneId.of(scheduleZone);
|
||||
var payload = new FirstLegContainerShippingNodeRecordedV1(
|
||||
string(query.getId()), string(query.getOceanBookingId()),
|
||||
string(query.getContainerBookingId()), normalizedContainerNo,
|
||||
string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
|
||||
fact.getStandardNode(), fact.getNodeSequence(),
|
||||
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
|
||||
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
|
||||
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
|
||||
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
|
||||
long aggregateVersion = Math.max(0, version == null ? 0 : version);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()),
|
||||
"0", "CONTAINER_BOOKING", string(containerBookingId),
|
||||
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
|
||||
payloadNode, payloadHash).requireValid();
|
||||
|
||||
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||
row.setId(IdWorker.getId()); row.setTenantId(0L);
|
||||
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
|
||||
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(containerBookingId);
|
||||
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
|
||||
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
|
||||
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
|
||||
|
||||
+54
-20
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -147,7 +150,7 @@ public class CarrierScheduleQueryService {
|
||||
throw exception;
|
||||
}
|
||||
persistenceStage = "NODE_FACT";
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) {
|
||||
@@ -164,13 +167,6 @@ public class CarrierScheduleQueryService {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail ensureForManualEntry(long oceanBookingId, long containerBookingId,
|
||||
long operatorId, String operatorName, String key) {
|
||||
validateKey(key);
|
||||
return detail(ensureFromOutboundConfirm(null, oceanBookingId, containerBookingId, operatorId, operatorName));
|
||||
}
|
||||
|
||||
private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) {
|
||||
if (!"MANUAL_REQUIRED".equals(strategy.status())) return null;
|
||||
String reason = strategy.manualReason();
|
||||
@@ -185,7 +181,7 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
||||
request.providerNodeCode(), node,
|
||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
||||
@@ -226,10 +222,9 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
requireManualActualAdvance(value, node, request.actualAt());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||
request.observedAt(),
|
||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||
"MANUAL:" + operatorId, hash(operation, id, request));
|
||||
@@ -248,13 +243,42 @@ public class CarrierScheduleQueryService {
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
}
|
||||
|
||||
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
LocalDateTime actualAt) {
|
||||
if (actualAt == null || value.getLatestStandardNode() == null) return;
|
||||
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||
if (node.sequence() <= current.sequence()) {
|
||||
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
|
||||
public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
|
||||
if (portSchedules.containerBooking(containerBookingId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
}
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ContainerManualNodeRequest request) {
|
||||
validateKey(key);
|
||||
if (request.estimatedAt() == null && request.actualAt() == null) {
|
||||
throw badRequest("estimatedAt和actualAt至少提供一个");
|
||||
}
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
|
||||
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
|
||||
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
||||
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
|
||||
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
|
||||
fact, normalizedContainerNo(container));
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
private ContainerNodeDetail containerNodeDetail(long containerBookingId,
|
||||
List<CarrierScheduleNodeFactEntity> facts) {
|
||||
CarrierScheduleNodeFactEntity current = facts.stream()
|
||||
.max(Comparator.comparing(CarrierScheduleNodeFactEntity::getObservedAt)
|
||||
.thenComparing(CarrierScheduleNodeFactEntity::getId)).orElse(null);
|
||||
return new ContainerNodeDetail(containerBookingId, current == null ? null : current.getStandardNode(),
|
||||
current == null ? null : current.getActualAt() != null ? current.getActualAt() : current.getEstimatedAt(),
|
||||
facts.stream().map(this::node).toList());
|
||||
}
|
||||
|
||||
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
@@ -500,13 +524,23 @@ public class CarrierScheduleQueryService {
|
||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
|
||||
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
||||
String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
||||
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
||||
sourceReference, rawPayloadHash);
|
||||
}
|
||||
|
||||
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
||||
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.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
|
||||
fact.setProviderEventId(providerEventId.trim());
|
||||
@@ -515,7 +549,7 @@ public class CarrierScheduleQueryService {
|
||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||
if (repository.insertIfAbsent(fact)) return fact;
|
||||
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
|
||||
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
||||
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
|
||||
}
|
||||
|
||||
+11
-1
@@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id=#{containerBookingId} FOR UPDATE
|
||||
WHERE b.id=#{containerBookingId}
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
FOR UPDATE
|
||||
""")
|
||||
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id=#{containerBookingId}
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
""")
|
||||
Context context(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
|
||||
+18
-16
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
|
||||
if (context.facts().arrivedDestinationPortAt() != null
|
||||
if (context.facts() != null && context.facts().arrivedDestinationPortAt() != null
|
||||
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
|
||||
throw badRequest("预约提柜时间不能早于实际到港时间");
|
||||
}
|
||||
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
|
||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||
}
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
|
||||
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
if (context.facts() != null && context.facts().latestActualAt() != null
|
||||
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
@@ -192,14 +193,11 @@ public class TerminalDrayageService {
|
||||
|
||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
String action, String key, String reason, long operatorId, String operatorName) {
|
||||
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
|
||||
throw conflict("货柜尚未建立船节点跟踪任务");
|
||||
}
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current != null && current.sequence() >= target.sequence()) return;
|
||||
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
|
||||
carrierSchedules.addContainerManualNode(context.base().containerBookingId(), operatorId, operatorName,
|
||||
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
|
||||
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||
new ContainerManualNodeRequest(target.name(), LocalDateTime.now(clock), null,
|
||||
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
|
||||
}
|
||||
|
||||
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
|
||||
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
||||
&& actualPickupAt(context, value) == null) {
|
||||
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
|
||||
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
|
||||
if (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
||||
}
|
||||
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
|
||||
}
|
||||
|
||||
@@ -330,13 +328,18 @@ public class TerminalDrayageService {
|
||||
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
|
||||
}
|
||||
|
||||
private CarrierScheduleNode currentNode(WorkContext context) {
|
||||
return context.facts() == null ? null : node(context.facts().latestActualNode());
|
||||
}
|
||||
|
||||
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
|
||||
: context.facts() == null ? null : context.facts().pickedUpAt();
|
||||
}
|
||||
|
||||
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualEmptyReturnAt() != null
|
||||
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
|
||||
? value.getActualEmptyReturnAt() : context.facts() == null ? null : context.facts().emptyReturnedAt();
|
||||
}
|
||||
|
||||
private WorkContext requiredContext(long id) {
|
||||
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private WorkContext context(Context base, ContainerActualFacts facts) {
|
||||
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|
||||
|| facts.departedWarehouseAt() == null) return null;
|
||||
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
|
||||
return new WorkContext(base, facts);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
|
||||
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
|
||||
query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
|
||||
container_booking_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',
|
||||
@@ -130,9 +131,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
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_node_event (query_id,event_key),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
|
||||
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_carrier_schedule_node_standard CHECK
|
||||
(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')),
|
||||
|
||||
@@ -65,17 +65,6 @@ paths:
|
||||
'200':
|
||||
description: Stable task detail
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
|
||||
/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:
|
||||
post:
|
||||
summary: Idempotently ensure the carrier task for a departed container before manual entry
|
||||
x-permission: platform:firstleg:port-schedule-query:manual
|
||||
parameters:
|
||||
- {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
responses:
|
||||
'200':
|
||||
description: Existing or newly created task detail; platform-owned consolidated bookings use customerId 0
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
|
||||
/api/platform/v1/carrier-schedule-queries/{queryId}/history:
|
||||
parameters: [{$ref: '#/components/parameters/QueryId'}]
|
||||
get:
|
||||
|
||||
@@ -14,6 +14,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||
get:
|
||||
summary: 查询单柜码头拖车详情
|
||||
description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
@@ -63,6 +64,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/emails:
|
||||
get:
|
||||
summary: 查询最近100封车队邮件及发信开关
|
||||
description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses:
|
||||
|
||||
-4
@@ -28,8 +28,6 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.contains("Estimated facts are never returned or inferred")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:")
|
||||
.contains("platform-owned consolidated bookings use customerId 0")
|
||||
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
|
||||
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
|
||||
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
|
||||
@@ -40,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
|
||||
.contains("latestActualNode:")
|
||||
.contains("latestActualAt:")
|
||||
.contains("manual actual node is equal to or earlier than latestActualNode")
|
||||
.contains("strictly greater sequence than latestActualNode")
|
||||
.contains("internal execution fields are intentionally absent")
|
||||
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
|
||||
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
"detail", "platform:firstleg:port-schedule-query:list",
|
||||
"history", "platform:firstleg:port-schedule-query:list",
|
||||
"create", "platform:firstleg:port-schedule-query:create",
|
||||
"ensureForManualEntry", "platform:firstleg:port-schedule-query:manual",
|
||||
"manualResult", "platform:firstleg:port-schedule-query:manual",
|
||||
"manualNode", "platform:firstleg:port-schedule-query:manual");
|
||||
permissions.forEach((name, permission) -> {
|
||||
@@ -31,7 +30,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (name.equals("create") || name.equals("ensureForManualEntry") || name.equals("manualResult") || name.equals("manualNode")) {
|
||||
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
|
||||
+1
-29
@@ -451,34 +451,6 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
|
||||
CarrierScheduleQueryEntity task = task(0, 3);
|
||||
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
|
||||
task.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
|
||||
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
|
||||
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
|
||||
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
|
||||
"Los Angeles", "倒序补录可提柜")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class));
|
||||
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(nodeOutbox, never()).publish(any(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
|
||||
CarrierScheduleQueryEntity before = task(0, 3);
|
||||
@@ -549,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
|
||||
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
|
||||
when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing);
|
||||
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
|
||||
|
||||
|
||||
+16
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.file.Files;
|
||||
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
|
||||
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
|
||||
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception {
|
||||
var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
|
||||
for (String methodName : java.util.List.of("context", "lockContext", "contexts")) {
|
||||
var method = java.util.Arrays.stream(mapper.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
String sql = String.join("\n", method.getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL",
|
||||
"d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String permission) {
|
||||
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
|
||||
+11
-3
@@ -92,19 +92,27 @@ class ContainerWorkbenchServiceTest {
|
||||
"platform:firstleg:terminal-drayage:document:edit");
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of());
|
||||
when(terminalDrayages.authorize(List.of(), permissions)).thenReturn(List.of());
|
||||
var waiting = waitingDrayage();
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting));
|
||||
when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting));
|
||||
|
||||
var result = service.overseasDetail(11L, permissions);
|
||||
|
||||
assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
assertThat(result.actualFacts()).isNull();
|
||||
assertThat(result.terminalDrayage()).isNull();
|
||||
assertThat(result.terminalDrayage()).isEqualTo(waiting);
|
||||
assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS");
|
||||
verify(terminalDrayages, never()).detail(11L);
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() {
|
||||
return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View(
|
||||
11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null,
|
||||
null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning(
|
||||
"NOT_APPLICABLE", null, 0, 0), List.of(), null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, true);
|
||||
|
||||
+24
-11
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
|
||||
|
||||
assertThat(result.status()).isEqualTo("PICKED_UP");
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
|
||||
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
|
||||
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
@@ -144,7 +144,7 @@ class TerminalDrayageServiceTest {
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("海外卸柜收货完成后才能确认还柜");
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
|
||||
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
|
||||
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
|
||||
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
when(mapper.context(11L)).thenReturn(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null));
|
||||
when(mapper.find(11L)).thenReturn(null);
|
||||
|
||||
assertThat(service.views(List.of(11L))).isEmpty();
|
||||
verify(carrierSchedules).containerActualFacts(List.of(11L));
|
||||
var bulk = service.views(List.of(11L));
|
||||
var detail = service.detail(11L);
|
||||
|
||||
assertThat(bulk).singleElement().satisfies(view -> {
|
||||
assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL");
|
||||
assertThat(view.actualFacts()).isNull();
|
||||
assertThat(view.allowedActions()).isEmpty();
|
||||
});
|
||||
assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL");
|
||||
assertThat(detail.actualFacts()).isNull();
|
||||
assertThat(detail.allowedActions()).isEmpty();
|
||||
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
|
||||
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(node.getValue().reason()).isEqualTo("已还柜");
|
||||
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
|
||||
Reference in New Issue
Block a user