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) {