feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
+5
@@ -106,6 +106,11 @@ public class OutboundDispatchService {
|
||||
throw conflict("当前出仓单不在等待装船状态");
|
||||
}
|
||||
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
|
||||
OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId());
|
||||
if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact(
|
||||
binding.tenantId(), ocean.id(), binding.containerBookingId())) {
|
||||
throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓");
|
||||
}
|
||||
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
|
||||
+16
@@ -70,6 +70,17 @@ public class CarrierScheduleQueryRepository {
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
|
||||
}
|
||||
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
|
||||
public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) {
|
||||
return nodes.selectOne(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
|
||||
}
|
||||
public boolean hasActualNode(long queryId, String standardNode) {
|
||||
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
.eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode)
|
||||
.isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0;
|
||||
}
|
||||
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
|
||||
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
||||
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
|
||||
@@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository {
|
||||
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
|
||||
return exceptions.insertIfAbsent(value) == 1;
|
||||
}
|
||||
public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) {
|
||||
return exceptions.selectOne(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
||||
.eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId)
|
||||
.eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey));
|
||||
}
|
||||
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
|
||||
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
|
||||
}
|
||||
|
||||
+18
-16
@@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler {
|
||||
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()));
|
||||
}
|
||||
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
|
||||
? List.of() : result.exceptions();
|
||||
for (var exception : exceptions) {
|
||||
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
|
||||
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
|
||||
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
|
||||
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
|
||||
if ("SUCCESS".equals(result.outcome())) {
|
||||
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()));
|
||||
}
|
||||
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
|
||||
? List.of() : result.exceptions();
|
||||
for (var exception : exceptions) {
|
||||
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
|
||||
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
|
||||
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
|
||||
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
|
||||
}
|
||||
}
|
||||
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
|
||||
result.errorMessage());
|
||||
|
||||
+41
-8
@@ -59,6 +59,7 @@ public class CarrierScheduleQueryService {
|
||||
@Resource private CarrierScheduleQueryRepository repository;
|
||||
@Resource private PortScheduleRepository portSchedules;
|
||||
@Resource private CarrierScheduleAdapterRegistry adapters;
|
||||
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
|
||||
private String scheduleZone = "Asia/Shanghai";
|
||||
@@ -127,9 +128,10 @@ public class CarrierScheduleQueryService {
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
return value;
|
||||
@@ -140,14 +142,17 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
|
||||
request.providerNodeCode(), node,
|
||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
|
||||
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
||||
if (fact == null) {
|
||||
return detail(value);
|
||||
}
|
||||
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
||||
0L, "carrier-scheduler");
|
||||
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
|
||||
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
|
||||
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
|
||||
after, 0L, "carrier-scheduler");
|
||||
return detail(after);
|
||||
@@ -180,9 +185,11 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||
request.observedAt(),
|
||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||
"MANUAL:" + operatorId, hash(operation, id, request))) {
|
||||
"MANUAL:" + operatorId, hash(operation, id, request));
|
||||
if (fact == null) {
|
||||
return complete(id, operatorId, key, detail(value));
|
||||
}
|
||||
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
||||
@@ -191,6 +198,7 @@ public class CarrierScheduleQueryService {
|
||||
throw concurrent();
|
||||
}
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
|
||||
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
|
||||
operatorId, operatorName);
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
@@ -221,6 +229,12 @@ public class CarrierScheduleQueryService {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
@@ -426,7 +440,8 @@ public class CarrierScheduleQueryService {
|
||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
|
||||
private CarrierScheduleNodeFactEntity 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) {
|
||||
@@ -439,7 +454,12 @@ public class CarrierScheduleQueryService {
|
||||
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);
|
||||
if (repository.insertIfAbsent(fact)) return fact;
|
||||
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
|
||||
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean addException(long queryId, String providerCode, String providerEventId,
|
||||
@@ -454,7 +474,20 @@ public class CarrierScheduleQueryService {
|
||||
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
|
||||
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||
return repository.insertIfAbsent(fact);
|
||||
if (repository.insertIfAbsent(fact)) return true;
|
||||
CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey());
|
||||
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String requireContainerNo(long containerBookingId) {
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (container == null || !StringUtils.hasText(container.getContainerNo())) {
|
||||
throw conflict("跨区船节点交接要求柜次具备柜号");
|
||||
}
|
||||
return container.getContainerNo().trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
|
||||
|
||||
@@ -60,7 +60,8 @@ paths:
|
||||
'200': {description: Business envelope; gate or version failures use business error code}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
|
||||
post:
|
||||
summary: Reverse WAITING_FOR_LOADING to LOADED
|
||||
summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts
|
||||
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
|
||||
+19
@@ -102,6 +102,25 @@ class OutboundDispatchServiceTest {
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
|
||||
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
|
||||
new ActionRequest(11L, 22L, 1, "误操作")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||
|
||||
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest {
|
||||
.contains("outbound-dispatches/check-gate:")
|
||||
.contains("outbound-dispatches/confirm-dispatch:")
|
||||
.contains("outbound-dispatches/reverse-dispatch:")
|
||||
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key");
|
||||
}
|
||||
|
||||
+14
@@ -103,6 +103,20 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception {
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(unified)
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'")
|
||||
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'")
|
||||
.contains("uk_inbound_outbox_event_key")
|
||||
.contains("MODIFY COLUMN payload_hash VARCHAR(71)")
|
||||
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL");
|
||||
}
|
||||
|
||||
private static String moduleSql() throws Exception {
|
||||
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
+4
-2
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -21,7 +22,8 @@ import static org.mockito.Mockito.verify;
|
||||
|
||||
class CarrierScheduleNodeOutboxServiceTest {
|
||||
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
|
||||
|
||||
@BeforeEach
|
||||
@@ -56,7 +58,7 @@ class CarrierScheduleNodeOutboxServiceTest {
|
||||
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
|
||||
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
|
||||
service.publish(query(), fact, "ZCSU7881970");
|
||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any());
|
||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity query() {
|
||||
|
||||
+39
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
class CarrierScheduleQuerySchedulerTest {
|
||||
@Test
|
||||
@@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest {
|
||||
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseLoserNeverCallsProviderOrWritesAttempt() {
|
||||
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
|
||||
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
|
||||
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
|
||||
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
|
||||
CarrierScheduleQueryEntity task = task(1L);
|
||||
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false);
|
||||
|
||||
scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0));
|
||||
|
||||
verify(integration, never()).query(any(), any());
|
||||
verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() {
|
||||
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
|
||||
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
|
||||
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
|
||||
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
|
||||
CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM");
|
||||
task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970");
|
||||
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0);
|
||||
var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY",
|
||||
now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64));
|
||||
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
|
||||
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
|
||||
new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(),
|
||||
"HTTP_503", "unavailable", now)));
|
||||
|
||||
scheduler.execute(task, now);
|
||||
|
||||
verify(service, never()).applyProviderObservation(anyLong(), any());
|
||||
verify(service, never()).applyProviderException(anyLong(), any());
|
||||
verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable");
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity task(long id) {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
|
||||
}
|
||||
|
||||
+66
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
@@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
class CarrierScheduleQueryServiceTest {
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
|
||||
@Mock private CarrierScheduleQueryRepository repository;
|
||||
@Mock private PortScheduleRepository portSchedules;
|
||||
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||
@InjectMocks private CarrierScheduleQueryService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest {
|
||||
when(repository.nodes(anyLong())).thenReturn(List.of());
|
||||
when(repository.exceptions(anyLong())).thenReturn(List.of());
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("ZCSU7881970");
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest {
|
||||
assertThat(task.getNextAllowedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() {
|
||||
CarrierScheduleQueryEntity task = task(0, 4);
|
||||
task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED");
|
||||
task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜");
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
|
||||
|
||||
service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge",
|
||||
"DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2),
|
||||
"USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64)));
|
||||
|
||||
assertThat(task.getStatus()).isEqualTo("STOPPED");
|
||||
assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(task.getNextAllowedAt()).isNull();
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
||||
CarrierScheduleQueryEntity task = task(0, 3);
|
||||
@@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest {
|
||||
assertThat(task.getLatestStandardNode()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() {
|
||||
CarrierScheduleQueryEntity task = task(0, 1);
|
||||
task.setLatestStandardNode("IN_TRANSIT");
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
|
||||
|
||||
for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) {
|
||||
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON",
|
||||
code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64)));
|
||||
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF",
|
||||
code + " RELEASED", code, false, NOW.plusMinutes(1), null, null,
|
||||
"provider:" + code + ":released", "b".repeat(64)));
|
||||
}
|
||||
|
||||
verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class));
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerEventIdReplayRequiresTheSameRawPayloadHash() {
|
||||
CarrierScheduleQueryEntity task = task(0, 1);
|
||||
task.setLatestStandardNode("IN_TRANSIT");
|
||||
CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity();
|
||||
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);
|
||||
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
|
||||
|
||||
service.applyProviderObservation(1L, replay);
|
||||
|
||||
verify(nodeOutbox, never()).publish(any(), any(), anyString());
|
||||
ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64));
|
||||
assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everySchedulerCompletionWritesImmutableAttempt() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
|
||||
Reference in New Issue
Block a user