This commit is contained in:
2026-08-26 15:24:48 +08:00
parent 3f9ee28d22
commit 4ae236fe11
18 changed files with 198 additions and 92 deletions
@@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -122,19 +123,30 @@ public class CarrierScheduleQueryService {
value.setManualReason(outboundManualReason(strategy, booking));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (concurrent != null) return concurrent;
throw exception;
String persistenceStage = "QUERY_TASK";
try {
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (concurrent != null) return concurrent;
throw exception;
}
persistenceStage = "NODE_FACT";
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) {
persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
}
persistenceStage = "QUERY_HISTORY";
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
operatorId, operatorName);
} catch (DataIntegrityViolationException exception) {
throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage
+ "),请确认OTHER/MANUAL约束及节点事实表已初始化");
}
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;
}
@@ -61,6 +61,25 @@ class CarrierScheduleNodeOutboxServiceTest {
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
CarrierScheduleQueryEntity query = query();
query.setVersion(0);
CarrierScheduleNodeFactEntity fact = fact();
fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10);
fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM");
fact.setProviderNodeCode("OUTBOUND_CONFIRM");
service.publish(query, fact, "CSNU1234567");
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(envelope.aggregateVersion()).isZero();
assertThat(envelope.handoverVersion()).isEqualTo(1L);
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE");
}
private static CarrierScheduleQueryEntity query() {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
@@ -23,6 +23,7 @@ import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.Clock;
import java.time.Instant;
@@ -166,6 +167,26 @@ class CarrierScheduleQueryServiceTest {
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
}
@Test
void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected"))
.when(repository).insert(any(CarrierScheduleQueryEntity.class));
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class)
.hasMessageContaining("船节点持久化契约与当前服务不一致")
.hasMessageContaining("QUERY_TASK")
.hasMessageContaining("OTHER/MANUAL");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
}
@Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);