diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java index cdde0b81e..589488bf6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters { private MerchantWorkbenchFilters() {} public record Request(@NotNull @Positive Long tenantId, - @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {} + @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus, + @jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {} public record Response(List matchingStatusOrderIds, List feeOrderIds, List customsOrderIds, List progress) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index 8e81bcdd3..12b1223ea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper { List pendingCustoms(@Param("tenantId") long tenantId); /** Only physically loaded fragments can expose container milestones to the customer. */ @Select(""" + """) - List progressFragments(@Param("tenantId") long tenantId); + List progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List orderIds); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java index 65df89291..ef0880e5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -12,11 +12,15 @@ public class MerchantWorkbenchFilterService { @org.springframework.transaction.annotation.Transactional(readOnly=true) public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { + if (request.orderIds() != null) { + return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(), + progress(mapper.progressFragments(request.tenantId(), request.orderIds()))); + } return new MerchantWorkbenchFilters.Response( request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), unified.internalStatuses(request.mainStatus())), mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()), - progress(mapper.progressFragments(request.tenantId()))); + progress(mapper.progressFragments(request.tenantId(), null))); } static java.util.List progress(java.util.List facts) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c09bb115c..f22a25424 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。 + description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -367,6 +367,13 @@ paths: properties: tenantId: {type: integer, format: int64, minimum: 1} mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + orderIds: + type: array + nullable: true + minItems: 1 + maxItems: 200 + items: {type: integer, format: int64, minimum: 1} + description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。 responses: '200': description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index a7f41e878..c72e432b4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest { private InternalInboundOrderController controller; @Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() { - var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED"); + var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null); assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class); assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class); org.mockito.Mockito.verifyNoInteractions(workbenchFilters); @@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest { carrierId == null ? null : "承运商", null, null, null, null, null, null, null, null, null, null); } + + @Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() { + try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + for(java.util.List ids: java.util.List.of(java.util.List.of(),java.util.List.of(0L), + java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) { + assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isNotEmpty(); + } + for(java.util.List ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) { + assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isEmpty(); + } + } + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index 7ae30b21b..258f214fb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -74,4 +74,16 @@ class UnifiedInboundOpenApiContractTest { .containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName"); } + + @Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception { + var document=(java.util.Map)new org.yaml.snakeyaml.Yaml().load( + new org.springframework.core.io.ClassPathResource("openapi/firstleg-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8)); + var paths=(java.util.Map)document.get("paths"); + var operation=(java.util.Map)((java.util.Map)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post"); + var content=(java.util.Map)((java.util.Map)operation.get("requestBody")).get("content"); + var schema=(java.util.Map)((java.util.Map)content.get("application/json")).get("schema"); + var ids=(java.util.Map)((java.util.Map)schema.get("properties")).get("orderIds"); + assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true); + assertThat((java.util.List)schema.get("required")).doesNotContain("orderIds"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index 1ebb617d3..00b029a6d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -83,19 +83,28 @@ class MerchantWorkbenchFilterMapperTest { } @Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() { - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0); jdbc.update("INSERT INTO fl_loading_pick_task VALUES(900,100,'COMPLETED')"); jdbc.update("INSERT INTO fl_loading_pick_item SELECT 900,id,'PICKED',2 FROM fl_loading_plan_item"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104); jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1); jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==3); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3); + assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);}); jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); - assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0); } + + @Test void exactOrderScopeKeepsAllItsFragmentsAndCannotReadForeignOrDeletedOrders() { + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(50,11,101,0,'TAX_REFUND','LOADED')"); + assertThat(mapper.progressFragments(11,List.of(101L,103L,104L))).extracting(f->f.orderId()).containsExactly(101L,101L); + assertThat(mapper.progressFragments(11,List.of(999L))).isEmpty(); + assertThat(mapper.progressFragments(22,List.of(101L))).isEmpty(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java new file mode 100644 index 000000000..b2c74a883 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MerchantWorkbenchFilterServiceTest { + @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() { + var mapper=mock(MerchantWorkbenchFilterMapper.class); + var unified=mock(UnifiedInboundQueryService.class); + var service=new MerchantWorkbenchFilterService(); + ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"unified",unified); + when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of( + new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1))); + var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,List.of(101L))); + assertThat(response.progress()).containsExactly(new MerchantWorkbenchFilters.Progress(101,"cleared",true)); + assertThat(response.feeOrderIds()).isEmpty();assertThat(response.customsOrderIds()).isEmpty(); + assertThat(response.matchingStatusOrderIds()).isNull(); + verify(mapper).progressFragments(11,List.of(101L));verifyNoMoreInteractions(mapper);verifyNoInteractions(unified); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java index 3b0f1f763..8ede1e4c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java @@ -20,6 +20,6 @@ public class InternalCustomerOrderProgressController { @RequestHeader(value="X-Internal-Service-Token", required=false) String token, @Valid @RequestBody CustomerOrderProgress.Request request) { guard.verify(token); - return ApiResponse.success(service.read(request.customerId())); + return ApiResponse.success(service.read(request.customerId(), request.orderIds())); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java index 744a8a4cc..07564821e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java @@ -6,7 +6,8 @@ import java.util.List; public final class CustomerOrderProgress { private CustomerOrderProgress() {} - public record Request(@NotNull @Positive Long customerId) {} + public record Request(@NotNull @Positive Long customerId, + @jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {} public record Source(String orderId, long packages, long scheduledPackages, long deliveringPackages) {} public record Progress(long orderId, String stage, boolean partial) {} public record Response(List progress) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java index d602ab804..13ea3d7f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java @@ -8,12 +8,17 @@ import java.util.List; @Mapper public interface CustomerOrderProgressMapper { @Select(""" + """) - List sources(@Param("customerId") String customerId); + List sources(@Param("customerId") String customerId, @Param("orderIds") List orderIds); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java index 665a10d4d..da82987e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java @@ -12,8 +12,8 @@ public class CustomerOrderProgressService { private final CustomerOrderProgressMapper mapper; public CustomerOrderProgressService(CustomerOrderProgressMapper mapper) { this.mapper = mapper; } @Transactional(readOnly=true) - public CustomerOrderProgress.Response read(long customerId) { - return new CustomerOrderProgress.Response(project(mapper.sources(Long.toString(customerId)))); + public CustomerOrderProgress.Response read(long customerId, List orderIds) { + return new CustomerOrderProgress.Response(project(mapper.sources(Long.toString(customerId), orderIds == null ? null : orderIds.stream().map(String::valueOf).toList()))); } static List project(List sources) { return sources.stream().collect(Collectors.groupingBy(CustomerOrderProgress.Source::orderId)) diff --git a/lastleg-service/src/main/resources/openapi/lastleg-customer-order-progress-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-customer-order-progress-v1.yaml index d702aa64b..db3121322 100644 --- a/lastleg-service/src/main/resources/openapi/lastleg-customer-order-progress-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/lastleg-customer-order-progress-v1.yaml @@ -21,6 +21,13 @@ paths: required: [customerId] properties: customerId: {type: integer, format: int64, minimum: 1} + orderIds: + type: array + nullable: true + minItems: 1 + maxItems: 200 + items: {type: integer, format: int64, minimum: 1} + description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。 responses: '200': description: ApiResponse,只有code为200表示成功;progress为空表示客户当前无有效排车或派送 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java index 1a5ac72da..961594d10 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java @@ -11,10 +11,36 @@ class InternalCustomerOrderProgressControllerTest { @Test void validatesInternalIdentityBeforeReadingCustomerFacts() { var service=mock(CustomerOrderProgressService.class); var controller=new InternalCustomerOrderProgressController(new InternalServiceTokenGuard("test-token"),service); - var request=new CustomerOrderProgress.Request(11L); + var request=new CustomerOrderProgress.Request(11L, null); assertThatThrownBy(()->controller.read(null,request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); assertThatThrownBy(()->controller.read("wrong",request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); verifyNoInteractions(service); - controller.read("test-token",request);verify(service).read(11); + controller.read("test-token",request);verify(service).read(11, null); + } + + @Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() { + try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + for(java.util.List ids: java.util.List.of(java.util.List.of(),java.util.List.of(0L), + java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) { + assertThat(validator.validate(new CustomerOrderProgress.Request(11L,ids))).isNotEmpty(); + } + for(java.util.List ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) { + assertThat(validator.validate(new CustomerOrderProgress.Request(11L,ids))).isEmpty(); + } + } + } + + + @Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception { + var document=(java.util.Map)new org.yaml.snakeyaml.Yaml().load( + new org.springframework.core.io.ClassPathResource("openapi/lastleg-customer-order-progress-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8)); + var paths=(java.util.Map)document.get("paths"); + var operation=(java.util.Map)((java.util.Map)paths.get("/internal/lastleg/v1/customer-order-progress")).get("post"); + var content=(java.util.Map)((java.util.Map)operation.get("requestBody")).get("content"); + var schema=(java.util.Map)((java.util.Map)content.get("application/json")).get("schema"); + var ids=(java.util.Map)((java.util.Map)schema.get("properties")).get("orderIds"); + assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true); + assertThat((java.util.List)schema.get("required")).doesNotContain("orderIds"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java index cd2724947..e831aa9de 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java @@ -38,21 +38,23 @@ class CustomerOrderProgressMapperTest { mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class); } @Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() { - var sources=mapper.sources("11");assertThat(sources).hasSize(2); + var sources=mapper.sources("11", null);assertThat(sources).hasSize(2); assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); + assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0)); assertThat(sources).allMatch(s->s.deliveringPackages()==0); jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); - assertThat(mapper.sources("11")).allMatch(s->s.scheduledPackages()==0); - assertThat(mapper.sources("22")).hasSize(1).allMatch(s->s.scheduledPackages()==0); + assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0); + assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0); } @Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() { jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800"); jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)"); jdbc.update("INSERT INTO ll_inventory_position VALUES(400,'11',800,'W1',300,6),(401,'11',800,'W1',301,20)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)"); - var sources=mapper.sources("11");assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); + var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); + assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0)); } @Test void deliveryRequiresActualDepartureOutboundAndMatchingDestinationAttribution() { jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,'11',10)"); @@ -60,15 +62,26 @@ class CustomerOrderProgressMapperTest { jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4)"); jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','WAIT_OUTBOUND')"); jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1')"); - assertThat(mapper.sources("11")).allMatch(s->s.deliveringPackages()==0); + assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',depart_at=CURRENT_TIMESTAMP"); - assertThat(mapper.sources("11")).allMatch(s->s.deliveringPackages()==0); + assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'"); - assertThat(mapper.sources("11").get(0).deliveringPackages()).isEqualTo(4); - assertThat(mapper.sources("11").get(1).deliveringPackages()).isZero(); + assertThat(mapper.sources("11", null).get(0).deliveringPackages()).isEqualTo(4); + assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(mapper.sources("11",null).get(0)); + assertThat(mapper.sources("11", null).get(1).deliveringPackages()).isZero(); jdbc.update("UPDATE ll_delivery_order SET delivery_group_key='CODE:FBA2'"); - assertThat(mapper.sources("11")).allMatch(s->s.deliveringPackages()==0); + assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); jdbc.update("UPDATE ll_delivery_order SET delivery_group_key='CODE:FBA1',status='RETURN_PENDING'"); - assertThat(mapper.sources("11")).allMatch(s->s.deliveringPackages()==0); + assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); } + + @Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() { + jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)"); + var own=mapper.sources("11",java.util.List.of("101","103")); + assertThat(own).extracting(s->s.orderId()).containsExactly("101","101"); + assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L); + assertThat(mapper.sources("22",java.util.List.of("101"))).isEmpty(); + assertThat(mapper.sources("11",java.util.List.of("999"))).isEmpty(); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java index 9ad75becb..2b6cffd69 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java @@ -15,4 +15,13 @@ class CustomerOrderProgressServiceTest { assertThat(result.get(1).stage()).isEqualTo("delivering");assertThat(result.get(1).partial()).isFalse(); assertThat(result.get(2).partial()).isTrue();assertThat(result.get(3).partial()).isTrue(); } + + @Test void boundedSourceIdsReachTheMapperBeforeProjection() { + var mapper=org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.CustomerOrderProgressMapper.class); + org.mockito.Mockito.when(mapper.sources("11",List.of("101"))).thenReturn(List.of(new Source("101",2,2,0))); + assertThat(new CustomerOrderProgressService(mapper).read(11,List.of(101L)).progress()).singleElement() + .isEqualTo(new com.baiyue.lastleg.dto.CustomerOrderProgress.Progress(101,"scheduled",false)); + org.mockito.Mockito.verify(mapper).sources("11",List.of("101"));org.mockito.Mockito.verifyNoMoreInteractions(mapper); + } + } diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 3600b71d7..091c70060 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -20,7 +20,7 @@ public interface FirstlegUnifiedOrderClient { ApiResponse workbenchFilters(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody WorkbenchFilterRequest request); - record WorkbenchFilterRequest(long tenantId, String mainStatus) {} + record WorkbenchFilterRequest(long tenantId, String mainStatus, List orderIds) {} record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, List customsOrderIds, List progress) {} diff --git a/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java b/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java index 2cd64e579..8eb2dac9c 100644 --- a/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java +++ b/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java @@ -11,6 +11,6 @@ import java.util.List; public interface LastlegOrderProgressClient { @PostMapping ApiResponse read(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request); - record Request(long customerId) {} + record Request(long customerId, List orderIds) {} record Response(List progress) {} } diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java index d977e7523..6b1fde0a0 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -51,10 +51,13 @@ public class MerchantUnifiedOrderController { @GetMapping("/{orderId}") @PreAuthorize("hasAuthority('merchant:order:view')") @Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。") - public ApiResponse detail( + public ApiResponse 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") diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index b1d7a3110..27dd462a4 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -50,6 +50,9 @@ public final class UnifiedOrderResponse { public record Detail(Row summary, OrderDetailResponse order, List timeline, List transportFragments) {} + public record MerchantDetail(Row summary, OrderDetailResponse order, + List timeline, List transportFragments, + MerchantOrderProgress progress) {} public record TransportFragment(String inboundCode, String cabinetNo, String containerNo, List nodes) {} public record TransportNode(String standardNode, Integer nodeSequence, String providerCode, diff --git a/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java index aa5a1741e..77ab9a941 100644 --- a/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java @@ -51,6 +51,17 @@ public class MerchantOrderWorkbenchRepository { return mapper.selectList(new QueryWrapper().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 detailProgressScope(long customerId,long orderId) { + var selected=mapper.selectOne(new QueryWrapper().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();scope.add(selected); + scope.addAll(mapper.selectList(new QueryWrapper().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 base,java.util.Map> progressIds,String stage) { return mapper.selectCount(ids(base.clone(),progressIds.get(stage))); diff --git a/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java index aaf351f97..b415f1011 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java @@ -42,12 +42,12 @@ public class MerchantOrderWorkbenchService { List 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();var tailFacts=new ArrayList(); + var ids=scope.stream().map(OrderEntity::getId).toList(); + try { + for(int start=0;start!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 facts, int min, int max) { var seen=new java.util.HashSet(); for(var fact:facts) if(fact==null||fact.rank()max||!seen.add(fact.orderId())) diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index fc23b0e56..c7ed83a02 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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: diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java index fc2cd3196..f88f90312 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java @@ -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')"); + } + } diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 826b0c335..7cdb81731 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -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"); + } + } diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java index 96d7a9af4..0929211a1 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java @@ -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); + } + } diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java index 01b67d22c..4ffde22dc 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java @@ -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); + } + }