发布当前测试版本至临时灰度环境 #19
+2
-2
@@ -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) { }
|
||||
}
|
||||
|
||||
+9
@@ -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) { }
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -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);
|
||||
|
||||
@@ -303,6 +303,29 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'}
|
||||
responses:
|
||||
'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:
|
||||
post:
|
||||
summary: 同步 Order 权威协作备注到未冻结预装明细
|
||||
|
||||
+12
@@ -39,9 +39,21 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
private OrderDeclarationSyncService declarationSync;
|
||||
@MockBean
|
||||
private OrderRemarksSyncService remarksSync;
|
||||
@MockBean private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot;
|
||||
@Resource
|
||||
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
|
||||
void shouldHandleCase01() {
|
||||
var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L));
|
||||
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
-3
@@ -36,7 +36,7 @@ class OverseasDeliveryCompletedConsumerTest {
|
||||
when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1);
|
||||
when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0);
|
||||
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));
|
||||
|
||||
@@ -72,10 +72,48 @@ class OverseasDeliveryCompletedConsumerTest {
|
||||
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");
|
||||
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);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000001",
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time,
|
||||
|
||||
@@ -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>
|
||||
<artifactId>order-service</artifactId>
|
||||
<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>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>
|
||||
|
||||
@@ -14,6 +14,13 @@ import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient",
|
||||
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds")
|
||||
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")
|
||||
ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderApprovedInboundCommand command);
|
||||
|
||||
@@ -12,9 +12,17 @@ import java.util.Map;
|
||||
*/
|
||||
@Mapper
|
||||
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")
|
||||
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 COUNT(*) AS total_submitted_orders,
|
||||
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
|
||||
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 DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd");
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private OrderFulfillmentCompletionService fulfillmentCompletion;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private OrderEligibilityGuard eligibilityGuard;
|
||||
@Resource private OrderMasterDataGuard masterDataGuard;
|
||||
@@ -173,6 +174,7 @@ public class OrderApplicationService {
|
||||
@Transactional
|
||||
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason,
|
||||
Integer expectedVersion) {
|
||||
if (target == OrderStatus.CANCELLED) fulfillmentCompletion.lockCancellationRoot(tenantId, id);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
|
||||
@@ -210,6 +212,7 @@ public class OrderApplicationService {
|
||||
if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) {
|
||||
writeOutbox(order, target);
|
||||
}
|
||||
if (target == OrderStatus.CANCELLED) fulfillmentCompletion.afterCancellation(tenantId, id);
|
||||
}
|
||||
|
||||
/** 提交订单的幂等入口。 */
|
||||
|
||||
+115
-19
@@ -25,42 +25,138 @@ public class OrderFulfillmentCompletionService {
|
||||
OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING);
|
||||
@Resource private OrderMapper orders;
|
||||
@Resource private OrderStatusHistoryMapper histories;
|
||||
@Resource private com.baiyue.order.mapper.OutboxEventMapper outbox;
|
||||
@Resource private com.fasterxml.jackson.databind.ObjectMapper json;
|
||||
|
||||
@Transactional
|
||||
public Result complete(Request request) {
|
||||
long tenantId = positive(request.tenantId(), "客户 ID 无效");
|
||||
long orderId = positive(request.orderId(), "订单 ID 无效");
|
||||
OrderEntity order = orders.lockOwned(tenantId, orderId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在");
|
||||
OrderEntity source = orders.findOwned(tenantId, orderId);
|
||||
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())
|
||||
|| !Objects.equals(order.getInboundCode(), request.inboundCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致");
|
||||
}
|
||||
if (order.getStatus() == OrderStatus.COMPLETED) {
|
||||
return new Result(orderId, order.getStatus().name(), true);
|
||||
}
|
||||
if (!COMPLETABLE.contains(order.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus());
|
||||
}
|
||||
OrderStatus from = order.getStatus();
|
||||
requireCompletable(order);
|
||||
requireCompletable(root);
|
||||
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 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)
|
||||
.set(OrderEntity::getStatus, OrderStatus.COMPLETED)
|
||||
.set(OrderEntity::getCompletedAt, completedAt)
|
||||
.set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt)
|
||||
.setSql("version=version+1"));
|
||||
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();
|
||||
history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setOrderId(orderId);
|
||||
history.setFromStatus(from); history.setToStatus(OrderStatus.COMPLETED);
|
||||
history.setAction("OVERSEAS_DELIVERED");
|
||||
history.setReason("海外派送单 " + request.deliveryNo() + " 已签收");
|
||||
history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE");
|
||||
history.setOccurredAt(completedAt); histories.insert(history);
|
||||
return new Result(orderId, OrderStatus.COMPLETED.name(), false);
|
||||
history.setId(IdWorker.getId()); history.setTenantId(order.getTenantId()); history.setOrderId(order.getId());
|
||||
history.setFromStatus(order.getStatus()); history.setToStatus(to); history.setAction(action);
|
||||
history.setReason("海外派送单 " + deliveryNo + " 已签收");
|
||||
history.setOperatorId(0L); history.setOperatorName("OVERSEAS_DELIVERY_EVENT");
|
||||
history.setOccurredAt(at);
|
||||
if (histories.insert(history) != 1) throw new BizException(ResultCode.CONFLICT, "派送完成历史写入失败");
|
||||
}
|
||||
|
||||
private static long positive(String value, String message) {
|
||||
@@ -69,8 +165,8 @@ public class OrderFulfillmentCompletionService {
|
||||
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 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);
|
||||
continue;
|
||||
}
|
||||
if ("FIRST_LEG_ORDER_FULFILLMENT_COMPLETED".equals(event.getEventType())) {
|
||||
publishFulfillmentCompletion(event);
|
||||
continue;
|
||||
}
|
||||
boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType());
|
||||
if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
|
||||
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) {
|
||||
try {
|
||||
OrderDeclarationDtos.UpdatedEvent command = json.readValue(
|
||||
|
||||
@@ -9,10 +9,13 @@ paths:
|
||||
/internal/order/v1/fulfillment-completions:
|
||||
post:
|
||||
security: []
|
||||
summary: Firstleg 回传海外派送完成并结束对应订单
|
||||
summary: Firstleg 回传来源实装签收并聚合履约根订单完成
|
||||
description: >-
|
||||
仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、
|
||||
FBA 派送单号和完成时间;只推进该派送单关联的订单,COMPLETED 重放返回成功。
|
||||
FBA 派送单号和完成时间。来源根订单先持久化自身实装签收事实,只有全部有效余量子单
|
||||
已完成才完成根订单;来源子单独立完成并在根锁下重新评估聚合。明确 CANCELLED 子单
|
||||
不参与完成门禁,其他未完成状态均阻断。重复事实不得重复写历史。响应明确返回来源状态
|
||||
status 和履约根 rootOrderId/rootStatus;根尚未聚合完成时允许保留其原状态。
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
@@ -24,18 +27,36 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [tenantId, orderId, orderNo, inboundCode, deliveryNo, completedAt, eventId, payloadHash]
|
||||
required: [tenantId, orderId, orderNo, inboundCode, rootOrderId, deliveryNo, completedAt, eventId, payloadHash]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
rootOrderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
deliveryNo: {type: string}
|
||||
completedAt: {type: string, format: date-time}
|
||||
eventId: {type: string, format: uuid}
|
||||
payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||
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: 内部服务令牌无效}
|
||||
/internal/order/v1/cargoes/{tenantId}/{orderId}:
|
||||
get:
|
||||
|
||||
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class OrderApplicationServiceStateTest {
|
||||
@MockBean private OrderRepository contextRepository;
|
||||
@MockBean private OrderFulfillmentCompletionService contextCompletion;
|
||||
@MockBean private OrderEligibilityGuard contextEligibility;
|
||||
@MockBean private OrderMasterDataGuard contextMasterData;
|
||||
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
|
||||
@@ -48,6 +49,24 @@ class OrderApplicationServiceStateTest {
|
||||
static class TestConfig {
|
||||
@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
|
||||
void unapprovedTenantCanCreateDraft() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
+11
-3
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
|
||||
class OrderFulfillmentCompletionServiceTest {
|
||||
@Mock private OrderMapper orders;
|
||||
@Mock private OrderStatusHistoryMapper histories;
|
||||
@Mock private com.baiyue.order.mapper.OutboxEventMapper outbox;
|
||||
@InjectMocks private OrderFulfillmentCompletionService service;
|
||||
|
||||
@BeforeAll
|
||||
@@ -45,19 +46,26 @@ class OrderFulfillmentCompletionServiceTest {
|
||||
order.setTenantId(11L);
|
||||
order.setOrderNo("FL201");
|
||||
order.setInboundCode("IN-201");
|
||||
order.setOrderRole("MASTER");
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
order.setVersion(7);
|
||||
when(orders.findOwned(11L, 201L)).thenReturn(order);
|
||||
when(orders.lockOwned(11L, 201L)).thenReturn(order);
|
||||
when(orders.update(isNull(), any(LambdaUpdateWrapper.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(
|
||||
"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"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("COMPLETED");
|
||||
ArgumentCaptor<OrderStatusHistoryEntity> history = ArgumentCaptor.forClass(OrderStatusHistoryEntity.class);
|
||||
verify(histories).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("OVERSEAS_DELIVERED").hasSizeLessThanOrEqualTo(24);
|
||||
verify(histories, org.mockito.Mockito.times(2)).insert(history.capture());
|
||||
assertThat(history.getAllValues()).extracting(OrderStatusHistoryEntity::getAction)
|
||||
.containsExactly("OVERSEAS_DELIVERED", "FULFILLMENT_COMPLETED");
|
||||
assertThat(history.getAllValues()).allSatisfy(row -> assertThat(row.getAction()).hasSizeLessThanOrEqualTo(24));
|
||||
}
|
||||
}
|
||||
|
||||
+222
@@ -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})
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class OrderIdempotencyServiceTest {
|
||||
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
|
||||
|
||||
@MockBean private OrderRepository contextRepository;
|
||||
@MockBean private OrderEligibilityGuard contextEligibility;
|
||||
@MockBean private OrderMasterDataGuard contextMasterData;
|
||||
|
||||
@@ -206,6 +206,41 @@ class OrderInboundOutboxPublisherTest {
|
||||
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) {
|
||||
return fixture(event, "BUY_DECLARATION");
|
||||
}
|
||||
@@ -242,7 +277,7 @@ class OrderInboundOutboxPublisherTest {
|
||||
ReflectionTestUtils.setField(publisher, "repository", repository);
|
||||
ReflectionTestUtils.setField(publisher, "client", client);
|
||||
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
|
||||
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
|
||||
return new Fixture(publisher, repository, client, pricingClient);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import static org.mockito.Mockito.verify;
|
||||
|
||||
@SpringJUnitConfig(classes = {OrderApplicationService.class, PlatformOrderPageServiceTest.TestConfig.class})
|
||||
class PlatformOrderPageServiceTest {
|
||||
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
|
||||
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderEligibilityGuard eligibilityGuard;
|
||||
@MockBean private OrderMasterDataGuard masterDataGuard;
|
||||
|
||||
@@ -19,6 +19,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderCountServiceTest.TestConfig.class})
|
||||
class TenantOrderCountServiceTest {
|
||||
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
|
||||
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderEligibilityGuard eligibilityGuard;
|
||||
@MockBean private OrderMasterDataGuard masterDataGuard;
|
||||
|
||||
@@ -23,6 +23,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class})
|
||||
class TenantOrderStatisticsServiceTest {
|
||||
@MockBean private OrderFulfillmentCompletionService fulfillmentCompletion;
|
||||
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderEligibilityGuard eligibilityGuard;
|
||||
@MockBean private OrderMasterDataGuard masterDataGuard;
|
||||
|
||||
Reference in New Issue
Block a user