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 5866dc917..cdde0b81e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -13,5 +13,8 @@ public final class MerchantWorkbenchFilters { @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {} public record Response(List matchingStatusOrderIds, List feeOrderIds, - List customsOrderIds) {} + List customsOrderIds, List progress) {} + + public record Progress(long orderId, String stage, boolean partial) {} + public record Fragment(long orderId, int stage) {} } 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 214c45ba0..8e81bcdd3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -63,4 +63,29 @@ public interface MerchantWorkbenchFilterMapper { ORDER BY i.order_id """) List pendingCustoms(@Param("tenantId") long tenantId); + /** Only physically loaded fragments can expose container milestones to the customer. */ + @Select(""" + 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 + AND n.standard_node='PICKUP_READY' AND n.actual_at IS NOT NULL) THEN 3 + WHEN EXISTS (SELECT 1 FROM fl_customs_container c + WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2 + ELSE 1 END AS stage + FROM fl_inbound_order o + 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 + 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 + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + WHERE i.account_id=#{tenantId} AND i.deleted=0 + 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' + ORDER BY o.order_id + """) + List progressFragments(@Param("tenantId") long tenantId); } 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 785d506db..65df89291 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -10,10 +10,22 @@ public class MerchantWorkbenchFilterService { @Resource private MerchantWorkbenchFilterMapper mapper; @Resource private UnifiedInboundQueryService unified; + @org.springframework.transaction.annotation.Transactional(readOnly=true) public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { return new MerchantWorkbenchFilters.Response( request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), unified.internalStatuses(request.mainStatus())), - mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId())); + mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()), + progress(mapper.progressFragments(request.tenantId()))); + } + + static java.util.List progress(java.util.List facts) { + var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId)); + return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> { + int max = entry.getValue().stream().mapToInt(MerchantWorkbenchFilters.Fragment::stage).max().orElse(0); + boolean partial = entry.getValue().stream().anyMatch(fragment -> fragment.stage() < max); + return new MerchantWorkbenchFilters.Progress(entry.getKey(), + switch(max) { case 1 -> "loaded"; case 2 -> "declared"; case 3 -> "cleared"; default -> "none"; }, partial); + }).filter(fact -> !"none".equals(fact.stage())).toList(); } } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index fa2e6112d..415b08cb6 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -378,11 +378,22 @@ paths: code: {type: string} data: type: object - required: [feeOrderIds, customsOrderIds] + required: [feeOrderIds, customsOrderIds, progress] properties: matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} feeOrderIds: {type: array, items: {type: integer, format: int64}} customsOrderIds: {type: array, items: {type: integer, format: int64}} + progress: + type: array + description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。 + items: + type: object + required: [orderId, stage, partial] + properties: + orderId: {type: integer, format: int64} + stage: {type: string, enum: [loaded, declared, cleared]} + partial: {type: boolean} + /internal/firstleg/v1/inbounds/unified-orders/query: post: summary: 按统一订单条件分页查询履约摘要(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 56d55faca..a7f41e878 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -49,7 +49,7 @@ class InternalInboundOrderReferenceControllerTest { assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class); assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class); org.mockito.Mockito.verifyNoInteractions(workbenchFilters); - var response = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Response(List.of(1L), List.of(), List.of()); + var response = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Response(List.of(1L), List.of(), List.of(), List.of()); when(workbenchFilters.read(request)).thenReturn(response); assertThat(controller.workbenchFilters("internal-token", request).data()).isEqualTo(response); } 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 fcd01ece1..1ebb617d3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -26,13 +26,17 @@ class MerchantWorkbenchFilterMapperTest { jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT PRIMARY KEY,loading_plan_id BIGINT,status VARCHAR(40))"); jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT PRIMARY KEY,customs_container_id BIGINT,tenant_id BIGINT,status VARCHAR(32))"); jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT,tenant_id BIGINT)"); + jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(32))"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(32),picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(64),actual_at TIMESTAMP)"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class); jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')"); jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); - jdbc.update("INSERT INTO fl_loading_plan_item VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); + jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); } @Test void latestFeeVersionAndCustomerScopeDetermineConfirmationQueue() { @@ -71,10 +75,27 @@ class MerchantWorkbenchFilterMapperTest { jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')"); jdbc.update("INSERT INTO fl_container_booking VALUES(101,'ACTIVE')"); jdbc.update("INSERT INTO fl_loading_plan VALUES(101,101,'COMPLETED',TIMESTAMP '2099-01-01 00:00:00',CURRENT_TIMESTAMP)"); - jdbc.update("INSERT INTO fl_loading_plan_item VALUES(101,1,11,0)"); + jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(101,1,11,0)"); jdbc.update("INSERT INTO fl_customs_container VALUES(201,101,'COMPLETED')"); assertThat(mapper.pendingCustoms(11)).containsExactly(102L); jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED' WHERE id=100"); assertThat(mapper.pendingCustoms(11)).isEmpty(); } + + @Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() { + assertThat(mapper.progressFragments(11)).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); + jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')"); + assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1); + jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'"); + assertThat(mapper.progressFragments(11)).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); + jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP"); + assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==3); + jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); + assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchProgressTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchProgressTest.java new file mode 100644 index 000000000..a2598f312 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchProgressTest.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class MerchantWorkbenchProgressTest { + @Test void unstartedFragmentsPreventWholeOrderProgressAndLatestFactsWinWithoutInventingHistory() { + var facts=List.of(new Fragment(1,0),new Fragment(1,1),new Fragment(2,2),new Fragment(2,3),new Fragment(3,3),new Fragment(4,0)); + var projected=MerchantWorkbenchFilterService.progress(facts); + assertThat(projected).hasSize(3); + assertThat(projected.get(0).stage()).isEqualTo("loaded");assertThat(projected.get(0).partial()).isTrue(); + assertThat(projected.get(1).stage()).isEqualTo("cleared");assertThat(projected.get(1).partial()).isTrue(); + assertThat(projected.get(2).partial()).isFalse(); + } +} 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 new file mode 100644 index 000000000..3b0f1f763 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.dto.CustomerOrderProgress; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.CustomerOrderProgressService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/lastleg/v1/customer-order-progress") +public class InternalCustomerOrderProgressController { + private final InternalServiceTokenGuard guard; + private final CustomerOrderProgressService service; + public InternalCustomerOrderProgressController(InternalServiceTokenGuard guard, CustomerOrderProgressService service) { + this.guard = guard; this.service = service; + } + @PostMapping + public ApiResponse read( + @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())); + } +} 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 new file mode 100644 index 000000000..744a8a4cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; + +public final class CustomerOrderProgress { + private CustomerOrderProgress() {} + public record Request(@NotNull @Positive Long customerId) {} + 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 new file mode 100644 index 000000000..d602ab804 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.mapper; + +import com.baiyue.lastleg.dto.CustomerOrderProgress; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */ +@Mapper +public interface CustomerOrderProgressMapper { + @Select(""" + 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 + 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 + UNION ALL + SELECT b.inbound_order_id AS order_id,SUM(p.available_packages+COALESCE(pk.packages,0)) 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_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 + 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 + WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id)) + GROUP BY b.inbound_order_id + ), departed AS ( + SELECT a.order_id,SUM(a.packages) AS packages + FROM ll_dispatch_pda_pick_order a + JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id + 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 + 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 + JOIN ll_outbound_order outb ON outb.id=d.outbound_order_id AND outb.customer_id=d.customer_id + WHERE d.customer_id=a.customer_id AND d.dispatch_order_id=ds.id + AND outb.status='OUTBOUNDED' AND d.status IN ('WAIT_DELIVERY','DELIVERING') + AND d.delivery_group_key=CASE + WHEN i.destination_type='PRIVATE' AND NULLIF(TRIM(i.delivery_address),'') IS NOT NULL + THEN CONCAT('PRIVATE:',TRIM(i.delivery_address),':',TRIM(COALESCE(i.postal_code,''))) + WHEN COALESCE(NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),'')) IS NOT NULL + THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),'')))) + WHEN NULLIF(TRIM(i.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(i.destination_warehouse_id)) + ELSE NULL END) + GROUP BY a.order_id + ) + SELECT o.source_order_id AS order_id,COALESCE(o.package_count,0) AS packages, + COALESCE((SELECT SUM(s.packages) FROM scheduled s WHERE s.order_id=o.id),0) AS scheduled_packages, + COALESCE((SELECT SUM(d.packages) FROM departed d WHERE d.order_id=o.id),0) AS delivering_packages + 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') + ORDER BY o.id + """) + List sources(@Param("customerId") String customerId); +} 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 new file mode 100644 index 000000000..665a10d4d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.CustomerOrderProgress; +import com.baiyue.lastleg.mapper.CustomerOrderProgressMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + +@Service +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)))); + } + static List project(List sources) { + return sources.stream().collect(Collectors.groupingBy(CustomerOrderProgress.Source::orderId)) + .entrySet().stream().map(entry -> { + long total = entry.getValue().stream().mapToLong(CustomerOrderProgress.Source::packages).sum(); + long delivering = entry.getValue().stream().mapToLong(CustomerOrderProgress.Source::deliveringPackages).sum(); + long scheduled = entry.getValue().stream().mapToLong(CustomerOrderProgress.Source::scheduledPackages).sum(); + String stage = delivering > 0 ? "delivering" : scheduled > 0 ? "scheduled" : "none"; + long covered = delivering > 0 ? delivering : scheduled; + // Equality is deliberate: duplicate or incomplete attribution must not claim full coverage. + boolean partial = total <= 0 || covered != total || entry.getValue().stream().anyMatch(s -> s.packages() <= 0); + return new CustomerOrderProgress.Progress(Long.parseLong(entry.getKey()), stage, partial); + }).filter(p -> !"none".equals(p.stage())).sorted(java.util.Comparator.comparingLong(CustomerOrderProgress.Progress::orderId)).toList(); + } +} 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 new file mode 100644 index 000000000..d702aa64b --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/lastleg-customer-order-progress-v1.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.3 +info: + title: 客户来源头程订单尾程进度内部查询 + version: 1.0.0 +paths: + /internal/lastleg/v1/customer-order-progress: + post: + summary: Order按认证客户读取排车和实际派送进度 + description: >- + 只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。 + 正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。 + 派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。 + 混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。 + security: [{internalServiceToken: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId] + properties: + customerId: {type: integer, format: int64, minimum: 1} + responses: + '200': + description: ApiResponse,只有code为200表示成功;progress为空表示客户当前无有效排车或派送 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [progress] + properties: + progress: + type: array + items: + type: object + required: [orderId, stage, partial] + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + stage: {type: string, enum: [scheduled, delivering]} + partial: {type: boolean} + '400': {description: 客户参数无效} + '401': {description: 内部服务令牌缺失或无效} + '503': {description: 内部令牌未配置} +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token 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 new file mode 100644 index 000000000..1a5ac72da --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.dto.CustomerOrderProgress; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.CustomerOrderProgressService; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +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); + 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); + } +} 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 new file mode 100644 index 000000000..cd2724947 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class CustomerOrderProgressMapperTest { + private JdbcTemplate jdbc; + private CustomerOrderProgressMapper mapper; + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:tail-progress-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_order_id VARCHAR,source_inbound_id VARCHAR,source_region VARCHAR,order_type VARCHAR,package_count INT)"); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,status VARCHAR,depart_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,overseas_warehouse_id VARCHAR,destination_type VARCHAR,delivery_address VARCHAR,postal_code VARCHAR,destination_warehouse_code VARCHAR,fba_warehouse_code VARCHAR,destination_warehouse_id VARCHAR)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,planned_package_count INT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,pallet_id BIGINT,overseas_warehouse_id VARCHAR,balance_id BIGINT,available_packages INT)"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR,dispatch_item_id BIGINT,inventory_position_id BIGINT,packages INT,task_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,pick_id BIGINT,customer_id VARCHAR,order_id BIGINT,packages INT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)"); + jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)"); + jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)"); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'11','PENDING_DEPARTURE',NULL)"); + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')"); + jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(1000,'11',1),(1001,'11',2),(1002,'22',3)"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(500,1000,10),(500,1001,20),(500,1002,30)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(CustomerOrderProgressMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class); + } + @Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() { + var sources=mapper.sources("11");assertThat(sources).hasSize(2); + assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); + assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); + 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); + } + @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); + assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); + } + @Test void deliveryRequiresActualDepartureOutboundAndMatchingDestinationAttribution() { + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,'11',10)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)"); + 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); + jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',depart_at=CURRENT_TIMESTAMP"); + assertThat(mapper.sources("11")).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(); + jdbc.update("UPDATE ll_delivery_order SET delivery_group_key='CODE:FBA2'"); + assertThat(mapper.sources("11")).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); + } +} 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 new file mode 100644 index 000000000..9ad75becb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.CustomerOrderProgress.Source; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class CustomerOrderProgressServiceTest { + @Test void aggregatesAllInboundFragmentsAndNeverTreatsDuplicateOrUnknownCoverageAsComplete() { + var result=CustomerOrderProgressService.project(List.of( + new Source("1",10,10,0),new Source("1",10,0,0), + new Source("2",10,0,10),new Source("3",10,0,20),new Source("4",0,5,0),new Source("5",10,0,0))); + assertThat(result).hasSize(4); + assertThat(result.get(0).stage()).isEqualTo("scheduled");assertThat(result.get(0).partial()).isTrue(); + 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(); + } +} 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 bf628d4c3..3600b71d7 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -22,7 +22,7 @@ public interface FirstlegUnifiedOrderClient { record WorkbenchFilterRequest(long tenantId, String mainStatus) {} record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, - List customsOrderIds) {} + List customsOrderIds, List progress) {} @PostMapping("/query") ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java b/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java new file mode 100644 index 000000000..2cd64e579 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java @@ -0,0 +1,16 @@ +package com.baiyue.order.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.order.dto.MerchantOrderProgress; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="lastleg-service", contextId="orderLastlegProgressClient", + url="${baiyue.clients.lastleg.url:}", path="/internal/lastleg/v1/customer-order-progress") +public interface LastlegOrderProgressClient { + @PostMapping + ApiResponse read(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request); + record Request(long customerId) {} + record Response(List progress) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderProgress.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderProgress.java new file mode 100644 index 000000000..57f2209fc --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderProgress.java @@ -0,0 +1,11 @@ +package com.baiyue.order.dto; + +/** Customer progress is a read projection, not another writable order state machine. */ +public record MerchantOrderProgress(long orderId, String stage, boolean partial) { + public static final java.util.List STAGES = java.util.List.of("none", "loaded", "declared", "cleared", "scheduled", "delivering", "delivered"); + public int rank() { + int rank = STAGES.indexOf(stage); + if (rank < 0 || orderId <= 0) throw new IllegalArgumentException("Invalid order progress"); + return rank; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java index 842fdfad5..fdee35198 100644 --- a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java @@ -13,7 +13,7 @@ public record MerchantOrderWorkbenchQuery( @Size(max=128) String trackingNumber, @Pattern(regexp="DRAFT|PENDING_REVIEW|WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED|COMPLETED|CANCELLED") String mainStatus, LocalDateTime createdFrom, LocalDateTime createdTo, - @Pattern(regexp="all|fee|customs|draft") String queue, + @Pattern(regexp="all|fee|customs|draft|loaded|declared|cleared|scheduled|delivering|delivered") String queue, @Min(1) long page, @Min(1) @Max(200) long size) { public MerchantOrderWorkbenchQuery { if (page==0) page=1; diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java index f5a0b3f83..43d075898 100644 --- a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java @@ -4,6 +4,14 @@ import com.baiyue.common.core.api.PageResult; import java.util.List; public record MerchantOrderWorkbenchResponse(PageResult page, - Counts queues, List channelNames) { - public record Counts(long all, long fee, long customs, long draft) {} + Counts queues, List channelNames, List progress) { + public record Counts(long all, long fee, long customs, long draft, long loaded, long declared, + long cleared, long scheduled, long delivering, long delivered) { + public long forQueue(String queue) { + return switch(queue) { case "fee" -> fee; case "customs" -> customs; case "draft" -> draft; + case "loaded" -> loaded; case "declared" -> declared; case "cleared" -> cleared; + case "scheduled" -> scheduled; case "delivering" -> delivering; case "delivered" -> delivered; + case "all" -> all; default -> throw new IllegalArgumentException("Invalid queue"); }; + } + } } 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 a3f0ede92..aa5a1741e 100644 --- a/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java @@ -21,14 +21,18 @@ public class MerchantOrderWorkbenchRepository { @Transactional(readOnly=true) public Result read(long customerId, MerchantOrderWorkbenchQuery q, List statusIds, - List feeIds, List customsIds) { + List feeIds, List customsIds, java.util.Map> progressIds) { var base=base(customerId,q,statusIds); long all=mapper.selectCount(base.clone()); long fee=mapper.selectCount(ids(base.clone(),feeIds)); long customs=mapper.selectCount(ids(base.clone(),customsIds)); long draft=mapper.selectCount(base.clone().eq("status","DRAFT")); - var counts=new Counts(all,fee,customs,draft); - long total=switch(q.queue()) {case "fee"->fee;case "customs"->customs;case "draft"->draft;default->all;}; + var counts=new Counts(all,fee,customs,draft, + progressCount(base,progressIds,"loaded"), progressCount(base,progressIds,"declared"), + progressCount(base,progressIds,"cleared"), progressCount(base,progressIds,"scheduled"), + progressCount(base,progressIds,"delivering"), progressCount(base,progressIds,"delivered")); + long total=counts.forQueue(q.queue()); + if (progressIds.containsKey(q.queue())) ids(base,progressIds.get(q.queue())); if ("fee".equals(q.queue())) ids(base,feeIds); if ("customs".equals(q.queue())) ids(base,customsIds); if ("draft".equals(q.queue())) base.eq("status","DRAFT"); @@ -43,6 +47,15 @@ public class MerchantOrderWorkbenchRepository { total/q.size()+(total%q.size()==0?0:1)),counts,channels); } + public List progressScope(long customerId) { + return mapper.selectList(new QueryWrapper().select("id","root_order_id","status") + .eq("tenant_id",customerId).ne("status","CANCELLED")); + } + + private long progressCount(QueryWrapper base,java.util.Map> progressIds,String stage) { + return mapper.selectCount(ids(base.clone(),progressIds.get(stage))); + } + private QueryWrapper base(long customerId, MerchantOrderWorkbenchQuery q, List statusIds) { var w=new QueryWrapper().eq("tenant_id",customerId); String direct=switch(q.mainStatus()==null?"":q.mainStatus()) { 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 df79a8f30..aaf351f97 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java @@ -4,40 +4,109 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.client.LastlegOrderProgressClient; +import com.baiyue.order.dto.MerchantOrderProgress; import com.baiyue.order.dto.MerchantOrderWorkbenchQuery; import com.baiyue.order.dto.MerchantOrderWorkbenchResponse; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.MerchantOrderWorkbenchRepository; import jakarta.annotation.Resource; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.util.Set; +import org.springframework.transaction.annotation.Transactional; @Service public class MerchantOrderWorkbenchService { @Resource private MerchantOrderWorkbenchRepository repository; @Resource private FirstlegUnifiedOrderClient firstleg; @Resource private UnifiedOrderQueryService unified; + @Resource private LastlegOrderProgressClient lastleg; @Value("${baiyue.security.internal-service-token}") private String internalToken; private static final Set DIRECT=Set.of("DRAFT","PENDING_REVIEW","COMPLETED","CANCELLED"); + @Transactional(readOnly=true) public MerchantOrderWorkbenchResponse page(long customerId,MerchantOrderWorkbenchQuery query) { if(query.createdFrom()!=null&&query.createdTo()!=null&&query.createdFrom().isAfter(query.createdTo())) throw new BizException(ResultCode.BAD_REQUEST,"创建时间起止范围不合法"); String status=query.mainStatus()==null||DIRECT.contains(query.mainStatus())?null:query.mainStatus(); FirstlegUnifiedOrderClient.WorkbenchFilters filters; + List tailProgress; try { var response=firstleg.workbenchFilters(internalToken, new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(customerId,status)); if(response==null||!response.successful()||response.data()==null) throw new IllegalStateException(); filters=response.data(); - if(filters.feeOrderIds()==null||filters.customsOrderIds()==null + 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)); + if(tail==null||!tail.successful()||tail.data()==null||tail.data().progress()==null) throw new IllegalStateException(); + tailProgress=tail.data().progress(); + validateFacts(filters.progress(),1,3); + validateFacts(tailProgress,4,5); } catch(RuntimeException failure) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单待办查询暂时不可用,请重试"); } - var result=repository.read(customerId,query,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds()); + var progress=project(repository.progressScope(customerId),filters.progress(),tailProgress); + var progressIds=new LinkedHashMap>(); + for(String stage:MerchantOrderProgress.STAGES.subList(1,7)) + progressIds.put(stage,progress.values().stream().filter(p->stage.equals(p.stage())).map(MerchantOrderProgress::orderId).toList()); + var result=repository.read(customerId,query,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds(),progressIds); var source=result.page(); return new MerchantOrderWorkbenchResponse(new PageResult<>(unified.merchantWorkbenchRows(customerId,source.records()), - source.page(),source.size(),source.total(),source.pages()),result.queues(),result.channelNames()); + source.page(),source.size(),source.total(),source.pages()),result.queues(),result.channelNames(), + source.records().stream().map(o->progress.getOrDefault(o.getId(),new MerchantOrderProgress(o.getId(),"none",false))).toList()); + } + + 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())) + throw new IllegalStateException("Invalid progress response"); + } + + static Map project( + List scope, + List firstleg, + List lastleg) { + var scopeById=scope.stream().collect(Collectors.toMap(OrderEntity::getId,o->o)); + var facts=new HashMap(); + Stream.concat(firstleg.stream(),lastleg.stream()).forEach(p-> + facts.merge(p.orderId(),p,(a,b)->a.rank()>b.rank()?a:b)); + var result=new LinkedHashMap(); + for(var order:scope) { + var status=order.getStatus(); + var fact=status==OrderStatus.COMPLETED + ? new MerchantOrderProgress(order.getId(),"delivered",false) + : status==OrderStatus.DRAFT||status==OrderStatus.SUBMITTED + ? new MerchantOrderProgress(order.getId(),"none",false) + : facts.getOrDefault(order.getId(),new MerchantOrderProgress(order.getId(),"none",false)); + result.put(order.getId(),fact); + } + var children=scope.stream().filter(o->o.getRootOrderId()!=null && !o.getRootOrderId().equals(o.getId())) + .collect(Collectors.groupingBy(OrderEntity::getRootOrderId)); + children.forEach((root,orders)->{ + var own=result.get(root); + if(own==null||"delivered".equals(own.stage())) return; + var rootOrder=scopeById.get(root); + if(rootOrder.getStatus()==OrderStatus.DRAFT||rootOrder.getStatus()==OrderStatus.SUBMITTED) return; + var fragments=new ArrayList(); + fragments.add(own); orders.forEach(o->fragments.add(result.get(o.getId()))); + // Completion remains exclusively owned by Order's trusted root completion gate. + int max=fragments.stream().mapToInt(MerchantOrderProgress::rank).max().orElse(0); + if(max==6) max=5; + final int rank=max; + boolean partial=fragments.stream().anyMatch(p->p.rank()- 仅按登录客户查询,权限merchant:order:list。草稿优先,再按创建时间和订单ID降序稳定分页。 - 入仓码/SKU采用或匹配;所有条件先筛选再计数。四类数量共用当前筛选,不从当前页行数计算。 + 入仓码/SKU采用或匹配;所有条件先筛选再计数。十类数量共用当前筛选,不从当前页行数计算。 默认包含草稿和有效正式单;履约子状态以Firstleg为准,待审核和已完成以Order为准。 security: [{bearerAuth: []}] parameters: @@ -23,7 +23,7 @@ paths: - {name: mainStatus, in: query, schema: {type: string, enum: [DRAFT, PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, CANCELLED]}} - {name: createdFrom, in: query, schema: {type: string, format: date-time}} - {name: createdTo, in: query, schema: {type: string, format: date-time}} - - {name: queue, in: query, schema: {type: string, enum: [all, fee, customs, draft], default: all}} + - {name: queue, in: query, schema: {type: string, enum: [all, fee, customs, draft, loaded, declared, cleared, scheduled, delivering, delivered], default: all}} - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 10}} responses: @@ -37,18 +37,35 @@ paths: code: {type: string} data: type: object - required: [page, queues, channelNames] + required: [page, queues, channelNames, progress] properties: page: {$ref: '#/components/schemas/UnifiedOrderPage'} queues: type: object - required: [all, fee, customs, draft] + required: [all, fee, customs, draft, loaded, declared, cleared, scheduled, delivering, delivered] properties: all: {type: integer, format: int64, minimum: 0} fee: {type: integer, format: int64, minimum: 0} customs: {type: integer, format: int64, minimum: 0} draft: {type: integer, format: int64, minimum: 0} + loaded: {type: integer, format: int64, minimum: 0} + declared: {type: integer, format: int64, minimum: 0} + cleared: {type: integer, format: int64, minimum: 0} + scheduled: {type: integer, format: int64, minimum: 0} + delivering: {type: integer, format: int64, minimum: 0} + delivered: {type: integer, format: int64, minimum: 0} channelNames: {type: array, items: {type: string}, description: 当前客户完整渠道筛选项,不限于当前页} + 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} + /internal/order/v1/fulfillment-completions: post: security: [] 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 edce11769..96d7a9af4 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java @@ -68,11 +68,16 @@ class MerchantOrderWorkbenchRepositoryTest { private MerchantOrderWorkbenchQuery query(String keyword,String queue,long page) { return new MerchantOrderWorkbenchQuery(keyword,null,null,null,null,null,null,null,queue,page,10); } + private java.util.Map> progressIds() { + var map=new java.util.LinkedHashMap>(); + for(String key:List.of("loaded","declared","cleared","scheduled","delivering","delivered")) map.put(key,List.of()); + return map; + } @Test void readsBeyondTheOldTwoHundredLimitWithStableDraftPrefixAndCorrectTotals() { - var first=repository.read(11,query(null,"all",1),null,List.of(210L),List.of(211L)); + var first=repository.read(11,query(null,"all",1),null,List.of(210L),List.of(211L), progressIds()); assertThat(first.page().total()).isEqualTo(212); assertThat(first.page().records().subList(0,3)).extracting(OrderEntity::getId).containsExactly(3L,2L,1L); - var last=repository.read(11,query(null,"all",22),null,List.of(210L),List.of(211L)); + var last=repository.read(11,query(null,"all",22),null,List.of(210L),List.of(211L), progressIds()); assertThat(last.page().records()).extracting(OrderEntity::getId).containsExactly(5L,4L); assertThat(last.page().pages()).isEqualTo(22); assertThat(last.queues().all()).isEqualTo(212); @@ -80,31 +85,44 @@ class MerchantOrderWorkbenchRepositoryTest { assertThat(last.queues().fee()).isEqualTo(1); assertThat(last.queues().customs()).isEqualTo(1); assertThat(last.channelNames()).containsExactly("AIR","SEA"); - assertThat(repository.read(11,query(null,"all",23),null,List.of(),List.of()).page().records()).isEmpty(); + assertThat(repository.read(11,query(null,"all",23),null,List.of(),List.of(), progressIds()).page().records()).isEmpty(); } @Test void filtersBeforeCountingAndPagingAndNeverUsesForeignSkuOrQueueIds() { - var sku=repository.read(11,query("SPECIAL","all",1),null,List.of(210L,214L),List.of(215L)); + var sku=repository.read(11,query("SPECIAL","all",1),null,List.of(210L,214L),List.of(215L), progressIds()); assertThat(sku.page().total()).isEqualTo(1); assertThat(sku.page().records()).extracting(OrderEntity::getId).containsExactly(210L); assertThat(sku.queues().fee()).isEqualTo(1);assertThat(sku.queues().customs()).isZero(); - var fee=repository.read(11,query(null,"fee",1),null,List.of(210L,214L),List.of()); + var fee=repository.read(11,query(null,"fee",1),null,List.of(210L,214L),List.of(), progressIds()); assertThat(fee.page().records()).extracting(OrderEntity::getId).containsExactly(210L); - assertThat(repository.read(11,query(null,"fee",1),null,List.of(),List.of()).page().records()).isEmpty(); - var drafts=repository.read(11,query(null,"draft",1),null,List.of(),List.of()); + assertThat(repository.read(11,query(null,"fee",1),null,List.of(),List.of(), progressIds()).page().records()).isEmpty(); + var drafts=repository.read(11,query(null,"draft",1),null,List.of(),List.of(), progressIds()); assertThat(drafts.page().total()).isEqualTo(3); } @Test void combinesWarehouseChannelDestinationTrackingDatesAndStatusBeforePaging() { var from=java.time.LocalDateTime.of(2026,10,2,0,0); var to=from.plusDays(1).minusNanos(1); var q=new MerchantOrderWorkbenchQuery("IN-210",10L,"SEA","FBA","TRACK-210","LOADED",from,to,"all",1,10); - assertThat(repository.read(11,q,List.of(210L,214L),List.of(),List.of()).page().records()) + assertThat(repository.read(11,q,List.of(210L,214L),List.of(),List.of(), progressIds()).page().records()) .extracting(OrderEntity::getId).containsExactly(210L); - assertThat(repository.read(11,q,List.of(),List.of(),List.of()).page().total()).isZero(); + assertThat(repository.read(11,q,List.of(),List.of(),List.of(), progressIds()).page().total()).isZero(); jdbc.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=210"); - assertThat(repository.read(11,q,List.of(210L),List.of(),List.of()).page().total()).isZero(); + assertThat(repository.read(11,q,List.of(210L),List.of(),List.of(), progressIds()).page().total()).isZero(); var pending=new MerchantOrderWorkbenchQuery(null,null,null,null,null,"PENDING_REVIEW",null,null,"all",1,10); - assertThat(repository.read(11,pending,null,List.of(),List.of()).page().records()).extracting(OrderEntity::getId).containsExactly(210L); + assertThat(repository.read(11,pending,null,List.of(),List.of(), progressIds()).page().records()).extracting(OrderEntity::getId).containsExactly(210L); var outside=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,to.plusDays(1),null,"all",1,10); - assertThat(repository.read(11,outside,null,List.of(),List.of()).page().total()).isZero(); + assertThat(repository.read(11,outside,null,List.of(),List.of(), progressIds()).page().total()).isZero(); + } + + @Test void progressQueuesShareFiltersAndCountBeforePagination() { + var ids=progressIds();ids.put("cleared",List.of(210L,211L,214L));ids.put("delivering",List.of(212L)); + var result=repository.read(11,query(null,"cleared",1),null,List.of(210L),List.of(),ids); + assertThat(result.page().total()).isEqualTo(2); + assertThat(result.queues().cleared()).isEqualTo(2); + assertThat(result.queues().delivering()).isEqualTo(1); + assertThat(result.page().records()).extracting(OrderEntity::getId).containsExactly(211L,210L); + var filtered=repository.read(11,query("SPECIAL","cleared",1),null,List.of(210L),List.of(),ids); + assertThat(filtered.page().total()).isEqualTo(1); + assertThat(filtered.queues().fee()).isEqualTo(1); + assertThat(repository.progressScope(11)).hasSize(212); } } 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 68f65338f..01b67d22c 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java @@ -19,11 +19,14 @@ import static org.mockito.ArgumentMatchers.*; class MerchantOrderWorkbenchServiceTest { private final FirstlegUnifiedOrderClient firstleg = mock(FirstlegUnifiedOrderClient.class); private final MerchantOrderWorkbenchRepository repository = mock(MerchantOrderWorkbenchRepository.class); + private final com.baiyue.order.client.LastlegOrderProgressClient lastleg = mock(com.baiyue.order.client.LastlegOrderProgressClient.class); private final UnifiedOrderQueryService unified = mock(UnifiedOrderQueryService.class); private final MerchantOrderWorkbenchService service = new MerchantOrderWorkbenchService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service, "firstleg", firstleg); + ReflectionTestUtils.setField(service, "lastleg", lastleg); + when(lastleg.read(anyString(),any())).thenReturn(ApiResponse.success(new com.baiyue.order.client.LastlegOrderProgressClient.Response(List.of()))); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "unified", unified); ReflectionTestUtils.setField(service, "internalToken", "test-service-token"); @@ -33,10 +36,10 @@ class MerchantOrderWorkbenchServiceTest { } @Test void combinesScopedFiltersWithDatabasePageWithoutReplacingTotals() { var q = query("LOADED"); - var filters = new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(1L),List.of(2L),List.of(3L)); + var filters = new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(1L),List.of(2L),List.of(3L), List.of()); when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(filters)); - when(repository.read(11,q,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds())) - .thenReturn(new MerchantOrderWorkbenchRepository.Result(new PageResult<>(List.of(),22,10,210,21),new Counts(210,2,1,0),List.of("SEA"))); + when(repository.read(eq(11L),eq(q),eq(filters.matchingStatusOrderIds()),eq(filters.feeOrderIds()),eq(filters.customsOrderIds()),anyMap())) + .thenReturn(new MerchantOrderWorkbenchRepository.Result(new PageResult<>(List.of(),22,10,210,21),new Counts(210,2,1,0,0,0,0,0,0,0),List.of("SEA"))); when(unified.merchantWorkbenchRows(11,List.of())).thenReturn(List.of()); var result = service.page(11,q); assertThat(result.page().total()).isEqualTo(210); @@ -47,9 +50,10 @@ class MerchantOrderWorkbenchServiceTest { } @Test void unavailableOrIncompleteFulfillmentFiltersCannotProduceMisleadingEmptyQueues() { for(var filters : List.of( - new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of()), - new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),null,List.of()), - new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),List.of(),null))) { + new FirstlegUnifiedOrderClient.WorkbenchFilters(null,List.of(),List.of(), List.of()), + new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),null,List.of(), List.of()), + new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),List.of(),null, List.of()), + new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(),List.of(),List.of(),null))) { when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(filters)); assertThatThrownBy(()->service.page(11,query("LOADED"))).isInstanceOf(BizException.class).hasMessageContaining("暂时不可用"); } @@ -63,4 +67,29 @@ class MerchantOrderWorkbenchServiceTest { assertThatThrownBy(()->service.page(11,q)).isInstanceOf(BizException.class).hasMessageContaining("时间"); verifyNoInteractions(firstleg,repository,unified); } + + @Test void tailFailureDoesNotReturnPartialQueueCounts() { + 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()); + assertThatThrownBy(()->service.page(11,query(null))).isInstanceOf(BizException.class); + verifyNoInteractions(repository,unified); + } + private com.baiyue.order.entity.OrderEntity order(long id,Long root,com.baiyue.order.entity.OrderStatus status) { + var o=new com.baiyue.order.entity.OrderEntity();o.setId(id);o.setRootOrderId(root);o.setStatus(status);return o; + } + @Test void currentStageIsExclusiveAndRootRequiresEveryFragmentWithTrustedCompletion() { + var status=com.baiyue.order.entity.OrderStatus.APPROVED; + var scope=List.of(order(1L,1L,status),order(2L,1L,status),order(3L,null,com.baiyue.order.entity.OrderStatus.COMPLETED), + order(4L,null,com.baiyue.order.entity.OrderStatus.SUBMITTED)); + var first=List.of(new com.baiyue.order.dto.MerchantOrderProgress(1L,"cleared",false), + new com.baiyue.order.dto.MerchantOrderProgress(2L,"loaded",false)); + var tail=List.of(new com.baiyue.order.dto.MerchantOrderProgress(2L,"delivering",false), + new com.baiyue.order.dto.MerchantOrderProgress(4L,"scheduled",false), + new com.baiyue.order.dto.MerchantOrderProgress(999L,"delivering",false)); + var projected=MerchantOrderWorkbenchService.project(scope,first,tail); + assertThat(projected.get(1L).stage()).isEqualTo("delivering");assertThat(projected.get(1L).partial()).isTrue(); + assertThat(projected.get(2L).partial()).isFalse();assertThat(projected.get(3L).stage()).isEqualTo("delivered"); + assertThat(projected.get(4L).stage()).isEqualTo("none");assertThat(projected).doesNotContainKey(999L); + } } diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs index 349452c5a..4935f6d0c 100644 --- a/scripts/client-workbench-test-env.mjs +++ b/scripts/client-workbench-test-env.mjs @@ -41,7 +41,7 @@ export async function verifyWorkbench(read, customerId) { const checks = [] const check = (name, ok) => { requireFact(ok, name); checks.push({ name, passed: true }) } async function all(path, workbench = false, params = {}) { - let rows = [], expected = null, first + let rows = [], progress = [], expected = null, first for (let number = 1; ; number++) { const query = new URLSearchParams({ ...params, page: String(number), size: '200' }) const response = await read(`${path}?${query}`) @@ -49,31 +49,44 @@ export async function verifyWorkbench(read, customerId) { if (number === 1) { expected = Number(page.total); first = response } requireFact(Number(page.total) === expected, 'Dataset changed while paging; rerun in a stable window') requireFact(page.records.every(row => String(row.tenantId) === String(customerId)), 'Customer scope mismatch') + if (workbench) { + requireFact(Array.isArray(response.progress) && response.progress.length === page.records.length + && new Set(response.progress.map(p => String(p.orderId))).size === page.records.length + && response.progress.every(p => page.records.some(r => String(r.orderId) === String(p.orderId)) + && ['none','loaded','declared','cleared','scheduled','delivering','delivered'].includes(p.stage) + && typeof p.partial === 'boolean'), 'Invalid progress coverage') + progress.push(...response.progress) + } rows.push(...page.records) if (number >= Number(page.pages)) break requireFact(number < 1000, 'Pagination safety bound exceeded') } requireFact(rows.length === expected && new Set(ids(rows)).size === expected, 'Duplicate or missing orders across pages') - return { rows, first } + return { rows, first, progress } } const workbench = await all(endpoint, true) check('sample actually exceeds the former 200-order limit', workbench.rows.length > 200) const formal = await all('/merchant/v1/orders') check('all formal orders match the independent unified query', equalIds(workbench.rows.filter(r => r.mainStatus !== 'DRAFT'), formal.rows)) const queues = workbench.first.queues - for (const name of ['all', 'fee', 'customs', 'draft']) { + for (const name of ['all', 'fee', 'customs', 'draft', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered']) { check(`queue ${name} has a valid count`, Number.isSafeInteger(Number(queues?.[name])) && Number(queues[name]) >= 0) } check('all queue count matches complete rows', Number(queues.all) === workbench.rows.length) check('fee count matches authoritative confirmation flags', Number(queues.fee) === workbench.rows.filter(r => r.customerFeeConfirmationRequired === true).length) check('draft count matches draft rows', Number(queues.draft) === workbench.rows.filter(r => r.mainStatus === 'DRAFT').length) - for (const queue of ['fee', 'customs', 'draft']) { + for (const queue of ['fee', 'customs', 'draft', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered']) { const selected = await all(endpoint, true, { queue }) check(`${queue} complete page count`, selected.rows.length === Number(queues[queue])) const own = new Set(ids(workbench.rows)) check(`${queue} contains only listed customer orders`, ids(selected.rows).every(id => own.has(id))) if (queue === 'fee') check('fee queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.customerFeeConfirmationRequired === true))) if (queue === 'draft') check('draft queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.mainStatus === 'DRAFT'))) + if (['loaded','declared','cleared','scheduled','delivering','delivered'].includes(queue)) { + const expectedIds = new Set(workbench.progress.filter(p => p.stage === queue).map(p => String(p.orderId))) + check(`${queue} progress exact membership`, selected.rows.length === expectedIds.size && ids(selected.rows).every(id => expectedIds.has(id))) + check(`${queue} selected progress agrees`, selected.progress.every(p => p.stage === queue)) + } } for (const [page, size] of [[21, 10], [2, 50]]) { const result = await read(`${endpoint}?page=${page}&size=${size}`) diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs index 4240a7bdd..350c9d097 100644 --- a/scripts/client-workbench-test-env.test.mjs +++ b/scripts/client-workbench-test-env.test.mjs @@ -25,8 +25,10 @@ function fixture() { mainStatus: i < 3 ? 'DRAFT' : 'LOADED', customerFeeConfirmationRequired: i >= 10 && i < 12, channelName: i % 2 ? 'SEA' : 'AIR' })) const calls = [] + const stages = ['loaded','declared','cleared','scheduled','delivering','delivered'] + const stage = row => row.mainStatus === 'DRAFT' ? 'none' : stages[Number(row.orderId) % stages.length] const queues = list => ({ all: list.length, fee: list.filter(r => r.customerFeeConfirmationRequired).length, - customs: list.filter(r => r.orderId === '1012').length, draft: list.filter(r => r.mainStatus === 'DRAFT').length }) + customs: list.filter(r => r.orderId === '1012').length, draft: list.filter(r => r.mainStatus === 'DRAFT').length, ...Object.fromEntries(stages.map(key => [key, list.filter(r => stage(r) === key).length])) }) function read(path) { calls.push(path) const url = new URL(path, 'https://test.invalid'), q = url.searchParams @@ -36,9 +38,10 @@ function fixture() { if (q.get('queue') === 'fee') selected = selected.filter(r => r.customerFeeConfirmationRequired) if (q.get('queue') === 'customs') selected = selected.filter(r => r.orderId === '1012') if (q.get('queue') === 'draft') selected = selected.filter(r => r.mainStatus === 'DRAFT') + if (stages.includes(q.get('queue'))) selected = selected.filter(r => stage(r) === q.get('queue')) const number = Number(q.get('page')), size = Number(q.get('size')) const page = { page: number, size, total: selected.length, pages: Math.ceil(selected.length / size), records: selected.slice((number - 1) * size, number * size).map(r => ({ ...r })) } - return url.pathname.endsWith('/workbench') ? { page, queues: counts, channelNames: ['AIR', 'SEA'] } : page + return url.pathname.endsWith('/workbench') ? { page, queues: counts, channelNames: ['AIR', 'SEA'], progress: page.records.map(row => ({ orderId: row.orderId, stage: stage(row), partial: false })) } : page } return { read, calls, rows } } @@ -55,6 +58,8 @@ for (const [name, mutate, expected] of [ ['customer leakage', r => { (r.page ?? r).records[0].tenantId = '22' }, /Customer scope/], ['duplicate identity', r => { const p = r.page ?? r; if (p.records.length > 1) p.records[1] = p.records[0] }, /duplicate/], ['partial response', r => { (r.page ?? r).records.pop() }, /Truncated/], + ['missing progress', r => { if (r.queues) delete r.progress }, /progress coverage/], + ['wrong clearing count', r => { if (r.queues) r.queues.cleared = 99 }, /cleared complete page count/], ['wrong fee count', r => { if (r.queues) r.queues.fee = 99 }, /fee count/], ['wrong draft count', r => { if (r.queues) r.queues.draft = 0 }, /draft count/], ['missing channel option', r => { if (r.queues) r.channelNames = ['SEA'] }, /channel options/],