feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
@@ -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();
}
@@ -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;
}
@@ -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());
@@ -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:
@@ -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());
@@ -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");
}
@@ -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);
@@ -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() {
@@ -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;
}
@@ -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);