merge: sync test and stabilize full acceptance

This commit is contained in:
2026-09-09 21:14:25 +08:00
40 changed files with 595 additions and 134 deletions
@@ -187,7 +187,10 @@ public class OrderApplicationService {
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username);
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order);
if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) {
writeOutbox(order, target);
}
}
/** 提交订单的幂等入口。 */
@@ -668,9 +671,13 @@ public class OrderApplicationService {
private void writeOutbox(OrderEntity order, OrderStatus target) {
OutboxEventEntity event = new OutboxEventEntity();
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED");
event.setEventType(switch (target) {
case SUBMITTED -> "FIRST_LEG_ORDER_PREPARED";
case APPROVED -> "FIRST_LEG_ORDER_APPROVED";
default -> "FIRST_LEG_ORDER_CANCELLED";
});
try {
event.setPayloadJson(target == OrderStatus.APPROVED
event.setPayloadJson(target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED
? json.writeValueAsString(inboundCommand(order, totalPackageCount(order)))
: json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(),
"orderNo", order.getOrderNo(), "status", target.name())));
@@ -699,10 +706,27 @@ public class OrderApplicationService {
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
if (client == null || !StringUtils.hasText(internalToken)) return;
int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount)
.filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
prepareSubmittedInbound(order, packageCount);
}
/**
* 提交即准备稳定的预入库单,供货物提前到仓时采集称重和箱规;
* 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。
*/
private void prepareSubmittedInbound(OrderEntity order) {
long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
.map(OrderCargoEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null);
}
private void prepareSubmittedInbound(OrderEntity order, Integer packageCount) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
if (client == null || !StringUtils.hasText(internalToken)) return;
FirstlegInboundClient.OrderApprovedInboundCommand command = inboundCommand(order, packageCount);
Runnable prepare = () -> {
try {
@@ -17,7 +17,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */
/** 可靠投递订单预入库与审核事件;接收端以订单唯一键保证重复投递安全。 */
@Component
@Slf4j
public class OrderInboundOutboxPublisher {
@@ -38,7 +38,8 @@ public class OrderInboundOutboxPublisher {
publishDeclarationUpdate(event);
continue;
}
if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
boolean preparing = "FIRST_LEG_ORDER_PREPARED".equals(event.getEventType());
if (!preparing && !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
failPermanently(event, "unsupported order event");
continue;
}
@@ -73,7 +74,9 @@ public class OrderInboundOutboxPublisher {
}
}
try {
ApiResponse<Long> response = client.create(internalToken, command);
ApiResponse<Long> response = preparing
? client.prepare(internalToken, command)
: client.create(internalToken, command);
if (response == null || !response.successful()) {
throw new IllegalStateException("头程入库服务返回失败");
}
@@ -65,9 +65,7 @@ public class UnifiedOrderQueryService {
.collect(Collectors.toMap(OrderEntity::getOrderNo, Function.identity(), (left, right) -> left));
List<OrderEntity> selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList();
List<Long> orderIds = selected.stream().map(OrderEntity::getId).toList();
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(null,
selected.stream().filter(order -> order.getStatus() != OrderStatus.SUBMITTED)
.map(OrderEntity::getId).toList());
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(null, orderIds);
Map<Long, String> latestNotes = latestNotes(null, orderIds);
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(null, orderIds);
Map<Long, Integer> childCounts = childCounts(selected);
@@ -87,9 +85,10 @@ public class UnifiedOrderQueryService {
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
List<Long> sourceOrderIds = source.records().stream().map(OrderEntity::getId).toList();
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
? optionalSummaries(tenantId, sourceOrderIds)
: summaries(tenantId, sourceOrderIds);
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId,
source.records().stream().map(OrderEntity::getId).toList());
@@ -118,7 +117,7 @@ public class UnifiedOrderQueryService {
orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(),
orders.findHistory(tenantId, orderId));
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId);
? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId);
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
String creatorName = orderDetail.statusHistory().stream()
.filter(item -> "CREATE".equals(item.getAction()))
@@ -193,6 +192,15 @@ public class UnifiedOrderQueryService {
Function.identity(), (left, right) -> left));
}
private Map<Long, FirstlegUnifiedOrderClient.Summary> optionalSummaries(Long tenantId, List<Long> orderIds) {
try {
return summaries(tenantId, orderIds);
} catch (BizException ignored) {
// Order 待审核列表仍可独立使用;Firstleg 预入库投影恢复后再开放提前测量入口。
return Map.of();
}
}
private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) {
FirstlegUnifiedOrderClient.Detail detail = requireNullable(call(() -> firstleg.detail(internalToken,
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
@@ -200,6 +208,15 @@ public class UnifiedOrderQueryService {
return detail;
}
private FirstlegUnifiedOrderClient.Detail optionalFirstlegDetail(long tenantId, long orderId) {
try {
return firstlegDetail(tenantId, orderId);
} catch (BizException ignored) {
return null;
}
}
private List<FirstlegUnifiedOrderClient.Summary> validateSummaries(
List<FirstlegUnifiedOrderClient.Summary> summaries, String message) {
return summaries.stream().map(summary -> validateSummary(summary, message)).toList();
@@ -1,7 +1,9 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.dto.OrderRequests;
@@ -19,6 +21,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.beans.factory.ObjectProvider;
import java.math.BigDecimal;
import java.time.LocalTime;
@@ -272,6 +275,30 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order);
}
@Test
void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
when(repository.saveOrder(20002L, order)).thenReturn(1);
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
@SuppressWarnings("unchecked")
ObjectProvider<FirstlegInboundClient> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(client);
when(client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L));
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "firstlegProvider", provider);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
verify(client).prepare(eq("internal-token"), argThat(command -> command.orderId() == 1L
&& command.totalPackageCount() == 1));
}
@Test
void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() {
OrderRepository repository = mock(OrderRepository.class);
@@ -23,6 +23,22 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class OrderInboundOutboxPublisherTest {
@Test
void matchingPreparedEventIsDeliveredAndPublished() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_PREPARED", 11L, 21L,
completePayload(11L, 21L, "BUY_DECLARATION")));
when(fixture.client.prepare(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).prepare(eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(command -> command.orderId() == 21L
&& command.totalPackageCount() == 4));
verify(fixture.client, never()).create(any(), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any());
}
@Test
void matchingApprovedEventIsDeliveredAndPublished() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L)));
@@ -81,7 +81,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
assertThat(row.relation()).isNull();
});
verify(firstleg, never()).summaries(any(), any());
verify(firstleg).summaries(eq("internal-token"), any());
}
@Test
@@ -439,7 +439,7 @@ class UnifiedOrderQueryServiceTest {
}
@Test
void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() {
void pendingReviewDetailUsesOrderInboundCodeWhenPreparingProjectionIsUnavailable() {
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setInboundCode("RK-77");
when(orders.findOwned(10001L, 77L)).thenReturn(order);
@@ -452,7 +452,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(result.summary().inboundCode()).isEqualTo("RK-77");
assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77");
verify(firstleg, never()).detail(any(), any());
verify(firstleg).detail(eq("internal-token"), any());
}
@Test