fix(order): complete root fulfillment only after effective remainders

This commit is contained in:
2026-09-30 13:49:40 +08:00
parent bd73c7a326
commit 13028d8a5a
25 changed files with 684 additions and 39 deletions
@@ -15,7 +15,7 @@ public interface OrderFulfillmentCompletionClient {
ApiResponse<Result> complete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Request request);
record Request(String tenantId, String orderId, String orderNo, String inboundCode,
record Request(String tenantId, String orderId, String orderNo, String inboundCode, String rootOrderId,
String deliveryNo, Instant completedAt, String eventId, String payloadHash) { }
record Result(Long orderId, String status, boolean replayed) { }
record Result(Long orderId, String status, boolean replayed, Long rootOrderId, String rootStatus) { }
}
@@ -47,6 +47,7 @@ public class InternalInboundOrderController {
private OrderDeclarationSyncService declarationSync;
@Resource
private OrderRemarksSyncService remarksSync;
@Resource private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@@ -56,6 +57,14 @@ public class InternalInboundOrderController {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/order-fulfillment-completed")
public ApiResponse<Integer> completeFulfillmentSnapshot(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody com.baiyue.firstleg.dto.OrderFulfillmentCompleted command) {
requireToken(token);
return ApiResponse.success(fulfillmentSnapshot.complete(command));
}
@PostMapping("/order-approved")
public ApiResponse<Long> createFromApprovedOrder(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.LocalDateTime;
/** Order authoritative final state, delivered through its transactional Outbox. */
public record OrderFulfillmentCompleted(@Positive long tenantId, @Positive long orderId,
@NotBlank String orderNo, @NotBlank String inboundCode,
@NotNull LocalDateTime completedAt) { }
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderFulfillmentCompleted;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderFulfillmentSnapshotService {
private final InboundOrderMapper inbounds;
@Transactional
public int complete(OrderFulfillmentCompleted command) {
var rows = inbounds.selectList(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, command.tenantId())
.eq(InboundOrderEntity::getOrderId, command.orderId())
.eq(InboundOrderEntity::getDeleted, false).orderByAsc(InboundOrderEntity::getId).last("FOR UPDATE"));
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "来源订单入仓单不存在");
if (rows.stream().anyMatch(row -> !command.orderNo().equals(row.getOrderNo())
|| !command.inboundCode().equals(row.getInboundCode())))
throw new BizException(ResultCode.CONFLICT, "履约完成快照与来源订单标识不一致");
if (rows.stream().anyMatch(row -> !"DELIVERED".equals(row.getProcessStatus())))
throw new BizException(ResultCode.CONFLICT, "来源订单仍有未签收入仓单");
return inbounds.markOrderSnapshotCompleted(command.tenantId(), command.orderId(), command.completedAt());
}
}
@@ -84,20 +84,33 @@ public class OverseasDeliveryCompletedConsumer {
long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效");
if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) {
var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request(
event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(),
event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(), payload.rootOrderId(),
payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash()));
if (response == null || !response.successful() || response.data() == null
|| !"COMPLETED".equals(response.data().status())) {
throw new IllegalStateException("Order 服务未确认来源订单完成");
|| !Objects.equals(response.data().orderId(), sourceOrderId)
|| !Objects.equals(response.data().rootOrderId(), positive(payload.rootOrderId(), "履约根订单 ID 无效"))
|| !completionStatus(response.data().rootStatus())
|| !("COMPLETED".equals(response.data().status())
|| (response.data().rootOrderId() == sourceOrderId
&& Objects.equals(response.data().status(), response.data().rootStatus())
&& completionStatus(response.data().status())))) {
throw new IllegalStateException("Order 服务未确认来源订单履约事实");
}
inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId,
LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC));
LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC);
if ("COMPLETED".equals(response.data().status()))
inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId, completedAt);
if (response.data().rootOrderId() != sourceOrderId && "COMPLETED".equals(response.data().rootStatus()))
inboundOrders.markOrderSnapshotCompleted(tenantId, response.data().rootOrderId(), completedAt);
}
inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow);
return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED);
}
private static boolean completionStatus(String status) {
return status != null && java.util.Set.of("APPROVED", "ACCEPTED", "PROCESSING", "COMPLETED").contains(status);
}
private void validate(EventEnvelope event) {
event.requireValid();
if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1
@@ -114,7 +127,7 @@ public class OverseasDeliveryCompletedConsumer {
OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class);
if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo())
|| !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo())
|| !StringUtils.hasText(value.inboundCode())) throw new EventProtocolException("字段不完整");
|| !StringUtils.hasText(value.inboundCode()) || !StringUtils.hasText(value.rootOrderId())) throw new EventProtocolException("字段不完整");
return value;
} catch (Exception exception) {
throw new EventProtocolException("海外派送完成事件负载无效", exception);