diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3279bbcd3..38d0741ce 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`,只接受内部服务令牌 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index b8f0ced52..88cd299c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -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 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> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java new file mode 100644 index 000000000..5866dc917 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -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 matchingStatusOrderIds, List feeOrderIds, + List customsOrderIds) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java new file mode 100644 index 000000000..214c45ba0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -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(""" + + """) + List byProcessStatuses(@Param("tenantId") long tenantId, @Param("statuses") List 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 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 pendingCustoms(@Param("tenantId") long tenantId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java new file mode 100644 index 000000000..785d506db --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -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())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index e4848fdd0..7ec66f537 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -199,7 +199,7 @@ public class UnifiedInboundQueryService { return mainStatus != null && Set.of("LOADING", "LOADED", "DELIVERED").contains(mainStatus); } - private List internalStatuses(String mainStatus) { + List 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", diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index faa977f34..7f6c33527 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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 内部) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 59a083a37..56d55faca 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -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, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java new file mode 100644 index 000000000..fcd01ece1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -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(); + } +} diff --git a/order/AGENTS.md b/order/AGENTS.md index e6acbabff..23ad5d926 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -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` 投影 | diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 0faf2484f..025557a32 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -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(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody WorkbenchFilterRequest request); + + record WorkbenchFilterRequest(long tenantId, String mainStatus) {} + record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, + List customsOrderIds) {} + @PostMapping("/query") ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query request); diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java index 6c30ba57a..d977e7523 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -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 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')") diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java new file mode 100644 index 000000000..842fdfad5 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchQuery.java @@ -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"; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java new file mode 100644 index 000000000..f5a0b3f83 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantOrderWorkbenchResponse.java @@ -0,0 +1,9 @@ +package com.baiyue.order.dto; + +import com.baiyue.common.core.api.PageResult; +import java.util.List; + +public record MerchantOrderWorkbenchResponse(PageResult page, + Counts queues, List channelNames) { + public record Counts(long all, long fee, long customs, long draft) {} +} diff --git a/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java new file mode 100644 index 000000000..a3f0ede92 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepository.java @@ -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 ACTIVE=List.of("DRAFT","SUBMITTED","APPROVED","ACCEPTED","PROCESSING","COMPLETED"); + public record Result(PageResult page, Counts queues, List channelNames) {} + + @Transactional(readOnly=true) + public Result read(long customerId, MerchantOrderWorkbenchQuery q, List statusIds, + List feeIds, List 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(q.page(),q.size(),false); + page.setTotal(total); + if (total>0) mapper.selectPage(page,base); + var channels=mapper.selectObjs(new QueryWrapper().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 base(long customerId, MerchantOrderWorkbenchQuery q, List statusIds) { + var w=new QueryWrapper().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 ids(QueryWrapper query,List values) { + if(values==null) throw new IllegalArgumentException("Order filter identifiers are required"); + return values.isEmpty()?query.apply("1=0"):query.in("id",values); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java new file mode 100644 index 000000000..df79a8f30 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/MerchantOrderWorkbenchService.java @@ -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 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()); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index c266bc268..9722b842f 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -74,6 +74,15 @@ public class UnifiedOrderQueryService { order.getInternalRemark(), childCounts, cargoSummaries.get(order.getId()), null)).toList(); } + public List merchantWorkbenchRows(long tenantId,List selected) { + List 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 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; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 30a81fd82..e1bcba11b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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 仅兼容存量已入库记录。 diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java new file mode 100644 index 000000000..fc2cd3196 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderWorkbenchControllerTest.java @@ -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)); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 07a5ecf2d..fa625f4d0 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -50,8 +50,8 @@ class UnifiedOrderApiContractTest { Map schemas = stringMap(components.get("schemas")); Map 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") diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java new file mode 100644 index 000000000..edce11769 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/MerchantOrderWorkbenchRepositoryTest.java @@ -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(); + 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(); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java new file mode 100644 index 000000000..68f65338f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderWorkbenchServiceTest.java @@ -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); + } +}