fix(order): paginate merchant workbench with scoped queue counts
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
||||
| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 |
|
||||
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
|
||||
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
|
||||
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
|
||||
|
||||
+9
@@ -43,6 +43,7 @@ public class InternalInboundOrderController {
|
||||
private InboundCartonLabelService cartonLabels;
|
||||
@Resource
|
||||
private UnifiedInboundQueryService unifiedQueryService;
|
||||
@Resource private com.baiyue.firstleg.service.MerchantWorkbenchFilterService workbenchFilters;
|
||||
@Resource
|
||||
private OrderDeclarationSyncService declarationSync;
|
||||
@Resource
|
||||
@@ -148,6 +149,14 @@ public class InternalInboundOrderController {
|
||||
return ApiResponse.success(remarksSync.sync(command));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/workbench-filters")
|
||||
public ApiResponse<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Response> workbenchFilters(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(workbenchFilters.read(request));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/query")
|
||||
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
/** Customer-scoped filter identifiers; never a replacement order list or cross-schema query. */
|
||||
public final class MerchantWorkbenchFilters {
|
||||
private MerchantWorkbenchFilters() {}
|
||||
|
||||
public record Request(@NotNull @Positive Long tenantId,
|
||||
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {}
|
||||
|
||||
public record Response(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
|
||||
List<Long> customsOrderIds) {}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MerchantWorkbenchFilterMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT order_id FROM fl_inbound_order
|
||||
WHERE tenant_id=#{tenantId} AND deleted=0 AND order_id IS NOT NULL
|
||||
AND process_status IN
|
||||
<foreach collection="statuses" item="status" open="(" separator="," close=")">#{status}</foreach>
|
||||
ORDER BY order_id
|
||||
</script>
|
||||
""")
|
||||
List<Long> byProcessStatuses(@Param("tenantId") long tenantId, @Param("statuses") List<String> statuses);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT i.order_id
|
||||
FROM fl_inbound_order i
|
||||
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
||||
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
|
||||
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
||||
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
||||
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
|
||||
ORDER BY i.order_id
|
||||
""")
|
||||
List<Long> pendingFees(@Param("tenantId") long tenantId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT i.order_id
|
||||
FROM fl_inbound_order i
|
||||
JOIN fl_loading_plan p ON p.id=(
|
||||
SELECT latest.id FROM fl_loading_plan latest
|
||||
JOIN fl_loading_plan_item li ON li.loading_plan_id=latest.id AND li.deleted=0
|
||||
JOIN fl_inbound_order own ON own.id=li.inbound_id AND own.tenant_id=li.account_id AND own.deleted=0
|
||||
WHERE latest.plan_status='COMPLETED' AND own.tenant_id=i.tenant_id
|
||||
AND own.order_id=i.order_id AND own.tax_mode='TAX_REFUND'
|
||||
ORDER BY COALESCE(latest.confirmed_at,latest.updated_at) DESC,latest.id DESC LIMIT 1
|
||||
)
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
LEFT JOIN fl_customs_container c ON c.loading_plan_id=p.id
|
||||
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.tax_mode='TAX_REFUND'
|
||||
AND i.process_status IN ('LOADED','DELIVERED')
|
||||
AND (c.id IS NULL OR c.status='WAITING_CUSTOMER_DOCUMENTS')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item item
|
||||
JOIN fl_inbound_order member ON member.id=item.inbound_id
|
||||
AND member.tenant_id=item.account_id AND member.deleted=0
|
||||
WHERE item.loading_plan_id=p.id AND item.deleted=0
|
||||
AND member.tenant_id=i.tenant_id AND member.tax_mode='TAX_REFUND'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_customs_customer_document_inbound scope
|
||||
JOIN fl_customs_customer_document d ON d.id=scope.document_id
|
||||
AND d.customs_container_id=c.id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE'
|
||||
WHERE scope.inbound_id=member.id AND scope.tenant_id=i.tenant_id
|
||||
)
|
||||
)
|
||||
ORDER BY i.order_id
|
||||
""")
|
||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MerchantWorkbenchFilterService {
|
||||
@Resource private MerchantWorkbenchFilterMapper mapper;
|
||||
@Resource private UnifiedInboundQueryService unified;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ public class UnifiedInboundQueryService {
|
||||
return mainStatus != null && Set.of("LOADING", "LOADED", "DELIVERED").contains(mainStatus);
|
||||
}
|
||||
|
||||
private List<String> internalStatuses(String mainStatus) {
|
||||
List<String> internalStatuses(String mainStatus) {
|
||||
if (!StringUtils.hasText(mainStatus)) return List.of();
|
||||
return switch (mainStatus) {
|
||||
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
|
||||
@@ -352,6 +352,37 @@ paths:
|
||||
operatorName: {type: string, nullable: true, maxLength: 128}
|
||||
responses:
|
||||
'200': {description: data 返回已同步的预装明细数量}
|
||||
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
||||
post:
|
||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [tenantId]
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
|
||||
responses:
|
||||
'200':
|
||||
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
type: object
|
||||
required: [feeOrderIds, customsOrderIds]
|
||||
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}}
|
||||
/internal/firstleg/v1/inbounds/unified-orders/query:
|
||||
post:
|
||||
summary: 按统一订单条件分页查询履约摘要(Order 内部)
|
||||
|
||||
+11
@@ -40,9 +40,20 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
@MockBean
|
||||
private OrderRemarksSyncService remarksSync;
|
||||
@MockBean private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot;
|
||||
@MockBean private com.baiyue.firstleg.service.MerchantWorkbenchFilterService workbenchFilters;
|
||||
@Resource
|
||||
private InternalInboundOrderController controller;
|
||||
|
||||
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
|
||||
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED");
|
||||
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());
|
||||
when(workbenchFilters.read(request)).thenReturn(response);
|
||||
assertThat(controller.workbenchFilters("internal-token", request).data()).isEqualTo(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionSnapshotRequiresInternalTokenAndDelegatesExactCommand() {
|
||||
var command = new com.baiyue.firstleg.dto.OrderFulfillmentCompleted(11, 21,
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
||||
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.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MerchantWorkbenchFilterMapperTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private MerchantWorkbenchFilterMapper mapper;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
|
||||
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR(32))");
|
||||
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)");
|
||||
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)");
|
||||
}
|
||||
|
||||
@Test void latestFeeVersionAndCustomerScopeDetermineConfirmationQueue() {
|
||||
jdbc.update("INSERT INTO fl_inbound_fee VALUES(10,11,1,1,'SENT',NULL),(11,11,1,2,'DRAFT',NULL),(12,11,2,1,'SENT',NULL),(13,22,3,1,'SENT',NULL),(14,11,4,1,'SENT',NULL)");
|
||||
assertThat(mapper.pendingFees(11)).containsExactly(102L);
|
||||
jdbc.update("INSERT INTO fl_inbound_fee VALUES(15,11,2,1,'SENT',CURRENT_TIMESTAMP)");
|
||||
assertThat(mapper.pendingFees(11)).isEmpty();
|
||||
assertThat(mapper.pendingFees(22)).containsExactly(103L);
|
||||
assertThat(mapper.byProcessStatuses(11,List.of("LOADED"))).containsExactly(101L,105L);
|
||||
}
|
||||
|
||||
@Test void absentAggregateIsReadOnlyAndWaitingForOwnDocumentsNotAnotherCustomer() {
|
||||
assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_customs_container",Integer.class)).isZero();
|
||||
jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')");
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document VALUES(201,200,11,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,1,11)");
|
||||
assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L);
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,2,11)");
|
||||
assertThat(mapper.pendingCustoms(11)).isEmpty();
|
||||
assertThat(mapper.pendingCustoms(22)).containsExactly(103L);
|
||||
}
|
||||
|
||||
@Test void foreignOrDeletedDocumentsCannotSatisfyOwnCoverageAndClosedContainersAreExcluded() {
|
||||
jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'WAITING_CUSTOMER_DOCUMENTS')");
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document VALUES(201,200,22,'ACTIVE'),(202,200,11,'DELETED')");
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(201,1,11),(202,2,11)");
|
||||
assertThat(mapper.pendingCustoms(11)).containsExactly(101L,102L);
|
||||
for(String status:List.of("READY_TO_START","DECLARING","COMPLETED")) {
|
||||
jdbc.update("UPDATE fl_customs_container SET status=?",status);
|
||||
assertThat(mapper.pendingCustoms(11)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void onlyLatestCompletedPlanCanCreateTheCurrentUploadQueue() {
|
||||
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_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();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
| 目标 | 入口 |
|
||||
|---|---|
|
||||
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
|
||||
| 客户订单工作台分页与待办 | `controller/MerchantUnifiedOrderController.java` 的 `/workbench`、`service/MerchantOrderWorkbenchService.java`、`repository/MerchantOrderWorkbenchRepository.java`;客户取认证上下文,筛选后在 Order 计算总数并分页,Firstleg 经内部服务身份提供履约状态、费用确认和报关待上传范围;契约 `src/main/resources/openapi/order-v1.yaml` |
|
||||
| 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 |
|
||||
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
|
||||
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序;`POST /batch` 按最多 100 个订单 ID 批量核验待审核状态,供同柜建组幂等准备 Firstleg `PREPARING` 投影 |
|
||||
|
||||
@@ -16,6 +16,14 @@ import java.util.List;
|
||||
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient",
|
||||
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders")
|
||||
public interface FirstlegUnifiedOrderClient {
|
||||
@PostMapping("/workbench-filters")
|
||||
ApiResponse<WorkbenchFilters> workbenchFilters(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody WorkbenchFilterRequest request);
|
||||
|
||||
record WorkbenchFilterRequest(long tenantId, String mainStatus) {}
|
||||
record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
|
||||
List<Long> customsOrderIds) {}
|
||||
|
||||
@PostMapping("/query")
|
||||
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody Query request);
|
||||
|
||||
@@ -28,6 +28,16 @@ import java.util.List;
|
||||
public class MerchantUnifiedOrderController {
|
||||
@Resource private UnifiedOrderQueryService service;
|
||||
@Resource private OrderFulfillmentSplitService splitService;
|
||||
@Resource private com.baiyue.order.service.MerchantOrderWorkbenchService workbench;
|
||||
|
||||
@GetMapping("/workbench")
|
||||
@PreAuthorize("hasAuthority('merchant:order:list')")
|
||||
@Operation(summary = "客户订单工作台分页与待办数量")
|
||||
public ApiResponse<com.baiyue.order.dto.MerchantOrderWorkbenchResponse> workbench(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid com.baiyue.order.dto.MerchantOrderWorkbenchQuery query) {
|
||||
return ApiResponse.success(workbench.page(OrderScopeGuard.merchant(user),query));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('merchant:order:list')")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record MerchantOrderWorkbenchQuery(
|
||||
@Size(max=64) String cargoKeyword, Long receivingWarehouseId,
|
||||
@Size(max=128) String channelName,
|
||||
@Pattern(regexp="FBA|OVERSEAS|PRIVATE") String destinationType,
|
||||
@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,
|
||||
@Min(1) long page, @Min(1) @Max(200) long size) {
|
||||
public MerchantOrderWorkbenchQuery {
|
||||
if (page==0) page=1;
|
||||
if (size==0) size=10;
|
||||
if (queue==null) queue="all";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
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) {}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.order.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchQuery;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchResponse.Counts;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.mapper.OrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class MerchantOrderWorkbenchRepository {
|
||||
@Resource private OrderMapper mapper;
|
||||
private static final List<String> ACTIVE=List.of("DRAFT","SUBMITTED","APPROVED","ACCEPTED","PROCESSING","COMPLETED");
|
||||
public record Result(PageResult<OrderEntity> page, Counts queues, List<String> channelNames) {}
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public Result read(long customerId, MerchantOrderWorkbenchQuery q, List<Long> statusIds,
|
||||
List<Long> feeIds, List<Long> customsIds) {
|
||||
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;};
|
||||
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");
|
||||
base.orderByDesc("CASE WHEN status='DRAFT' THEN 1 ELSE 0 END","created_at","id");
|
||||
var page=new Page<OrderEntity>(q.page(),q.size(),false);
|
||||
page.setTotal(total);
|
||||
if (total>0) mapper.selectPage(page,base);
|
||||
var channels=mapper.selectObjs(new QueryWrapper<OrderEntity>().select("DISTINCT channel_name")
|
||||
.eq("tenant_id",customerId).isNotNull("channel_name").ne("channel_name","").orderByAsc("channel_name"))
|
||||
.stream().map(Object::toString).toList();
|
||||
return new Result(new PageResult<>(page.getRecords(),q.page(),q.size(),total,
|
||||
total/q.size()+(total%q.size()==0?0:1)),counts,channels);
|
||||
}
|
||||
|
||||
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()) {
|
||||
case "DRAFT"->"DRAFT";case "CANCELLED"->"CANCELLED";case "COMPLETED"->"COMPLETED";
|
||||
case "PENDING_REVIEW"->"SUBMITTED";default->null;
|
||||
};
|
||||
if(direct!=null) w.eq("status",direct);
|
||||
else if(q.mainStatus()!=null) ids(w.in("status",List.of("APPROVED","ACCEPTED","PROCESSING")),statusIds);
|
||||
else w.in("status",ACTIVE);
|
||||
w.eq(q.receivingWarehouseId()!=null,"receiving_warehouse_id",q.receivingWarehouseId())
|
||||
.eq(StringUtils.hasText(q.channelName()),"channel_name",q.channelName())
|
||||
.eq(StringUtils.hasText(q.destinationType()),"destination_type",q.destinationType())
|
||||
.like(StringUtils.hasText(q.trackingNumber()),"express_tracking_no",q.trackingNumber())
|
||||
.ge(q.createdFrom()!=null,"created_at",q.createdFrom())
|
||||
.le(q.createdTo()!=null,"created_at",q.createdTo());
|
||||
if(StringUtils.hasText(q.cargoKeyword())) w.and(n->n.like("inbound_code",q.cargoKeyword())
|
||||
.or().apply("EXISTS (SELECT 1 FROM ord_order_cargo cargo WHERE cargo.tenant_id=ord_order.tenant_id "
|
||||
+"AND cargo.order_id=ord_order.id AND cargo.deleted=0 AND cargo.sku_no LIKE CONCAT('%',{0},'%'))",q.cargoKeyword()));
|
||||
return w;
|
||||
}
|
||||
|
||||
private QueryWrapper<OrderEntity> ids(QueryWrapper<OrderEntity> query,List<Long> values) {
|
||||
if(values==null) throw new IllegalArgumentException("Order filter identifiers are required");
|
||||
return values.isEmpty()?query.apply("1=0"):query.in("id",values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
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.dto.MerchantOrderWorkbenchQuery;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchResponse;
|
||||
import com.baiyue.order.repository.MerchantOrderWorkbenchRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class MerchantOrderWorkbenchService {
|
||||
@Resource private MerchantOrderWorkbenchRepository repository;
|
||||
@Resource private FirstlegUnifiedOrderClient firstleg;
|
||||
@Resource private UnifiedOrderQueryService unified;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
private static final Set<String> DIRECT=Set.of("DRAFT","PENDING_REVIEW","COMPLETED","CANCELLED");
|
||||
|
||||
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;
|
||||
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
|
||||
||status!=null&&filters.matchingStatusOrderIds()==null) throw new IllegalStateException();
|
||||
} catch(RuntimeException failure) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单待办查询暂时不可用,请重试");
|
||||
}
|
||||
var result=repository.read(customerId,query,filters.matchingStatusOrderIds(),filters.feeOrderIds(),filters.customsOrderIds());
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,15 @@ public class UnifiedOrderQueryService {
|
||||
order.getInternalRemark(), childCounts, cargoSummaries.get(order.getId()), null)).toList();
|
||||
}
|
||||
|
||||
public List<UnifiedOrderResponse.Row> merchantWorkbenchRows(long tenantId,List<OrderEntity> selected) {
|
||||
List<Long> activeIds=selected.stream().filter(order->order.getStatus()!=OrderStatus.DRAFT
|
||||
&&order.getStatus()!=OrderStatus.CANCELLED).map(OrderEntity::getId).toList();
|
||||
var summaries=summaries(tenantId,activeIds);
|
||||
var cargo=cargoSummaries(tenantId,selected);
|
||||
var children=childCounts(selected);
|
||||
return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList();
|
||||
}
|
||||
|
||||
private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
|
||||
boolean includeCoLoadGroups) {
|
||||
validate(query);
|
||||
@@ -116,8 +125,9 @@ public class UnifiedOrderQueryService {
|
||||
OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargoInheritedByOrder(tenantId, orderId),
|
||||
orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(),
|
||||
orders.findHistory(tenantId, orderId));
|
||||
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
|
||||
? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId);
|
||||
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.CANCELLED
|
||||
? null : order.getStatus() == OrderStatus.SUBMITTED
|
||||
? optionalFirstlegDetail(tenantId, orderId) : firstlegDetail(tenantId, orderId);
|
||||
String latestNote = order.getInternalRemark();
|
||||
String creatorName = orderDetail.statusHistory().stream()
|
||||
.filter(item -> "CREATE".equals(item.getAction()))
|
||||
@@ -439,6 +449,7 @@ public class UnifiedOrderQueryService {
|
||||
|
||||
private String orderMainStatus(OrderStatus status) {
|
||||
if (status == null) return null;
|
||||
if (status == OrderStatus.DRAFT || status == OrderStatus.CANCELLED) return status.name();
|
||||
return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
|
||||
: status == OrderStatus.COMPLETED ? "COMPLETED"
|
||||
: ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null;
|
||||
|
||||
@@ -6,6 +6,49 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/merchant/v1/orders/workbench:
|
||||
get:
|
||||
summary: 客户订单工作台分页和筛选后待办数量
|
||||
description: >-
|
||||
仅按登录客户查询,权限merchant:order:list。草稿优先,再按创建时间和订单ID降序稳定分页。
|
||||
入仓码/SKU采用或匹配;所有条件先筛选再计数。四类数量共用当前筛选,不从当前页行数计算。
|
||||
默认包含草稿和有效正式单;履约子状态以Firstleg为准,待审核和已完成以Order为准。
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: cargoKeyword, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: receivingWarehouseId, in: query, schema: {type: integer, format: int64}}
|
||||
- {name: channelName, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: destinationType, in: query, schema: {type: string, enum: [FBA, OVERSEAS, PRIVATE]}}
|
||||
- {name: trackingNumber, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {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: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 10}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务错误;后端依赖不可用时返回明确错误,不返回局部成功数据
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
type: object
|
||||
required: [page, queues, channelNames]
|
||||
properties:
|
||||
page: {$ref: '#/components/schemas/UnifiedOrderPage'}
|
||||
queues:
|
||||
type: object
|
||||
required: [all, fee, customs, draft]
|
||||
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}
|
||||
channelNames: {type: array, items: {type: string}, description: 当前客户完整渠道筛选项,不限于当前页}
|
||||
/internal/order/v1/fulfillment-completions:
|
||||
post:
|
||||
security: []
|
||||
@@ -1041,7 +1084,7 @@ components:
|
||||
asOf: {type: string, format: date-time}
|
||||
UnifiedMainStatus:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]
|
||||
enum: [DRAFT, PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, CANCELLED]
|
||||
description: >-
|
||||
统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT;
|
||||
STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchQuery;
|
||||
import com.baiyue.order.service.MerchantOrderWorkbenchService;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.Set;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MerchantOrderWorkbenchControllerTest {
|
||||
@Test void takesCustomerOnlyFromAuthenticatedMerchantAndKeepsListPermission() throws Exception {
|
||||
var service=mock(MerchantOrderWorkbenchService.class);
|
||||
var controller=new MerchantUnifiedOrderController();
|
||||
ReflectionTestUtils.setField(controller,"workbench",service);
|
||||
var query=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,null,null,null,0,0);
|
||||
var user=new AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",Set.of("merchant:order:list"));
|
||||
controller.workbench(user,query);
|
||||
verify(service).page(11,query);
|
||||
clearInvocations(service);
|
||||
for(var wrong : new AuthenticatedUser[]{null,
|
||||
new AuthenticatedUser(1L,0L,null,1L,"platform","PLATFORM","WEB",Set.of()),
|
||||
new AuthenticatedUser(1L,0L,null,1L,"customer","MERCHANT","WEB",Set.of())}) {
|
||||
assertThatThrownBy(()->controller.workbench(wrong,query)).isInstanceOf(BizException.class);
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
assertThat(MerchantUnifiedOrderController.class.getMethod("workbench",AuthenticatedUser.class,MerchantOrderWorkbenchQuery.class)
|
||||
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('merchant:order:list')");
|
||||
}
|
||||
@Test void validatesPageBoundsAndClosedFilterEnums() {
|
||||
try(var factory=Validation.buildDefaultValidatorFactory()) {
|
||||
var validator=factory.getValidator();
|
||||
var invalid=new MerchantOrderWorkbenchQuery(null,null,null,"UNKNOWN",null,"UNKNOWN",null,null,"UNKNOWN",-1,201);
|
||||
assertThat(validator.validate(invalid)).extracting(v->v.getPropertyPath().toString())
|
||||
.contains("destinationType","mainStatus","queue","page","size");
|
||||
var defaults=new MerchantOrderWorkbenchQuery(null,null,null,null,null,"DRAFT",null,null,null,0,0);
|
||||
assertThat(validator.validate(defaults)).isEmpty();
|
||||
assertThat(defaults.page()).isEqualTo(1);assertThat(defaults.size()).isEqualTo(10);
|
||||
}
|
||||
}
|
||||
@Test void bindsBrowserIsoDatesAndIgnoresSpoofedCustomerParameters() throws Exception {
|
||||
var service=mock(MerchantOrderWorkbenchService.class);
|
||||
var controller=new MerchantUnifiedOrderController();
|
||||
ReflectionTestUtils.setField(controller,"workbench",service);
|
||||
var user=new AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",Set.of("merchant:order:list"));
|
||||
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
|
||||
public boolean supportsParameter(MethodParameter p) { return p.getParameterType()==AuthenticatedUser.class; }
|
||||
public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory f) { return user; }
|
||||
}).build();
|
||||
mvc.perform(get("/api/merchant/v1/orders/workbench").param("page","21").param("size","10")
|
||||
.param("createdFrom","2026-10-01T00:00:00").param("createdTo","2026-10-03T23:59:59.999")
|
||||
.param("tenantId","999").param("customerId","999")).andExpect(status().isOk());
|
||||
var captured=org.mockito.ArgumentCaptor.forClass(MerchantOrderWorkbenchQuery.class);
|
||||
verify(service).page(eq(11L),captured.capture());
|
||||
assertThat(captured.getValue().page()).isEqualTo(21);
|
||||
assertThat(captured.getValue().createdTo()).isEqualTo(java.time.LocalDateTime.of(2026,10,3,23,59,59,999000000));
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,8 @@ class UnifiedOrderApiContractTest {
|
||||
Map<String, Object> schemas = stringMap(components.get("schemas"));
|
||||
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
|
||||
assertThat(stringList(responseStatus.get("enum"))).containsExactly(
|
||||
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED", "COMPLETED");
|
||||
"DRAFT", "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED", "COMPLETED", "CANCELLED");
|
||||
assertThat(responseStatus.get("description").toString())
|
||||
.contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
|
||||
Schema queryAnnotation = UnifiedOrderQuery.class.getDeclaredField("mainStatus")
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.order.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.mapper.OrderMapper;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchQuery;
|
||||
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 org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Real mapper + pagination interceptor: customer filters apply before COUNT and LIMIT. */
|
||||
class MerchantOrderWorkbenchRepositoryTest {
|
||||
private MerchantOrderWorkbenchRepository repository;
|
||||
private JdbcTemplate jdbc;
|
||||
private OrderMapper mapper;
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
var dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:workbench-page-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
var columns = new ArrayList<String>();
|
||||
for (Class<?> type = OrderEntity.class; type != Object.class; type = type.getSuperclass()) {
|
||||
for (var field : type.getDeclaredFields()) {
|
||||
var annotation = field.getAnnotation(TableField.class);
|
||||
if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue;
|
||||
String name = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
|
||||
Class<?> valueType = field.getType();
|
||||
String sqlType = valueType == Long.class || valueType == long.class ? "BIGINT"
|
||||
: valueType == Integer.class || valueType == int.class || valueType == Boolean.class || valueType == boolean.class ? "INT"
|
||||
: valueType == java.math.BigDecimal.class ? "DECIMAL(20,6)"
|
||||
: valueType == java.time.LocalDateTime.class ? "TIMESTAMP"
|
||||
: valueType == java.time.LocalDate.class ? "DATE" : "VARCHAR(2000)";
|
||||
columns.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY"
|
||||
: name.equals("deleted") || name.equals("version") ? " DEFAULT 0"
|
||||
: name.equals("created_at") ? " DEFAULT CURRENT_TIMESTAMP" : ""));
|
||||
}
|
||||
}
|
||||
jdbc.execute("CREATE TABLE ord_order (" + String.join(",", columns) + ")");
|
||||
jdbc.execute("CREATE TABLE ord_order_cargo(id BIGINT,tenant_id BIGINT,order_id BIGINT,deleted INT,sku_no VARCHAR(128))");
|
||||
for(int id=1;id<=215;id++) jdbc.update("INSERT INTO ord_order(id,tenant_id,status,inbound_code,channel_name,destination_type,receiving_warehouse_id,express_tracking_no,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
id,id>213?22:11,id<=3?"DRAFT":"APPROVED","IN-"+id,id%2==0?"SEA":"AIR","FBA",10,"TRACK-"+id,java.time.LocalDateTime.of(2026,10,2,12,0));
|
||||
jdbc.update("UPDATE ord_order SET deleted=1 WHERE id=213");
|
||||
jdbc.update("INSERT INTO ord_order_cargo VALUES(1,11,210,0,'SPECIAL'),(2,22,211,0,'SPECIAL')");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true); config.addMapper(OrderMapper.class);
|
||||
var pagination = new MybatisPlusInterceptor();
|
||||
pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(config); factory.setPlugins(pagination);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
repository = new MerchantOrderWorkbenchRepository();
|
||||
mapper = session.getMapper(OrderMapper.class);
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
}
|
||||
private MerchantOrderWorkbenchQuery query(String keyword,String queue,long page) {
|
||||
return new MerchantOrderWorkbenchQuery(keyword,null,null,null,null,null,null,null,queue,page,10);
|
||||
}
|
||||
@Test void readsBeyondTheOldTwoHundredLimitWithStableDraftPrefixAndCorrectTotals() {
|
||||
var first=repository.read(11,query(null,"all",1),null,List.of(210L),List.of(211L));
|
||||
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));
|
||||
assertThat(last.page().records()).extracting(OrderEntity::getId).containsExactly(5L,4L);
|
||||
assertThat(last.page().pages()).isEqualTo(22);
|
||||
assertThat(last.queues().all()).isEqualTo(212);
|
||||
assertThat(last.queues().draft()).isEqualTo(3);
|
||||
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();
|
||||
}
|
||||
@Test void filtersBeforeCountingAndPagingAndNeverUsesForeignSkuOrQueueIds() {
|
||||
var sku=repository.read(11,query("SPECIAL","all",1),null,List.of(210L,214L),List.of(215L));
|
||||
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());
|
||||
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(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())
|
||||
.extracting(OrderEntity::getId).containsExactly(210L);
|
||||
assertThat(repository.read(11,q,List.of(),List.of(),List.of()).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();
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchQuery;
|
||||
import com.baiyue.order.dto.MerchantOrderWorkbenchResponse.Counts;
|
||||
import com.baiyue.order.repository.MerchantOrderWorkbenchRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class MerchantOrderWorkbenchServiceTest {
|
||||
private final FirstlegUnifiedOrderClient firstleg = mock(FirstlegUnifiedOrderClient.class);
|
||||
private final MerchantOrderWorkbenchRepository repository = mock(MerchantOrderWorkbenchRepository.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, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "unified", unified);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-service-token");
|
||||
}
|
||||
private MerchantOrderWorkbenchQuery query(String status) {
|
||||
return new MerchantOrderWorkbenchQuery(null,null,null,null,null,status,null,null,"all",22,10);
|
||||
}
|
||||
@Test void combinesScopedFiltersWithDatabasePageWithoutReplacingTotals() {
|
||||
var q = query("LOADED");
|
||||
var filters = new FirstlegUnifiedOrderClient.WorkbenchFilters(List.of(1L),List.of(2L),List.of(3L));
|
||||
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(unified.merchantWorkbenchRows(11,List.of())).thenReturn(List.of());
|
||||
var result = service.page(11,q);
|
||||
assertThat(result.page().total()).isEqualTo(210);
|
||||
assertThat(result.page().page()).isEqualTo(22);
|
||||
assertThat(result.queues().fee()).isEqualTo(2);
|
||||
verify(firstleg).workbenchFilters("test-service-token",new FirstlegUnifiedOrderClient.WorkbenchFilterRequest(11,"LOADED"));
|
||||
verify(unified).merchantWorkbenchRows(11,List.of());
|
||||
}
|
||||
@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))) {
|
||||
when(firstleg.workbenchFilters(anyString(),any())).thenReturn(ApiResponse.success(filters));
|
||||
assertThatThrownBy(()->service.page(11,query("LOADED"))).isInstanceOf(BizException.class).hasMessageContaining("暂时不可用");
|
||||
}
|
||||
when(firstleg.workbenchFilters(anyString(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.page(11,query(null))).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(repository,unified);
|
||||
}
|
||||
@Test void invalidDateRangeFailsBeforeAnyCrossServiceRead() {
|
||||
var now=LocalDateTime.of(2026,10,3,0,0);
|
||||
var q=new MerchantOrderWorkbenchQuery(null,null,null,null,null,null,now,now.minusDays(1),null,0,0);
|
||||
assertThatThrownBy(()->service.page(11,q)).isInstanceOf(BizException.class).hasMessageContaining("时间");
|
||||
verifyNoInteractions(firstleg,repository,unified);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user