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, ApiResponse<Result> complete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Request request); @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) { } 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; private OrderDeclarationSyncService declarationSync;
@Resource @Resource
private OrderRemarksSyncService remarksSync; private OrderRemarksSyncService remarksSync;
@Resource private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
private byte[] expectedToken; private byte[] expectedToken;
@@ -56,6 +57,14 @@ public class InternalInboundOrderController {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); 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") @PostMapping("/order-approved")
public ApiResponse<Long> createFromApprovedOrder( public ApiResponse<Long> createFromApprovedOrder(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @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 无效"); long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效");
if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) { if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) {
var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request( 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())); payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash()));
if (response == null || !response.successful() || response.data() == null if (response == null || !response.successful() || response.data() == null
|| !"COMPLETED".equals(response.data().status())) { || !Objects.equals(response.data().orderId(), sourceOrderId)
throw new IllegalStateException("Order 服务未确认来源订单完成"); || !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 completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC);
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)); inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow); inbox.updateById(inboxRow);
return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); 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) { private void validate(EventEnvelope event) {
event.requireValid(); event.requireValid();
if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 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); OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class);
if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo()) if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo())
|| !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo()) || !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; return value;
} catch (Exception exception) { } catch (Exception exception) {
throw new EventProtocolException("海外派送完成事件负载无效", exception); throw new EventProtocolException("海外派送完成事件负载无效", exception);
@@ -303,6 +303,29 @@ paths:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'} schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'}
responses: responses:
'200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409} '200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409}
/internal/firstleg/v1/inbounds/order-fulfillment-completed:
post:
summary: 接收 Order 履约最终完成并幂等同步入仓订单状态摘要
description: >-
由 Order 事务 Outbox 可靠投递,签收聚合和最后有效余量取消都使用同一正式完成事实。
按客户、来源订单号及入仓码核验全部有效入仓已为 DELIVERED 后更新摘要;重复调用不重复递增版本。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tenantId, orderId, orderNo, inboundCode, completedAt]
properties:
tenantId: {type: integer, format: int64, minimum: 1}
orderId: {type: integer, format: int64, minimum: 1}
orderNo: {type: string, minLength: 1}
inboundCode: {type: string, minLength: 1}
completedAt: {type: string, format: date-time}
responses:
'200': {description: data 返回实际更新入仓摘要数量,幂等重放为 0;未签收或来源不符时拒绝写入}
/internal/firstleg/v1/inbounds/order-remarks-updated: /internal/firstleg/v1/inbounds/order-remarks-updated:
post: post:
summary: 同步 Order 权威协作备注到未冻结预装明细 summary: 同步 Order 权威协作备注到未冻结预装明细
@@ -39,9 +39,21 @@ class InternalInboundOrderReferenceControllerTest {
private OrderDeclarationSyncService declarationSync; private OrderDeclarationSyncService declarationSync;
@MockBean @MockBean
private OrderRemarksSyncService remarksSync; private OrderRemarksSyncService remarksSync;
@MockBean private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot;
@Resource @Resource
private InternalInboundOrderController controller; private InternalInboundOrderController controller;
@Test
void completionSnapshotRequiresInternalTokenAndDelegatesExactCommand() {
var command = new com.baiyue.firstleg.dto.OrderFulfillmentCompleted(11, 21,
"FL-21", "IN-21", LocalDateTime.of(2026, 9, 30, 12, 0));
assertThatThrownBy(() -> controller.completeFulfillmentSnapshot("wrong", command)).isInstanceOf(BizException.class);
org.mockito.Mockito.verifyNoInteractions(fulfillmentSnapshot);
when(fulfillmentSnapshot.complete(command)).thenReturn(0);
assertThat(controller.completeFulfillmentSnapshot("internal-token", command).data()).isZero();
verify(fulfillmentSnapshot).complete(command);
}
@Test @Test
void shouldHandleCase01() { void shouldHandleCase01() {
var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L)); var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L));
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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 org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class OrderFulfillmentSnapshotServiceTest {
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "snapshot-test"), InboundOrderEntity.class);
}
@Test void completedAndReplaySyncOnlyAuthenticatedSource() {
var mapper = mock(InboundOrderMapper.class);
when(mapper.selectList(any())).thenReturn(List.of(row("FL21", "IN21", "DELIVERED")));
when(mapper.markOrderSnapshotCompleted(eq(11L),eq(21L),any())).thenReturn(1,0);
var service = new OrderFulfillmentSnapshotService(mapper);
assertThat(service.complete(command())).isEqualTo(1);
assertThat(service.complete(command())).isZero();
var query = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(mapper, times(2)).selectList(query.capture());
assertThat(query.getValue().getSqlSegment()).contains("tenant_id", "order_id", "deleted", "FOR UPDATE");
}
@Test void unDeliveredFragmentBlocksAllSnapshotWrites() {
var mapper = mock(InboundOrderMapper.class);
when(mapper.selectList(any())).thenReturn(List.of(row("FL21", "IN21", "DELIVERED"), row("FL21", "IN21", "LOADED")));
assertThatThrownBy(() -> new OrderFulfillmentSnapshotService(mapper).complete(command())).isInstanceOf(BizException.class);
verify(mapper,never()).markOrderSnapshotCompleted(anyLong(),anyLong(),any());
}
@Test void missingOrForeignSnapshotBlocksAllWrites() {
var mapper = mock(InboundOrderMapper.class);
when(mapper.selectList(any())).thenReturn(List.of(), List.of(row("OTHER", "IN21", "DELIVERED")));
var service = new OrderFulfillmentSnapshotService(mapper);
assertThatThrownBy(() -> service.complete(command())).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.complete(command())).isInstanceOf(BizException.class);
verify(mapper,never()).markOrderSnapshotCompleted(anyLong(),anyLong(),any());
}
private OrderFulfillmentCompleted command() { return new OrderFulfillmentCompleted(11,21,"FL21","IN21", LocalDateTime.of(2026,9,30,12,0)); }
private InboundOrderEntity row(String orderNo, String inboundCode, String process) {
var row = new InboundOrderEntity(); row.setId(1L);row.setTenantId(11L);row.setOrderId(21L);
row.setOrderNo(orderNo);row.setInboundCode(inboundCode);row.setProcessStatus(process); return row;
}
}
@@ -36,7 +36,7 @@ class OverseasDeliveryCompletedConsumerTest {
when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1);
when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0); when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0);
when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success( when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success(
new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false))); new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false, 201L, "COMPLETED")));
consumer.consume(event(json)); consumer.consume(event(json));
@@ -72,10 +72,48 @@ class OverseasDeliveryCompletedConsumerTest {
verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any()); verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any());
} }
private EventEnvelope event(ObjectMapper json) { @Test
void acceptsRootShipmentFactWithoutPrematureCompletedSnapshot() {
verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "PROCESSING", false,
201L, "PROCESSING"), false, false);
}
@Test
void marksOnlyChildWhenRootStillHasOutstandingBranch() {
verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false,
200L, "PROCESSING"), true, false);
}
@Test
void marksChildAndRootOnLastSignedBranch() {
verifyCompletionResponse(new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false,
200L, "COMPLETED"), true, true);
}
private void verifyCompletionResponse(OrderFulfillmentCompletionClient.Result result,
boolean sourceCompleted, boolean rootCompleted) {
InboundOrderMapper inbounds = mock(InboundOrderMapper.class);
OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class);
ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer(inbounds,
mock(InboundStatusHistoryMapper.class), mock(ContainerEventInboxMapper.class), orders, json);
ReflectionTestUtils.setField(consumer, "internalToken", "token");
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L);
inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8");
inbound.setProcessStatus("DELIVERED");
when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound);
when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success(result));
consumer.consume(event(json, result.rootOrderId().toString()));
verify(inbounds, times(sourceCompleted ? 1 : 0)).markOrderSnapshotCompleted(eq(88L), eq(201L), any());
verify(inbounds, times(rootCompleted ? 1 : 0)).markOrderSnapshotCompleted(eq(88L), eq(200L), any());
}
private EventEnvelope event(ObjectMapper json) { return event(json, "201"); }
private EventEnvelope event(ObjectMapper json, String rootOrderId) {
Instant time = Instant.parse("2026-09-15T08:00:00Z"); Instant time = Instant.parse("2026-09-15T08:00:00Z");
var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11",
"101", "IN-1", "201", "FL-201", "201", "ONT8", time); "101", "IN-1", "201", "FL-201", rootOrderId, "ONT8", time);
var node = json.valueToTree(payload); var node = json.valueToTree(payload);
return new EventEnvelope("00000000-0000-4000-8000-000000000001", return new EventEnvelope("00000000-0000-4000-8000-000000000001",
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time,
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent> <parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>order-service</artifactId> <artifactId>order-service</artifactId>
<dependencies> <dependencies>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency> <dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
@@ -14,6 +14,13 @@ import com.baiyue.order.dto.OrderDeclarationDtos;
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient", @FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds") url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds")
public interface FirstlegInboundClient { public interface FirstlegInboundClient {
@PostMapping("/order-fulfillment-completed")
ApiResponse<Integer> completeFulfillmentSnapshot(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody FulfillmentCompletedCommand command);
record FulfillmentCompletedCommand(long tenantId, long orderId, String orderNo, String inboundCode,
LocalDateTime completedAt) { }
@PostMapping("/order-approved") @PostMapping("/order-approved")
ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderApprovedInboundCommand command); @RequestBody OrderApprovedInboundCommand command);
@@ -12,9 +12,17 @@ import java.util.Map;
*/ */
@Mapper @Mapper
public interface OrderMapper extends BaseMapper<OrderEntity>{ public interface OrderMapper extends BaseMapper<OrderEntity>{
@Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0")
OrderEntity findOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE") @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE")
OrderEntity lockOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId); OrderEntity lockOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND parent_order_id=#{rootOrderId} "
+ "AND deleted=0 ORDER BY id FOR UPDATE")
java.util.List<OrderEntity> lockFulfillmentChildren(@Param("tenantId") long tenantId,
@Param("rootOrderId") long rootOrderId);
@Select(""" @Select("""
SELECT COUNT(*) AS total_submitted_orders, SELECT COUNT(*) AS total_submitted_orders,
COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart} COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart}
@@ -7,4 +7,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* 订单状态历史 Mapper,提供 {@code ord_order_status_history} 的 MyBatis-Plus 基础数据访问能力。 * 订单状态历史 Mapper,提供 {@code ord_order_status_history} 的 MyBatis-Plus 基础数据访问能力。
*/ */
@Mapper @Mapper
public interface OrderStatusHistoryMapper extends BaseMapper<OrderStatusHistoryEntity>{} public interface OrderStatusHistoryMapper extends BaseMapper<OrderStatusHistoryEntity>{
@org.apache.ibatis.annotations.Select("SELECT MAX(occurred_at) FROM ord_order_status_history "
+ "WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND action='OVERSEAS_DELIVERED'")
java.time.LocalDateTime overseasDeliveredAt(@org.apache.ibatis.annotations.Param("tenantId") long tenantId,
@org.apache.ibatis.annotations.Param("orderId") long orderId);
}
@@ -46,6 +46,7 @@ public class OrderApplicationService {
private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd");
@Resource private OrderRepository repository; @Resource private OrderRepository repository;
@Resource private OrderFulfillmentCompletionService fulfillmentCompletion;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderMasterDataGuard masterDataGuard;
@@ -173,6 +174,7 @@ public class OrderApplicationService {
@Transactional @Transactional
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason, public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason,
Integer expectedVersion) { Integer expectedVersion) {
if (target == OrderStatus.CANCELLED) fulfillmentCompletion.lockCancellationRoot(tenantId, id);
OrderEntity order = require(tenantId, id); OrderEntity order = require(tenantId, id);
if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) { if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试"); throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
@@ -210,6 +212,7 @@ public class OrderApplicationService {
if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) {
writeOutbox(order, target); writeOutbox(order, target);
} }
if (target == OrderStatus.CANCELLED) fulfillmentCompletion.afterCancellation(tenantId, id);
} }
/** 提交订单的幂等入口。 */ /** 提交订单的幂等入口。 */
@@ -25,42 +25,138 @@ public class OrderFulfillmentCompletionService {
OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING); OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING);
@Resource private OrderMapper orders; @Resource private OrderMapper orders;
@Resource private OrderStatusHistoryMapper histories; @Resource private OrderStatusHistoryMapper histories;
@Resource private com.baiyue.order.mapper.OutboxEventMapper outbox;
@Resource private com.fasterxml.jackson.databind.ObjectMapper json;
@Transactional @Transactional
public Result complete(Request request) { public Result complete(Request request) {
long tenantId = positive(request.tenantId(), "客户 ID 无效"); long tenantId = positive(request.tenantId(), "客户 ID 无效");
long orderId = positive(request.orderId(), "订单 ID 无效"); long orderId = positive(request.orderId(), "订单 ID 无效");
OrderEntity order = orders.lockOwned(tenantId, orderId); OrderEntity source = orders.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); if (source == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在");
long rootId = rootId(source);
if (positive(request.rootOrderId(), "履约根订单 ID 无效") != rootId)
throw new BizException(ResultCode.CONFLICT, "派送完成事件与履约根订单不一致");
// All fulfillment writers serialize on the root before locking a child, matching split creation.
OrderEntity root = orders.lockOwned(tenantId, rootId);
if (root == null || !"MASTER".equals(root.getOrderRole()))
throw new BizException(ResultCode.CONFLICT, "来源订单履约根不存在或层级非法");
OrderEntity order = orderId == rootId ? root : orders.lockOwned(tenantId, orderId);
if (order == null || rootId(order) != rootId)
throw new BizException(ResultCode.CONFLICT, "来源订单履约层级已变化");
if (!Objects.equals(order.getOrderNo(), request.orderNo()) if (!Objects.equals(order.getOrderNo(), request.orderNo())
|| !Objects.equals(order.getInboundCode(), request.inboundCode())) { || !Objects.equals(order.getInboundCode(), request.inboundCode())) {
throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致"); throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致");
} }
if (order.getStatus() == OrderStatus.COMPLETED) { requireCompletable(order);
return new Result(orderId, order.getStatus().name(), true); requireCompletable(root);
}
if (!COMPLETABLE.contains(order.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus());
}
OrderStatus from = order.getStatus();
LocalDateTime completedAt = LocalDateTime.ofInstant(request.completedAt(), ZoneOffset.UTC); LocalDateTime completedAt = LocalDateTime.ofInstant(request.completedAt(), ZoneOffset.UTC);
LocalDateTime ownDeliveredAt = histories.overseasDeliveredAt(tenantId, orderId);
boolean replayed = ownDeliveredAt != null;
if (!replayed) {
if (orderId == rootId) {
// MASTER also carries its first physical shipment: record that fact without completing
// the aggregate while an effective remainder/other shipment is outstanding.
record(order, order.getStatus(), "OVERSEAS_DELIVERED", request.deliveryNo(), completedAt);
} else {
finish(order, completedAt, "OVERSEAS_DELIVERED", request.deliveryNo());
}
}
LocalDateTime rootDeliveredAt = orderId == rootId && !replayed
? completedAt : histories.overseasDeliveredAt(tenantId, rootId);
aggregate(root, order, rootDeliveredAt, request.deliveryNo());
return new Result(orderId, order.getStatus().name(), replayed, rootId, root.getStatus().name());
}
/** Acquire the same root lock before the cancellation writer takes a child row lock. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockCancellationRoot(long tenantId, long orderId) {
OrderEntity source = orders.findOwned(tenantId, orderId);
if (source == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在");
orders.lockOwned(tenantId, rootId(source));
}
/** Called in the cancellation transaction after the valid terminal branch is persisted. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void afterCancellation(long tenantId, long orderId) {
OrderEntity source = orders.findOwned(tenantId, orderId);
if (source == null || !"CHILD".equals(source.getOrderRole())) return;
OrderEntity root = orders.lockOwned(tenantId, rootId(source));
if (root == null || root.getStatus() == OrderStatus.CANCELLED) return;
requireCompletable(root);
aggregate(root, source, histories.overseasDeliveredAt(tenantId, root.getId()), "余量取消后履约汇总");
}
private void aggregate(OrderEntity root, OrderEntity order, LocalDateTime rootDeliveredAt, String deliveryNo) {
long tenantId = root.getTenantId(), rootId = root.getId(), orderId = order.getId();
if (root.getStatus() != OrderStatus.COMPLETED && rootDeliveredAt != null) {
java.util.List<OrderEntity> children = orders.lockFulfillmentChildren(tenantId, rootId);
boolean allComplete = true;
LocalDateTime aggregateAt = rootDeliveredAt;
for (OrderEntity child : children) {
if (!"CHILD".equals(child.getOrderRole()) || !Objects.equals(child.getRootOrderId(), rootId))
throw new BizException(ResultCode.CONFLICT, "余量订单履约层级非法");
// CANCELLED is the only explicit non-effective terminal branch. Unknown, draft,
// rejected or pending statuses must not silently disappear from this gate.
if (child.getStatus() == OrderStatus.CANCELLED) continue;
OrderStatus status = child.getId().equals(orderId) ? order.getStatus() : child.getStatus();
if (status != OrderStatus.COMPLETED) allComplete = false;
LocalDateTime childAt = child.getId().equals(orderId) ? order.getCompletedAt() : child.getCompletedAt();
if (childAt != null && childAt.isAfter(aggregateAt)) aggregateAt = childAt;
}
if (allComplete) finish(root, aggregateAt, "FULFILLMENT_COMPLETED", deliveryNo);
}
}
private static long rootId(OrderEntity order) {
if ("MASTER".equals(order.getOrderRole())) return order.getId();
if (!"CHILD".equals(order.getOrderRole()) || order.getRootOrderId() == null
|| !Objects.equals(order.getRootOrderId(), order.getParentOrderId()))
throw new BizException(ResultCode.CONFLICT, "来源订单履约层级非法");
return order.getRootOrderId();
}
private static void requireCompletable(OrderEntity order) {
if (order.getStatus() != OrderStatus.COMPLETED && !COMPLETABLE.contains(order.getStatus()))
throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus());
}
private void finish(OrderEntity order, LocalDateTime completedAt, String action, String deliveryNo) {
if (order.getStatus() == OrderStatus.COMPLETED) return;
int version = order.getVersion() == null ? 0 : order.getVersion(); int version = order.getVersion() == null ? 0 : order.getVersion();
int updated = orders.update(null, new LambdaUpdateWrapper<OrderEntity>() int updated = orders.update(null, new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId).eq(OrderEntity::getId, orderId) .eq(OrderEntity::getTenantId, order.getTenantId()).eq(OrderEntity::getId, order.getId())
.eq(OrderEntity::getVersion, version).eq(OrderEntity::getDeleted, false) .eq(OrderEntity::getVersion, version).eq(OrderEntity::getDeleted, false)
.set(OrderEntity::getStatus, OrderStatus.COMPLETED) .set(OrderEntity::getStatus, OrderStatus.COMPLETED)
.set(OrderEntity::getCompletedAt, completedAt) .set(OrderEntity::getCompletedAt, completedAt)
.set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt) .set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt)
.setSql("version=version+1")); .setSql("version=version+1"));
if (updated != 1) throw new BizException(ResultCode.CONFLICT, "来源订单版本已变化,请重试"); if (updated != 1) throw new BizException(ResultCode.CONFLICT, "来源订单版本已变化,请重试");
record(order, OrderStatus.COMPLETED, action, deliveryNo, completedAt);
order.setStatus(OrderStatus.COMPLETED); order.setCompletedAt(completedAt); order.setVersion(version + 1);
com.baiyue.order.entity.OutboxEventEntity event = new com.baiyue.order.entity.OutboxEventEntity();
event.setId(IdWorker.getId()); event.setTenantId(order.getTenantId());
event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED");
try {
event.setPayloadJson(json.writeValueAsString(new com.baiyue.order.client.FirstlegInboundClient
.FulfillmentCompletedCommand(order.getTenantId(), order.getId(), order.getOrderNo(),
order.getInboundCode(), completedAt)));
} catch (com.fasterxml.jackson.core.JsonProcessingException failure) {
throw new BizException(ResultCode.INTERNAL_ERROR, "履约完成快照序列化失败");
}
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
if (outbox.insert(event) != 1) throw new BizException(ResultCode.CONFLICT, "履约完成事件写入失败");
}
private void record(OrderEntity order, OrderStatus to, String action, String deliveryNo, LocalDateTime at) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setOrderId(orderId); history.setId(IdWorker.getId()); history.setTenantId(order.getTenantId()); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus(OrderStatus.COMPLETED); history.setFromStatus(order.getStatus()); history.setToStatus(to); history.setAction(action);
history.setAction("OVERSEAS_DELIVERED"); history.setReason("海外派送单 " + deliveryNo + " 已签收");
history.setReason("海外派送单 " + request.deliveryNo() + " 已签收"); history.setOperatorId(0L); history.setOperatorName("OVERSEAS_DELIVERY_EVENT");
history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); history.setOccurredAt(at);
history.setOccurredAt(completedAt); histories.insert(history); if (histories.insert(history) != 1) throw new BizException(ResultCode.CONFLICT, "派送完成历史写入失败");
return new Result(orderId, OrderStatus.COMPLETED.name(), false);
} }
private static long positive(String value, String message) { private static long positive(String value, String message) {
@@ -69,8 +165,8 @@ public class OrderFulfillmentCompletionService {
throw new BizException(ResultCode.BAD_REQUEST, message); throw new BizException(ResultCode.BAD_REQUEST, message);
} }
public record Request(String tenantId, String orderId, String orderNo, String inboundCode, public record Request(String tenantId, String orderId, String orderNo, String inboundCode, String rootOrderId,
String deliveryNo, java.time.Instant completedAt, String eventId, String deliveryNo, java.time.Instant completedAt, String eventId,
String payloadHash) { } String payloadHash) { }
public record Result(Long orderId, String status, boolean replayed) { } public record Result(Long orderId, String status, boolean replayed, Long rootOrderId, String rootStatus) { }
} }
@@ -38,6 +38,10 @@ public class OrderInboundOutboxPublisher {
publishDeclarationUpdate(event); publishDeclarationUpdate(event);
continue; continue;
} }
if ("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED".equals(event.getEventType())) {
publishFulfillmentCompletion(event);
continue;
}
boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType()); boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType());
if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
failPermanently(event, "unsupported order event"); failPermanently(event, "unsupported order event");
@@ -87,6 +91,24 @@ public class OrderInboundOutboxPublisher {
} }
} }
private void publishFulfillmentCompletion(OutboxEventEntity event) {
try {
var command = json.readValue(event.getPayloadJson(), FirstlegInboundClient.FulfillmentCompletedCommand.class);
if (command == null || event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId() || event.getAggregateId() != command.orderId()) {
failPermanently(event, "completion event ownership does not match payload"); return;
}
var response = client.completeFulfillmentSnapshot(internalToken, command);
if (response == null || !response.successful() || response.data() == null)
throw new IllegalStateException("头程履约完成快照同步失败");
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
} catch (com.fasterxml.jackson.core.JsonProcessingException invalid) {
failPermanently(event, "invalid completion event payload");
} catch (Exception failure) {
repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10));
}
}
private void publishDeclarationUpdate(OutboxEventEntity event) { private void publishDeclarationUpdate(OutboxEventEntity event) {
try { try {
OrderDeclarationDtos.UpdatedEvent command = json.readValue( OrderDeclarationDtos.UpdatedEvent command = json.readValue(
+25 -4
View File
@@ -9,10 +9,13 @@ paths:
/internal/order/v1/fulfillment-completions: /internal/order/v1/fulfillment-completions:
post: post:
security: [] security: []
summary: Firstleg 回传海外派送完成并结束对应订单 summary: Firstleg 回传来源实装签收并聚合履约根订单完成
description: >- description: >-
仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、 仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、
FBA 派送单号和完成时间;只推进该派送单关联的订单,COMPLETED 重放返回成功。 FBA 派送单号和完成时间。来源根订单先持久化自身实装签收事实,只有全部有效余量子单
已完成才完成根订单;来源子单独立完成并在根锁下重新评估聚合。明确 CANCELLED 子单
不参与完成门禁,其他未完成状态均阻断。重复事实不得重复写历史。响应明确返回来源状态
status 和履约根 rootOrderId/rootStatus;根尚未聚合完成时允许保留其原状态。
parameters: parameters:
- name: X-Internal-Service-Token - name: X-Internal-Service-Token
in: header in: header
@@ -24,18 +27,36 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [tenantId, orderId, orderNo, inboundCode, deliveryNo, completedAt, eventId, payloadHash] required: [tenantId, orderId, orderNo, inboundCode, rootOrderId, deliveryNo, completedAt, eventId, payloadHash]
properties: properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string} orderNo: {type: string}
inboundCode: {type: string} inboundCode: {type: string}
rootOrderId: {type: string, pattern: '^[1-9][0-9]*$'}
deliveryNo: {type: string} deliveryNo: {type: string}
completedAt: {type: string, format: date-time} completedAt: {type: string, format: date-time}
eventId: {type: string, format: uuid} eventId: {type: string, format: uuid}
payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
responses: responses:
'200': {description: 对应订单已完成或幂等重放成功} '200':
description: 来源实装签收事实已保存,返回来源和履约根各自状态
content:
application/json:
schema:
type: object
required: [code, data]
properties:
code: {type: string}
data:
type: object
required: [orderId, status, replayed, rootOrderId, rootStatus]
properties:
orderId: {type: integer, format: int64}
status: {type: string, enum: [APPROVED, ACCEPTED, PROCESSING, COMPLETED]}
replayed: {type: boolean}
rootOrderId: {type: integer, format: int64}
rootStatus: {type: string, enum: [APPROVED, ACCEPTED, PROCESSING, COMPLETED]}
'403': {description: 内部服务令牌无效} '403': {description: 内部服务令牌无效}
/internal/order/v1/cargoes/{tenantId}/{orderId}: /internal/order/v1/cargoes/{tenantId}/{orderId}:
get: get:
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class OrderApplicationServiceStateTest { class OrderApplicationServiceStateTest {
@MockBean private OrderRepository contextRepository; @MockBean private OrderRepository contextRepository;
@MockBean private OrderFulfillmentCompletionService contextCompletion;
@MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@@ -48,6 +49,24 @@ class OrderApplicationServiceStateTest {
static class TestConfig { static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
} }
@Test
void cancellationLocksRootBeforeSourceWriteAndThenAggregates() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(OrderStatus.APPROVED);
order.setOrderNo("FL-1");
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
OrderFulfillmentCompletionService completion = mock(OrderFulfillmentCompletionService.class);
var app = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(app, "fulfillmentCompletion", completion);
app.transit(10001, 2, "u", 1, OrderStatus.CANCELLED, "取消余量");
var sequence = org.mockito.Mockito.inOrder(repository, completion);
sequence.verify(completion).lockCancellationRoot(10001, 1);
sequence.verify(repository).findOwned(10001, 1);
sequence.verify(repository).saveOrder(10001, order);
sequence.verify(completion).afterCancellation(10001, 1);
}
@Test @Test
void unapprovedTenantCanCreateDraft() { void unapprovedTenantCanCreateDraft() {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
class OrderFulfillmentCompletionServiceTest { class OrderFulfillmentCompletionServiceTest {
@Mock private OrderMapper orders; @Mock private OrderMapper orders;
@Mock private OrderStatusHistoryMapper histories; @Mock private OrderStatusHistoryMapper histories;
@Mock private com.baiyue.order.mapper.OutboxEventMapper outbox;
@InjectMocks private OrderFulfillmentCompletionService service; @InjectMocks private OrderFulfillmentCompletionService service;
@BeforeAll @BeforeAll
@@ -45,19 +46,26 @@ class OrderFulfillmentCompletionServiceTest {
order.setTenantId(11L); order.setTenantId(11L);
order.setOrderNo("FL201"); order.setOrderNo("FL201");
order.setInboundCode("IN-201"); order.setInboundCode("IN-201");
order.setOrderRole("MASTER");
order.setStatus(OrderStatus.PROCESSING); order.setStatus(OrderStatus.PROCESSING);
order.setVersion(7); order.setVersion(7);
when(orders.findOwned(11L, 201L)).thenReturn(order);
when(orders.lockOwned(11L, 201L)).thenReturn(order); when(orders.lockOwned(11L, 201L)).thenReturn(order);
when(orders.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1); when(orders.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
when(histories.insert(any(OrderStatusHistoryEntity.class))).thenReturn(1); when(histories.insert(any(OrderStatusHistoryEntity.class))).thenReturn(1);
when(outbox.insert(any(com.baiyue.order.entity.OutboxEventEntity.class))).thenReturn(1);
org.springframework.test.util.ReflectionTestUtils.setField(service, "json",
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
var result = service.complete(new OrderFulfillmentCompletionService.Request( var result = service.complete(new OrderFulfillmentCompletionService.Request(
"11", "201", "FL201", "IN-201", "DL-201", Instant.parse("2026-09-15T12:00:00Z"), "11", "201", "FL201", "IN-201", "201", "DL-201", Instant.parse("2026-09-15T12:00:00Z"),
"event-201", "sha256:test")); "event-201", "sha256:test"));
assertThat(result.status()).isEqualTo("COMPLETED"); assertThat(result.status()).isEqualTo("COMPLETED");
ArgumentCaptor<OrderStatusHistoryEntity> history = ArgumentCaptor.forClass(OrderStatusHistoryEntity.class); ArgumentCaptor<OrderStatusHistoryEntity> history = ArgumentCaptor.forClass(OrderStatusHistoryEntity.class);
verify(histories).insert(history.capture()); verify(histories, org.mockito.Mockito.times(2)).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("OVERSEAS_DELIVERED").hasSizeLessThanOrEqualTo(24); assertThat(history.getAllValues()).extracting(OrderStatusHistoryEntity::getAction)
.containsExactly("OVERSEAS_DELIVERED", "FULFILLMENT_COMPLETED");
assertThat(history.getAllValues()).allSatisfy(row -> assertThat(row.getAction()).hasSizeLessThanOrEqualTo(24));
} }
} }
@@ -0,0 +1,222 @@
package com.baiyue.order.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.mapper.OrderStatusHistoryMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** Runs production Mapper SQL and service writes with a real local transaction manager. */
class OrderFulfillmentCompletionTransactionTest {
private JdbcTemplate db;
private OrderFulfillmentCompletionService service;
private org.springframework.transaction.support.TransactionTemplate transactions;
private static final Instant ROOT_AT = Instant.parse("2026-09-30T08:00:00Z");
@BeforeEach
void prepare() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
db = new JdbcTemplate(source);
db.execute("CREATE TABLE ord_order (id BIGINT PRIMARY KEY, tenant_id BIGINT, order_no VARCHAR(80), "
+ "inbound_code VARCHAR(80), order_role VARCHAR(16), parent_order_id BIGINT, root_order_id BIGINT, "
+ "status VARCHAR(20), completed_at TIMESTAMP, version INT, deleted BOOLEAN DEFAULT FALSE, "
+ "updated_by BIGINT, updated_at TIMESTAMP)");
db.execute("CREATE TABLE ord_order_status_history (id BIGINT PRIMARY KEY, tenant_id BIGINT, order_id BIGINT, "
+ "from_status VARCHAR(20), to_status VARCHAR(20), action VARCHAR(24), reason VARCHAR(200), "
+ "operator_id BIGINT, operator_name VARCHAR(80), occurred_at TIMESTAMP)");
db.execute("CREATE TABLE ord_outbox_event (id BIGINT PRIMARY KEY, tenant_id BIGINT, aggregate_type VARCHAR(80), "
+ "aggregate_id BIGINT, event_type VARCHAR(80), payload_json CLOB, publish_status VARCHAR(20), "
+ "retry_count INT, created_at TIMESTAMP)");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(OrderMapper.class);
configuration.addMapper(OrderStatusHistoryMapper.class);
configuration.addMapper(com.baiyue.order.mapper.OutboxEventMapper.class);
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source); factory.setConfiguration(configuration);
SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject());
OrderFulfillmentCompletionService target = new OrderFulfillmentCompletionService();
ReflectionTestUtils.setField(target, "orders", session.getMapper(OrderMapper.class));
ReflectionTestUtils.setField(target, "histories", session.getMapper(OrderStatusHistoryMapper.class));
ReflectionTestUtils.setField(target, "outbox", session.getMapper(com.baiyue.order.mapper.OutboxEventMapper.class));
ReflectionTestUtils.setField(target, "json", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
transactions = new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(source));
ProxyFactory proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),
new AnnotationTransactionAttributeSource()));
service = (OrderFulfillmentCompletionService) proxy.getProxy();
master(201, 11);
}
@Test
void rootOwnShipmentKeepsRootOpenUntilAllChildrenDeliverAndReplaysDoNotRewrite() {
child(202, "APPROVED"); child(203, "ACCEPTED");
var root = complete(201, ROOT_AT);
assertThat(root.status()).isEqualTo("PROCESSING");
assertThat(root.rootStatus()).isEqualTo("PROCESSING");
assertThat(history(201, "OVERSEAS_DELIVERED")).isEqualTo(1);
assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero();
assertThat(complete(201, ROOT_AT).replayed()).isTrue();
complete(203, ROOT_AT.plusSeconds(20));
assertThat(status(201)).isEqualTo("PROCESSING");
var last = complete(202, ROOT_AT.plusSeconds(10));
assertThat(last.rootOrderId()).isEqualTo(201L);
assertThat(last.rootStatus()).isEqualTo("COMPLETED");
assertThat(db.queryForObject("SELECT completed_at FROM ord_order WHERE id=201", java.sql.Timestamp.class)
.toLocalDateTime().toInstant(java.time.ZoneOffset.UTC)).isEqualTo(ROOT_AT.plusSeconds(20));
complete(202, ROOT_AT.plusSeconds(10)); complete(203, ROOT_AT.plusSeconds(20)); complete(201, ROOT_AT);
assertThat(history(201, "OVERSEAS_DELIVERED")).isEqualTo(1);
assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1);
assertThat(history(202, "OVERSEAS_DELIVERED")).isEqualTo(1);
assertThat(history(203, "OVERSEAS_DELIVERED")).isEqualTo(1);
}
@Test
void allChildrenFirstCannotCompleteRootWithoutItsOwnSignedFact() {
child(202, "APPROVED"); child(203, "APPROVED");
complete(202, ROOT_AT.plusSeconds(10)); complete(203, ROOT_AT.plusSeconds(20));
assertThat(status(201)).isEqualTo("PROCESSING");
assertThat(history(201, "OVERSEAS_DELIVERED")).isZero();
assertThat(complete(201, ROOT_AT).status()).isEqualTo("COMPLETED");
assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1);
}
@Test
void onlyExplicitCancelledAndDeletedChildrenAreNonEffective() {
child(202, "CANCELLED"); child(203, "DRAFT");
db.update("UPDATE ord_order SET deleted=TRUE WHERE id=203");
master(900, 12);
db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,parent_order_id,root_order_id,status,version) "
+ "VALUES (901,12,'OTHER','OTHER-IN','CHILD',201,201,'DRAFT',0)");
assertThat(complete(201, ROOT_AT).status()).isEqualTo("COMPLETED");
assertThatThrownBy(() -> complete(202, ROOT_AT)).isInstanceOf(BizException.class);
assertThat(history(202, "OVERSEAS_DELIVERED")).isZero();
}
@Test
void draftOrRejectedEffectiveBranchBlocksAggregate() {
child(202, "REJECTED");
assertThat(complete(201, ROOT_AT).rootStatus()).isEqualTo("PROCESSING");
assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero();
}
@Test
void malformedChildIdentityRollsBackNewFactAndChildCompletion() {
child(202, "APPROVED");
db.update("UPDATE ord_order SET root_order_id=999 WHERE id=202");
assertThatThrownBy(() -> complete(201, ROOT_AT)).isInstanceOf(BizException.class);
assertThat(history(201, "OVERSEAS_DELIVERED")).isZero();
assertThat(status(201)).isEqualTo("PROCESSING");
assertThatThrownBy(() -> complete(202, ROOT_AT)).isInstanceOf(BizException.class);
assertThat(status(202)).isEqualTo("APPROVED");
}
@Test
void failedAggregateUpdateRollsBackSourceCompletionAndHistory() {
child(202, "APPROVED");
complete(201, ROOT_AT);
db.execute("ALTER TABLE ord_order ADD CONSTRAINT refuse_root_completion "
+ "CHECK (id<>201 OR status<>'COMPLETED')");
assertThatThrownBy(() -> complete(202, ROOT_AT.plusSeconds(10))).isInstanceOf(RuntimeException.class);
assertThat(status(202)).isEqualTo("APPROVED");
assertThat(history(202, "OVERSEAS_DELIVERED")).isZero();
assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero();
}
@Test
void concurrentLastChildrenSerializeOnRootAndProduceSingleFinalHistory() throws Exception {
child(202, "APPROVED"); child(203, "APPROVED");
complete(201, ROOT_AT);
ExecutorService workers = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<?> first = workers.submit(() -> { await(start); complete(202, ROOT_AT.plusSeconds(10)); });
Future<?> second = workers.submit(() -> { await(start); complete(203, ROOT_AT.plusSeconds(20)); });
start.countDown(); first.get(15, TimeUnit.SECONDS); second.get(15, TimeUnit.SECONDS);
} finally { workers.shutdownNow(); }
assertThat(status(201)).isEqualTo("COMPLETED");
assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1);
assertThat(history(202, "OVERSEAS_DELIVERED")).isEqualTo(1);
assertThat(history(203, "OVERSEAS_DELIVERED")).isEqualTo(1);
}
@Test
void lastRemainderCancellationFinishesSignedRootAndPublishesSnapshotExactlyOnce() {
child(202, "APPROVED");
complete(201, ROOT_AT);
transactions.executeWithoutResult(tx -> {
service.lockCancellationRoot(11, 202);
db.update("UPDATE ord_order SET status='CANCELLED' WHERE id=202");
service.afterCancellation(11, 202);
});
assertThat(status(201)).isEqualTo("COMPLETED");
transactions.executeWithoutResult(tx -> service.afterCancellation(11, 202));
assertThat(history(201, "FULFILLMENT_COMPLETED")).isEqualTo(1);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ord_outbox_event WHERE aggregate_id=201", Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT payload_json FROM ord_outbox_event WHERE aggregate_id=201", String.class))
.contains("FL201", "IN-201");
}
@Test
void cancellingAllChildrenWithoutRootSignedFactCannotCompleteRoot() {
child(202, "APPROVED");
transactions.executeWithoutResult(tx -> {
service.lockCancellationRoot(11, 202);
db.update("UPDATE ord_order SET status='CANCELLED' WHERE id=202");
service.afterCancellation(11, 202);
});
assertThat(status(201)).isEqualTo("PROCESSING");
assertThat(history(201, "FULFILLMENT_COMPLETED")).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ord_outbox_event", Integer.class)).isZero();
}
@Test
void foreignCustomerOrWrongSnapshotCannotWrite() {
assertThatThrownBy(() -> service.complete(new OrderFulfillmentCompletionService.Request(
"12", "201", "FL201", "IN-201", "201", "DL201", ROOT_AT, "event", "hash")))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.complete(new OrderFulfillmentCompletionService.Request(
"11", "201", "WRONG", "IN-201", "201", "DL201", ROOT_AT, "event", "hash")))
.isInstanceOf(BizException.class);
assertThat(history(201, "OVERSEAS_DELIVERED")).isZero();
}
private void master(long id, long tenant) {
db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,status,version) "
+ "VALUES (?,?,?,?,'MASTER','PROCESSING',0)", id, tenant, "FL"+id, "IN-"+id);
}
private void child(long id, String status) {
db.update("INSERT INTO ord_order (id,tenant_id,order_no,inbound_code,order_role,parent_order_id,root_order_id,status,version) "
+ "VALUES (?,11,?,?,'CHILD',201,201,?,0)", id, "FL"+id, "IN-"+id, status);
}
private OrderFulfillmentCompletionService.Result complete(long id, Instant at) {
return service.complete(new OrderFulfillmentCompletionService.Request("11", Long.toString(id),
"FL"+id, "IN-"+id, "201", "DL"+id, at, "event-"+id, "hash-"+id));
}
private String status(long id) { return db.queryForObject("SELECT status FROM ord_order WHERE id=?", String.class, id); }
private int history(long id, String action) {
return db.queryForObject("SELECT COUNT(*) FROM ord_order_status_history WHERE order_id=? AND action=?",
Integer.class, id, action);
}
private static void await(CountDownLatch latch) {
try { latch.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted); }
}
}
@@ -37,6 +37,8 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OrderApplicationService.class, OrderIdempotencyServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {OrderApplicationService.class, OrderIdempotencyServiceTest.TestConfig.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class OrderIdempotencyServiceTest { class OrderIdempotencyServiceTest {
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
@MockBean private OrderRepository contextRepository; @MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData; @MockBean private OrderMasterDataGuard contextMasterData;
@@ -206,6 +206,41 @@ class OrderInboundOutboxPublisherTest {
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any()); verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
} }
@Test
void completionOutboxPublishesAcknowledgedSnapshotIncludingIdempotentZeroUpdate() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L,
completionPayload(11, 21)));
when(fixture.client.completeFulfillmentSnapshot(eq("internal-token"), any())).thenReturn(ApiResponse.success(0));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).completeFulfillmentSnapshot(eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(command -> command.orderId()==21 && command.inboundCode().equals("IN-21")));
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any());
}
@Test
void completionOutboxFailureRetriesOriginalEventAndDoesNotPublish() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L,
completionPayload(11, 21)));
when(fixture.client.completeFulfillmentSnapshot(eq("internal-token"), any())).thenThrow(new IllegalStateException("timeout"));
fixture.publisher.publishApprovedOrders();
verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any());
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
}
@Test
void completionOutboxRejectsForeignPayloadBeforeCallingFirstleg() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED", 11L, 21L,
completionPayload(12, 21)));
fixture.publisher.publishApprovedOrders();
verifyNoInteractions(fixture.client);
verify(fixture.repository).markOutboxFailed(11L, 41L);
}
private String completionPayload(long tenant, long order) {
return "{\"tenantId\":"+tenant+",\"orderId\":"+order
+",\"orderNo\":\"FL-21\",\"inboundCode\":\"IN-21\",\"completedAt\":\"2026-09-30T08:00:00\"}";
}
private Fixture fixture(OutboxEventEntity event) { private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION"); return fixture(event, "BUY_DECLARATION");
} }
@@ -242,7 +277,7 @@ class OrderInboundOutboxPublisherTest {
ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "repository", repository);
ReflectionTestUtils.setField(publisher, "client", client); ReflectionTestUtils.setField(publisher, "client", client);
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient); ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper()); ReflectionTestUtils.setField(publisher, "json", new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token"); ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
return new Fixture(publisher, repository, client, pricingClient); return new Fixture(publisher, repository, client, pricingClient);
} }
@@ -16,6 +16,8 @@ import static org.mockito.Mockito.verify;
@SpringJUnitConfig(classes = {OrderApplicationService.class, PlatformOrderPageServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {OrderApplicationService.class, PlatformOrderPageServiceTest.TestConfig.class})
class PlatformOrderPageServiceTest { class PlatformOrderPageServiceTest {
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
@MockBean private OrderRepository repository; @MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderMasterDataGuard masterDataGuard;
@@ -19,6 +19,8 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderCountServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderCountServiceTest.TestConfig.class})
class TenantOrderCountServiceTest { class TenantOrderCountServiceTest {
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
@MockBean private OrderRepository repository; @MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderMasterDataGuard masterDataGuard;
@@ -23,6 +23,8 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class})
class TenantOrderStatisticsServiceTest { class TenantOrderStatisticsServiceTest {
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
@MockBean private OrderRepository repository; @MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard; @MockBean private OrderMasterDataGuard masterDataGuard;