fix(order): project exact customer fulfillment progress in detail

This commit is contained in:
2026-10-15 19:46:34 +08:00
parent 097bec33b1
commit ae33780dae
27 changed files with 391 additions and 50 deletions
@@ -20,7 +20,7 @@ public interface FirstlegUnifiedOrderClient {
ApiResponse<WorkbenchFilters> workbenchFilters(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody WorkbenchFilterRequest request);
record WorkbenchFilterRequest(long tenantId, String mainStatus) {}
record WorkbenchFilterRequest(long tenantId, String mainStatus, List<Long> orderIds) {}
record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds, List<com.baiyue.order.dto.MerchantOrderProgress> progress) {}
@@ -11,6 +11,6 @@ import java.util.List;
public interface LastlegOrderProgressClient {
@PostMapping
ApiResponse<Response> read(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request);
record Request(long customerId) {}
record Request(long customerId, List<Long> orderIds) {}
record Response(List<MerchantOrderProgress> progress) {}
}
@@ -51,10 +51,13 @@ public class MerchantUnifiedOrderController {
@GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。")
public ApiResponse<UnifiedOrderResponse.Detail> detail(
public ApiResponse<UnifiedOrderResponse.MerchantDetail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId) {
return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false));
long customerId=OrderScopeGuard.merchant(user);
var detail=service.detail(customerId,orderId,false);
return ApiResponse.success(new UnifiedOrderResponse.MerchantDetail(detail.summary(),detail.order(),detail.timeline(),
detail.transportFragments(),workbench.detailProgress(customerId,orderId)));
}
@GetMapping("/{orderId}/fulfillment-tree")
@@ -50,6 +50,9 @@ public final class UnifiedOrderResponse {
public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline,
List<TransportFragment> transportFragments) {}
public record MerchantDetail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline, List<TransportFragment> transportFragments,
MerchantOrderProgress progress) {}
public record TransportFragment(String inboundCode, String cabinetNo, String containerNo,
List<TransportNode> nodes) {}
public record TransportNode(String standardNode, Integer nodeSequence, String providerCode,
@@ -51,6 +51,17 @@ public class MerchantOrderWorkbenchRepository {
return mapper.selectList(new QueryWrapper<OrderEntity>().select("id","root_order_id","status")
.eq("tenant_id",customerId).ne("status","CANCELLED"));
}
/** Exact owned order and, only when it is a root, its own customer's active fragments. */
public List<OrderEntity> detailProgressScope(long customerId,long orderId) {
var selected=mapper.selectOne(new QueryWrapper<OrderEntity>().select("id","root_order_id","status")
.eq("tenant_id",customerId).eq("id",orderId));
if(selected==null) return List.of();
if(selected.getRootOrderId()!=null && selected.getRootOrderId()!=orderId) return List.of(selected);
var scope=new java.util.ArrayList<OrderEntity>();scope.add(selected);
scope.addAll(mapper.selectList(new QueryWrapper<OrderEntity>().select("id","root_order_id","status")
.eq("tenant_id",customerId).eq("root_order_id",orderId).ne("id",orderId).ne("status","CANCELLED").orderByAsc("id")));
return List.copyOf(scope);
}
private long progressCount(QueryWrapper<OrderEntity> base,java.util.Map<String,List<Long>> progressIds,String stage) {
return mapper.selectCount(ids(base.clone(),progressIds.get(stage)));
@@ -42,12 +42,12 @@ public class MerchantOrderWorkbenchService {
List<MerchantOrderProgress> tailProgress;
try {
var response=firstleg.workbenchFilters(internalToken,
new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(customerId,status));
new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(customerId,status,null));
if(response==null||!response.successful()||response.data()==null) throw new IllegalStateException();
filters=response.data();
if(filters.feeOrderIds()==null||filters.customsOrderIds()==null||filters.progress()==null
||status!=null&&filters.matchingStatusOrderIds()==null) throw new IllegalStateException();
var tail=lastleg.read(internalToken,new LastlegOrderProgressClient.Request(customerId));
var tail=lastleg.read(internalToken,new LastlegOrderProgressClient.Request(customerId,null));
if(tail==null||!tail.successful()||tail.data()==null||tail.data().progress()==null) throw new IllegalStateException();
tailProgress=tail.data().progress();
validateFacts(filters.progress(),1,3);
@@ -66,6 +66,34 @@ public class MerchantOrderWorkbenchService {
source.records().stream().map(o->progress.getOrDefault(o.getId(),new MerchantOrderProgress(o.getId(),"none",false))).toList());
}
@Transactional(readOnly=true)
public MerchantOrderProgress detailProgress(long customerId,long orderId) {
var scope=repository.detailProgressScope(customerId,orderId);
var selected=scope.stream().filter(o->o.getId()==orderId).findFirst()
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"订单不存在"));
if(selected.getStatus()==OrderStatus.CANCELLED) return new MerchantOrderProgress(orderId,"none",false);
if(Set.of(OrderStatus.DRAFT,OrderStatus.SUBMITTED,OrderStatus.COMPLETED).contains(selected.getStatus()))
return project(scope,List.of(),List.of()).get(orderId);
var firstFacts=new ArrayList<MerchantOrderProgress>();var tailFacts=new ArrayList<MerchantOrderProgress>();
var ids=scope.stream().map(OrderEntity::getId).toList();
try {
for(int start=0;start<ids.size();start+=200) {
var batch=ids.subList(start,Math.min(start+200,ids.size()));
var first=firstleg.workbenchFilters(internalToken,new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(customerId,null,batch));
if(first==null || !first.successful() || first.data()==null || first.data().progress()==null) throw new IllegalStateException();
var tail=lastleg.read(internalToken,new LastlegOrderProgressClient.Request(customerId,batch));
if(tail==null || !tail.successful() || tail.data()==null || tail.data().progress()==null) throw new IllegalStateException();
validateFacts(first.data().progress(),1,3);validateFacts(tail.data().progress(),4,5);
if(Stream.concat(first.data().progress().stream(),tail.data().progress().stream()).anyMatch(p->!batch.contains(p.orderId())))
throw new IllegalStateException("Progress response exceeds owned order scope");
firstFacts.addAll(first.data().progress());tailFacts.addAll(tail.data().progress());
}
} catch(RuntimeException failure) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单进度查询暂时不可用,请重试");
}
return project(scope,firstFacts,tailFacts).get(orderId);
}
private static void validateFacts(List<MerchantOrderProgress> facts, int min, int max) {
var seen=new java.util.HashSet<Long>();
for(var fact:facts) if(fact==null||fact.rank()<min||fact.rank()>max||!seen.add(fact.orderId()))
+23 -9
View File
@@ -58,13 +58,7 @@ paths:
progress:
type: array
description: 与当前页订单一一对应的客户履约进度;分类互斥,待办可重叠。分片未齐时partial为true,完成只认Order可信回写。
items:
type: object
required: [orderId, stage, partial]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
stage: {type: string, enum: [none, loaded, declared, cleared, scheduled, delivering, delivered]}
partial: {type: boolean}
items: {$ref: '#/components/schemas/MerchantOrderProgress'}
/internal/order/v1/fulfillment-completions:
post:
@@ -538,7 +532,7 @@ paths:
/api/merchant/v1/orders/{orderId}:
get:
summary: 客户统一订单详情
description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。
description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。progress使用与工作台相同的权威投影,按认证客户和精确订单读取;根单只汇总本客户有效子单,子单只投影自身。依赖不可用时失败,不回退delivery快照。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
responses:
@@ -546,7 +540,7 @@ paths:
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/UnifiedOrderDetailEnvelope'}
schema: {$ref: '#/components/schemas/MerchantOrderDetailEnvelope'}
'403': {description: 需要 merchant:order:view 权限}
/api/merchant/v1/orders/first-leg:
get:
@@ -1138,6 +1132,26 @@ components:
size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
MerchantOrderProgress:
type: object
required: [orderId, stage, partial]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
stage: {type: string, enum: [none, loaded, declared, cleared, scheduled, delivering, delivered]}
partial: {type: boolean}
MerchantOrderDetailEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/MerchantOrderDetail'}
MerchantOrderDetail:
allOf:
- {$ref: '#/components/schemas/UnifiedOrderDetail'}
- type: object
required: [progress]
properties:
progress: {$ref: '#/components/schemas/MerchantOrderProgress'}
UnifiedOrderDetailEnvelope:
type: object
properties:
@@ -67,4 +67,28 @@ class MerchantOrderWorkbenchControllerTest {
assertThat(captured.getValue().page()).isEqualTo(21);
assertThat(captured.getValue().createdTo()).isEqualTo(java.time.LocalDateTime.of(2026,10,3,23,59,59,999000000));
}
@Test void detailProvidesAuthoritativeProgressOnlyForAuthenticatedCustomerAndRequestedOrder() throws Exception {
var query=mock(com.baiyue.order.service.UnifiedOrderQueryService.class);
var progress=mock(MerchantOrderWorkbenchService.class);
var controller=new MerchantUnifiedOrderController();
ReflectionTestUtils.setField(controller,"service",query);ReflectionTestUtils.setField(controller,"workbench",progress);
var base=new com.baiyue.order.dto.UnifiedOrderResponse.Detail(null,null,java.util.List.of(),java.util.List.of());
var fact=new com.baiyue.order.dto.MerchantOrderProgress(21,"scheduled",true);
when(query.detail(11,21,false)).thenReturn(base);when(progress.detailProgress(11,21)).thenReturn(fact);
var user=new AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",Set.of("merchant:order:view"));
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
public boolean supportsParameter(MethodParameter p) { return p.getParameterType()==AuthenticatedUser.class; }
public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory f) { return user; }
}).build();
mvc.perform(get("/api/merchant/v1/orders/21").param("customerId","999").param("tenantId","999"))
.andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.data.progress.orderId").value(21))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.data.progress.stage").value("scheduled"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.data.progress.partial").value(true));
verify(query).detail(11,21,false);verify(progress).detailProgress(11,21);
clearInvocations(query,progress);
assertThatThrownBy(()->controller.detail(null,21)).isInstanceOf(BizException.class);verifyNoInteractions(query,progress);
assertThat(MerchantUnifiedOrderController.class.getMethod("detail",AuthenticatedUser.class,long.class).getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('merchant:order:view')");
}
}
@@ -85,7 +85,7 @@ class UnifiedOrderApiContractTest {
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
.containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope");
assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get"))
.containsEntry("$ref", "#/components/schemas/UnifiedOrderDetailEnvelope");
.containsEntry("$ref", "#/components/schemas/MerchantOrderDetailEnvelope");
}
@Test
@@ -143,4 +143,20 @@ class UnifiedOrderApiContractTest {
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
}
@Test void merchantDetailRequiresTheSameProgressShapeAsWorkbenchWithoutChangingPlatformDetail() throws Exception {
var document=stringMap(new Yaml().load(new ClassPathResource("openapi/order-v1.yaml").getContentAsString(StandardCharsets.UTF_8)));
var schemas=stringMap(stringMap(document.get("components")).get("schemas"));
var parts=(List<?>)stringMap(schemas.get("MerchantOrderDetail")).get("allOf");
assertThat(stringMap(parts.get(0))).containsEntry("$ref","#/components/schemas/UnifiedOrderDetail");
assertThat(stringList(stringMap(parts.get(1)).get("required"))).contains("progress");
var progress=stringMap(schemas.get("MerchantOrderProgress"));
assertThat(stringList(progress.get("required"))).containsExactly("orderId","stage","partial");
assertThat(stringList(stringMap(stringMap(progress.get("properties")).get("stage")).get("enum")))
.containsExactlyElementsOf(com.baiyue.order.dto.MerchantOrderProgress.STAGES);
assertThat(stringMap(stringMap(schemas.get("UnifiedOrderDetail")).get("properties"))).doesNotContainKey("progress");
assertThat(java.util.Arrays.stream(com.baiyue.order.dto.UnifiedOrderResponse.MerchantDetail.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.containsExactly("summary","order","timeline","transportFragments","progress");
}
}
@@ -125,4 +125,17 @@ class MerchantOrderWorkbenchRepositoryTest {
assertThat(filtered.queues().fee()).isEqualTo(1);
assertThat(repository.progressScope(11)).hasSize(212);
}
@Test void detailScopesOnlyExactOwnedOrderAndActiveOwnedChildren() {
jdbc.update("UPDATE ord_order SET root_order_id=210 WHERE id BETWEEN 210 AND 214");
jdbc.update("UPDATE ord_order SET status='CANCELLED' WHERE id=212");
assertThat(repository.detailProgressScope(11,210)).extracting(OrderEntity::getId).containsExactly(210L,211L);
assertThat(repository.detailProgressScope(11,211)).extracting(OrderEntity::getId).containsExactly(211L);
assertThat(repository.detailProgressScope(22,210)).isEmpty();
assertThat(repository.detailProgressScope(11,213)).isEmpty();
assertThat(repository.detailProgressScope(11,999)).isEmpty();
jdbc.update("UPDATE ord_order SET root_order_id=NULL WHERE id=210");
assertThat(repository.detailProgressScope(11,210)).extracting(OrderEntity::getId).containsExactly(210L,211L);
}
}
@@ -45,7 +45,7 @@ class MerchantOrderWorkbenchServiceTest {
assertThat(result.page().total()).isEqualTo(210);
assertThat(result.page().page()).isEqualTo(22);
assertThat(result.queues().fee()).isEqualTo(2);
verify(firstleg).workbenchFilters("test-service-token",new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,"LOADED"));
verify(firstleg).workbenchFilters("test-service-token",new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,"LOADED",null));
verify(unified).merchantWorkbenchRows(11,List.of());
}
@Test void unavailableOrIncompleteFulfillmentFiltersCannotProduceMisleadingEmptyQueues() {
@@ -92,4 +92,63 @@ class MerchantOrderWorkbenchServiceTest {
assertThat(projected.get(2L).partial()).isFalse();assertThat(projected.get(3L).stage()).isEqualTo("delivered");
assertThat(projected.get(4L).stage()).isEqualTo("none");assertThat(projected).doesNotContainKey(999L);
}
@Test void freshDetailUsesExactOwnedScopeAndTheSameProjectionAsList() {
var scope=List.of(order(1L,1L,com.baiyue.order.entity.OrderStatus.APPROVED),order(2L,1L,com.baiyue.order.entity.OrderStatus.APPROVED));
var first=List.of(new com.baiyue.order.dto.MerchantOrderProgress(1L,"cleared",false));
var tail=List.of(new com.baiyue.order.dto.MerchantOrderProgress(2L,"scheduled",false));
when(repository.detailProgressScope(11,1)).thenReturn(scope);
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(),first)));
when(lastleg.read(anyString(),any())).thenReturn(ApiResponse.success(new com.baiyue.order.client.LastlegOrderProgressClient.Response(tail)));
assertThat(service.detailProgress(11,1)).isEqualTo(MerchantOrderWorkbenchService.project(scope,first,tail).get(1L))
.isEqualTo(new com.baiyue.order.dto.MerchantOrderProgress(1L,"scheduled",true));
verify(firstleg).workbenchFilters("test-service-token",new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,null,List.of(1L,2L)));
verify(lastleg).read("test-service-token",new com.baiyue.order.client.LastlegOrderProgressClient.Request(11,List.of(1L,2L)));
verify(repository).detailProgressScope(11,1);verifyNoMoreInteractions(repository);verifyNoInteractions(unified);
}
@Test void childDetailDoesNotReadItsRootSiblingsOrAllCustomerPages() {
when(repository.detailProgressScope(11,2)).thenReturn(List.of(order(2L,1L,com.baiyue.order.entity.OrderStatus.APPROVED)));
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(),List.of())));
when(lastleg.read(anyString(),any())).thenReturn(ApiResponse.success(new com.baiyue.order.client.LastlegOrderProgressClient.Response(List.of(new com.baiyue.order.dto.MerchantOrderProgress(2L,"scheduled",false)))));
assertThat(service.detailProgress(11,2)).isEqualTo(new com.baiyue.order.dto.MerchantOrderProgress(2L,"scheduled",false));
verify(firstleg).workbenchFilters(anyString(),eq(new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,null,List.of(2L))));
verify(repository).detailProgressScope(11,2);verifyNoMoreInteractions(repository);verifyNoInteractions(unified);
}
@Test void missingOwnedOrderFailsBeforeAnyRemoteQuery() {
when(repository.detailProgressScope(11,99)).thenReturn(List.of());
assertThatThrownBy(()->service.detailProgress(11,99)).isInstanceOf(BizException.class).hasMessageContaining("订单不存在");
verifyNoInteractions(firstleg,lastleg,unified);
}
@Test void detailRejectsOutOfScopeDuplicateInvalidAndMissingFactsInsteadOfFallingBack() {
when(repository.detailProgressScope(11,1)).thenReturn(List.of(order(1L,null,com.baiyue.order.entity.OrderStatus.APPROVED)));
for(var facts:List.of(List.of(new com.baiyue.order.dto.MerchantOrderProgress(999L,"loaded",false)),
List.of(new com.baiyue.order.dto.MerchantOrderProgress(1L,"unknown",false)),
List.of(new com.baiyue.order.dto.MerchantOrderProgress(1L,"loaded",false),new com.baiyue.order.dto.MerchantOrderProgress(1L,"loaded",false)))) {
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(),facts)));
assertThatThrownBy(()->service.detailProgress(11,1)).isInstanceOf(BizException.class).hasMessageContaining("暂时不可用");
}
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(null);
assertThatThrownBy(()->service.detailProgress(11,1)).isInstanceOf(BizException.class);
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(),List.of())));
when(lastleg.read(anyString(),any())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->service.detailProgress(11,1)).isInstanceOf(BizException.class);
}
@Test void largeRootScopesUseBoundedBatchesWithoutLosingAnyChild() {
var scope=java.util.stream.LongStream.rangeClosed(1,201).mapToObj(id->order(id,1L,com.baiyue.order.entity.OrderStatus.APPROVED)).toList();
when(repository.detailProgressScope(11,1)).thenReturn(scope);
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(),List.of())));
service.detailProgress(11,1);
var requests=org.mockito.ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.WorkbenchFilterRequest.class);
verify(firstleg,times(2)).workbenchFilters(anyString(),requests.capture());
assertThat(requests.getAllValues()).extracting(r->r.orderIds().size()).containsExactly(200,1);
assertThat(requests.getAllValues().stream().flatMap(r->r.orderIds().stream())).containsExactlyElementsOf(scope.stream().map(com.baiyue.order.entity.OrderEntity::getId).toList());
}
@Test void authoritativeDraftSubmittedCompletedAndCancelledDoNotNeedRemoteProgress() {
for(var status:List.of(com.baiyue.order.entity.OrderStatus.DRAFT,com.baiyue.order.entity.OrderStatus.SUBMITTED,com.baiyue.order.entity.OrderStatus.COMPLETED,com.baiyue.order.entity.OrderStatus.CANCELLED)) {
when(repository.detailProgressScope(11,1)).thenReturn(List.of(order(1L,null,status)));
assertThat(service.detailProgress(11,1).stage()).isEqualTo(status==com.baiyue.order.entity.OrderStatus.COMPLETED?"delivered":"none");
}
verifyNoInteractions(firstleg,lastleg,unified);
}
}