fix(order): complete root fulfillment only after effective remainders
This commit is contained in:
+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,
|
||||
|
||||
Reference in New Issue
Block a user