fix(order): project exact customer fulfillment progress in detail
This commit is contained in:
@@ -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<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
|
||||
List<Long> customsOrderIds, List<Progress> progress) {}
|
||||
|
||||
+12
-2
@@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||
/** Only physically loaded fragments can expose container milestones to the customer. */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
||||
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
||||
WHERE n.container_booking_id=owned.container_booking_id
|
||||
@@ -76,6 +77,11 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
|
||||
FROM fl_loading_plan_item i
|
||||
<if test="orderIds != null">
|
||||
JOIN fl_inbound_order selected ON selected.id=i.inbound_id AND selected.tenant_id=i.account_id
|
||||
AND selected.deleted=0 AND selected.order_id IN
|
||||
<foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
|
||||
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id AND picked.loading_plan_item_id=i.id
|
||||
@@ -84,8 +90,12 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
AND picked.item_status IN ('PICKED','PARTIAL') AND picked.picked_package_count>0
|
||||
) owned ON owned.inbound_id=o.id AND owned.account_id=o.tenant_id
|
||||
WHERE o.tenant_id=#{tenantId} AND o.deleted=0 AND o.order_id IS NOT NULL
|
||||
AND o.process_status <> 'CANCELLED'
|
||||
AND o.process_status <> 'CANCELLED'
|
||||
<if test="orderIds != null">
|
||||
AND o.order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
ORDER BY o.order_id
|
||||
</script>
|
||||
""")
|
||||
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId);
|
||||
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List<Long> orderIds);
|
||||
}
|
||||
|
||||
+5
-1
@@ -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<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
||||
|
||||
@@ -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在自身客户范围内过滤、计数和分页
|
||||
|
||||
+15
-1
@@ -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<Long> ids: java.util.List.of(java.util.List.<Long>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<Long> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -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<String,Object>)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<String,Object>)document.get("paths");
|
||||
var operation=(java.util.Map<String,Object>)((java.util.Map<String,Object>)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post");
|
||||
var content=(java.util.Map<String,Object>)((java.util.Map<String,Object>)operation.get("requestBody")).get("content");
|
||||
var schema=(java.util.Map<String,Object>)((java.util.Map<String,Object>)content.get("application/json")).get("schema");
|
||||
var ids=(java.util.Map<String,Object>)((java.util.Map<String,Object>)schema.get("properties")).get("orderIds");
|
||||
assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true);
|
||||
assertThat((java.util.List<String>)schema.get("required")).doesNotContain("orderIds");
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> progress) {}
|
||||
|
||||
+35
-4
@@ -8,12 +8,17 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface CustomerOrderProgressMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
WITH scheduled AS (
|
||||
SELECT line.order_id,SUM(a.planned_package_count) AS packages
|
||||
FROM ll_dispatch_order d
|
||||
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=d.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
|
||||
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE d.customer_id=#{customerId} AND d.status IN ('PENDING_DEPARTURE','PICKING','READY_TO_DEPART')
|
||||
AND i.warehouse_pallet_id IS NULL
|
||||
GROUP BY line.order_id
|
||||
@@ -24,14 +29,32 @@ public interface CustomerOrderProgressMapper {
|
||||
JOIN ll_inventory_position p ON p.customer_id=d.customer_id AND p.pallet_id=i.warehouse_pallet_id
|
||||
AND p.overseas_warehouse_id=i.overseas_warehouse_id
|
||||
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=d.customer_id
|
||||
LEFT JOIN (SELECT dispatch_item_id,inventory_position_id,SUM(packages) AS packages
|
||||
FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId}
|
||||
GROUP BY dispatch_item_id,inventory_position_id) pk
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order stock_source ON stock_source.id=b.inbound_order_id AND stock_source.customer_id=#{customerId}
|
||||
AND stock_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
LEFT JOIN (SELECT pick.dispatch_item_id,pick.inventory_position_id,SUM(pick.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick pick
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_inventory_position scoped_position ON scoped_position.id=pick.inventory_position_id
|
||||
AND scoped_position.customer_id=pick.customer_id
|
||||
JOIN ll_inventory_balance scoped_balance ON scoped_balance.id=scoped_position.balance_id
|
||||
AND scoped_balance.customer_id=pick.customer_id
|
||||
JOIN ll_order scoped_order ON scoped_order.id=scoped_balance.inbound_order_id
|
||||
AND scoped_order.customer_id=pick.customer_id
|
||||
AND scoped_order.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE pick.customer_id=#{customerId}
|
||||
GROUP BY pick.dispatch_item_id,pick.inventory_position_id) pk
|
||||
ON pk.dispatch_item_id=i.id AND pk.inventory_position_id=p.id
|
||||
WHERE d.customer_id=#{customerId} AND d.status IN ('PENDING_DEPARTURE','PICKING','READY_TO_DEPART')
|
||||
AND (i.plan_pallet_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=d.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
|
||||
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id))
|
||||
GROUP BY b.inbound_order_id
|
||||
), departed AS (
|
||||
@@ -41,6 +64,10 @@ public interface CustomerOrderProgressMapper {
|
||||
JOIN ll_dispatch_pda_task t ON t.id=p.task_id AND t.customer_id=a.customer_id
|
||||
JOIN ll_dispatch_order ds ON ds.id=t.dispatch_order_id AND ds.customer_id=a.customer_id
|
||||
JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.dispatch_order_id=ds.id AND i.customer_id=a.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order departed_source ON departed_source.id=a.order_id AND departed_source.customer_id=#{customerId}
|
||||
AND departed_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE a.customer_id=#{customerId} AND ds.status='DEPARTED' AND ds.depart_at IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ll_delivery_order d
|
||||
@@ -62,7 +89,11 @@ public interface CustomerOrderProgressMapper {
|
||||
FROM ll_order o WHERE o.customer_id=#{customerId} AND o.source_region='FIRSTLEG'
|
||||
AND o.source_order_id IS NOT NULL AND o.source_inbound_id IS NOT NULL
|
||||
AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
||||
<if test="orderIds != null">
|
||||
AND o.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
ORDER BY o.id
|
||||
</script>
|
||||
""")
|
||||
List<CustomerOrderProgress.Source> sources(@Param("customerId") String customerId);
|
||||
List<CustomerOrderProgress.Source> sources(@Param("customerId") String customerId, @Param("orderIds") List<String> orderIds);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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<Long> orderIds) {
|
||||
return new CustomerOrderProgress.Response(project(mapper.sources(Long.toString(customerId), orderIds == null ? null : orderIds.stream().map(String::valueOf).toList())));
|
||||
}
|
||||
static List<CustomerOrderProgress.Progress> project(List<CustomerOrderProgress.Source> sources) {
|
||||
return sources.stream().collect(Collectors.groupingBy(CustomerOrderProgress.Source::orderId))
|
||||
|
||||
@@ -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为空表示客户当前无有效排车或派送
|
||||
|
||||
+28
-2
@@ -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<Long> ids: java.util.List.of(java.util.List.<Long>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<Long> 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<String,Object>)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<String,Object>)document.get("paths");
|
||||
var operation=(java.util.Map<String,Object>)((java.util.Map<String,Object>)paths.get("/internal/lastleg/v1/customer-order-progress")).get("post");
|
||||
var content=(java.util.Map<String,Object>)((java.util.Map<String,Object>)operation.get("requestBody")).get("content");
|
||||
var schema=(java.util.Map<String,Object>)((java.util.Map<String,Object>)content.get("application/json")).get("schema");
|
||||
var ids=(java.util.Map<String,Object>)((java.util.Map<String,Object>)schema.get("properties")).get("orderIds");
|
||||
assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true);
|
||||
assertThat((java.util.List<String>)schema.get("required")).doesNotContain("orderIds");
|
||||
}
|
||||
}
|
||||
|
||||
+23
-10
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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:
|
||||
|
||||
+24
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+60
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user