This commit is contained in:
2026-10-09 17:36:24 +08:00
parent 74bf0e73c5
commit 261cdc87cb
27 changed files with 636 additions and 45 deletions
@@ -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<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds) {}
List<Long> customsOrderIds, List<Progress> progress) {}
public record Progress(long orderId, String stage, boolean partial) {}
public record Fragment(long orderId, int stage) {}
}
@@ -63,4 +63,29 @@ public interface MerchantWorkbenchFilterMapper {
ORDER BY i.order_id
""")
List<Long> 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<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId);
}
@@ -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<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> 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();
}
}
@@ -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 内部)
@@ -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);
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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<CustomerOrderProgress.Response> 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()));
}
}
@@ -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> progress) {}
}
@@ -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<CustomerOrderProgress.Source> sources(@Param("customerId") String customerId);
}
@@ -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<CustomerOrderProgress.Progress> project(List<CustomerOrderProgress.Source> 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();
}
}
@@ -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
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -22,7 +22,7 @@ public interface FirstlegUnifiedOrderClient {
record WorkbenchFilterRequest(long tenantId, String mainStatus) {}
record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds) {}
List<Long> customsOrderIds, List<com.baiyue.order.dto.MerchantOrderProgress> progress) {}
@PostMapping("/query")
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
@@ -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<Response> read(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request);
record Request(long customerId) {}
record Response(List<MerchantOrderProgress> progress) {}
}
@@ -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<String> 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;
}
}
@@ -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;
@@ -4,6 +4,14 @@ import com.baiyue.common.core.api.PageResult;
import java.util.List;
public record MerchantOrderWorkbenchResponse(PageResult<UnifiedOrderResponse.Row> page,
Counts queues, List<String> channelNames) {
public record Counts(long all, long fee, long customs, long draft) {}
Counts queues, List<String> channelNames, List<MerchantOrderProgress> 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"); };
}
}
}
@@ -21,14 +21,18 @@ public class MerchantOrderWorkbenchRepository {
@Transactional(readOnly=true)
public Result read(long customerId, MerchantOrderWorkbenchQuery q, List<Long> statusIds,
List<Long> feeIds, List<Long> customsIds) {
List<Long> feeIds, List<Long> customsIds, java.util.Map<String,List<Long>> 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<OrderEntity> progressScope(long customerId) {
return mapper.selectList(new QueryWrapper<OrderEntity>().select("id","root_order_id","status")
.eq("tenant_id",customerId).ne("status","CANCELLED"));
}
private long progressCount(QueryWrapper<OrderEntity> base,java.util.Map<String,List<Long>> progressIds,String stage) {
return mapper.selectCount(ids(base.clone(),progressIds.get(stage)));
}
private QueryWrapper<OrderEntity> base(long customerId, MerchantOrderWorkbenchQuery q, List<Long> statusIds) {
var w=new QueryWrapper<OrderEntity>().eq("tenant_id",customerId);
String direct=switch(q.mainStatus()==null?"":q.mainStatus()) {
@@ -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<String> 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<MerchantOrderProgress> 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<String,List<Long>>();
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<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()))
throw new IllegalStateException("Invalid progress response");
}
static Map<Long,MerchantOrderProgress> project(
List<OrderEntity> scope,
List<MerchantOrderProgress> firstleg,
List<MerchantOrderProgress> lastleg) {
var scopeById=scope.stream().collect(Collectors.toMap(OrderEntity::getId,o->o));
var facts=new HashMap<Long,MerchantOrderProgress>();
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<Long,MerchantOrderProgress>();
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<MerchantOrderProgress>();
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()<rank||p.partial());
result.put(root,new MerchantOrderProgress(root,
MerchantOrderProgress.STAGES.get(rank),partial));
});
return result;
}
}
+21 -4
View File
@@ -11,7 +11,7 @@ paths:
summary: 客户订单工作台分页和筛选后待办数量
description: >-
仅按登录客户查询,权限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: []
@@ -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<String,List<Long>> progressIds() {
var map=new java.util.LinkedHashMap<String,List<Long>>();
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);
}
}
@@ -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);
}
}
+17 -4
View File
@@ -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}`)
+7 -2
View File
@@ -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/],