From 1202cafadfc11660ed0aa4132f7a8f2c4c329391 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 18:14:17 +0800 Subject: [PATCH] feat(firstleg): count actionable customer customs cabinets --- .../MerchantContainerCustomsController.java | 6 ++ .../container/dto/ContainerCustomsDtos.java | 1 + .../mapper/ContainerCustomsMapper.java | 36 +++++++++ .../service/ContainerCustomsService.java | 5 ++ .../main/resources/openapi/customs-v1.yaml | 22 ++++++ ...ontainerCustomsControllerContractTest.java | 13 ++++ .../ContainerCustomsPendingCountTest.java | 78 +++++++++++++++++++ 7 files changed, 161 insertions(+) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsPendingCountTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index faa6b9f41..a74cc5376 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -26,6 +26,12 @@ import java.util.List; public class MerchantContainerCustomsController { @Resource private ContainerCustomsService service; + @GetMapping("/pending-summary") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse pendingSummary(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.merchantPendingSummary(FirstlegScopeGuard.merchant(user))); + } + @GetMapping("/orders/{orderId}") @PreAuthorize("hasAuthority('merchant:order:view')") public ApiResponse order(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 776b65125..7acaa47e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -74,4 +74,5 @@ public final class ContainerCustomsDtos { @Size(max = 1000) String resolution) {} public record Download(String fileName, String contentType, byte[] content) {} + public record PendingSummary(long pendingCount) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 0cb37d577..38353542b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -87,6 +87,42 @@ public interface ContainerCustomsMapper { """) Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + /** One customer action badge per actual loaded cabinet, including receipt confirmation. */ + @Select(""" + SELECT COUNT(DISTINCT p.container_booking_id) + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + LEFT JOIN fl_customs_container c ON c.loading_plan_id=p.id + WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ + AND p.id=( + SELECT latest.id FROM fl_loading_plan latest + JOIN fl_loading_plan_item item ON item.loading_plan_id=latest.id AND item.deleted=0 + JOIN fl_inbound_order own ON own.id=item.inbound_id AND own.tenant_id=item.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' AND + """ + ACTUAL_ITEM_SCOPE + """ + ORDER BY COALESCE(latest.confirmed_at,latest.updated_at) DESC,latest.id DESC LIMIT 1 + ) + AND ( + ((c.id IS NULL OR c.status IN ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START')) 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.status='ACTIVE' + WHERE d.customs_container_id=c.id AND d.tenant_id=i.tenant_id + AND scope.tenant_id=i.tenant_id AND scope.inbound_id=i.id)) + OR (c.status='DECLARING' AND EXISTS ( + SELECT 1 FROM fl_customs_customer_document d + JOIN fl_customs_customer_document_inbound scope ON scope.document_id=d.id AND scope.tenant_id=d.tenant_id + JOIN fl_customs_completion_document receipt ON receipt.source_document_id=d.id + WHERE d.customs_container_id=c.id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' + AND d.frozen_at IS NOT NULL AND scope.inbound_id=i.id AND receipt.customer_confirm_status='PENDING')) + ) + """) + long merchantPendingCount(@Param("tenantId") long tenantId); + @Insert(""" INSERT INTO fl_customs_container (id,loading_plan_id,container_booking_id,status,created_at,updated_at,version) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 7882613ba..2425bf67c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -44,6 +44,11 @@ public class ContainerCustomsService { @Resource private ApplicationEventPublisher events; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Transactional(readOnly = true) + public PendingSummary merchantPendingSummary(long tenantId) { + return new PendingSummary(mapper.merchantPendingCount(tenantId)); + } + @Transactional public ContainerView platformView(long planId) { List rows = actualPlanRows(planId); diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index c10bb9c70..e5a7eb370 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -1,6 +1,28 @@ openapi: 3.0.3 info: {title: Firstleg Container Customs API, version: 2.0.0} paths: + /api/merchant/v1/customs/pending-summary: + get: + summary: 当前客户报关资料待处理柜次数量 + description: >- + 权限merchant:order:view,客户范围仅来自认证上下文。只读COUNT,不创建报关记录。 + 最新实际已装柜的退税货物尚未被本客户有效资料覆盖,或DECLARING阶段本客户冻结资料的当前回执为PENDING,均计入。 + 同柜多票、多份资料只计一次;已打回等待平台重做、已确认、已完成及其他客户的待办不计入。 + x-permission: merchant:order:view + responses: + '200': + description: ApiResponse,code为200时才成功;失败不能解释为零待办 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [pendingCount] + properties: + pendingCount: {type: string, pattern: '^[0-9]+$'} /api/platform/v1/customs/loading-plans/{planId}: get: summary: 查询整柜报关状态、缺资料票及文件映射 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index 3a2d1f28c..3d26731a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -30,6 +30,7 @@ class ContainerCustomsControllerContractTest { @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() { assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/merchant/v1/customs"); + assertPermission(MerchantContainerCustomsController.class,"pendingSummary","merchant:order:view"); assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view"); assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); @@ -47,6 +48,18 @@ class ContainerCustomsControllerContractTest { .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .doesNotContain("/cases/","CUSTOMS_DECLARATION"); } + @Test void badgeUsesAuthenticatedCustomerAndRejectsMissingIdentity() { + var service=org.mockito.Mockito.mock(com.baiyue.firstleg.customs.container.service.ContainerCustomsService.class); + var controller=new MerchantContainerCustomsController(); + org.springframework.test.util.ReflectionTestUtils.setField(controller,"service",service); + org.assertj.core.api.Assertions.assertThatThrownBy(()->controller.pendingSummary(null)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + org.mockito.Mockito.verifyNoInteractions(service); + var user=new com.baiyue.common.security.AuthenticatedUser(1L,11L,null,1L,"customer","MERCHANT","WEB",java.util.Set.of("merchant:order:view")); + controller.pendingSummary(user); + org.mockito.Mockito.verify(service).merchantPendingSummary(11L); + } + private static void assertPermission(Class type,String method,String permission) { Method value=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals(method)).findFirst().orElseThrow(); assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsPendingCountTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsPendingCountTest.java new file mode 100644 index 000000000..465b53802 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsPendingCountTest.java @@ -0,0 +1,78 @@ +package com.baiyue.firstleg.customs.container; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class ContainerCustomsPendingCountTest { + private JdbcTemplate jdbc; + private ContainerCustomsMapper mapper; + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:customs-badge-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR,confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,record_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT,package_count INT)"); + jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,tax_mode VARCHAR,deleted INT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,customs_container_id BIGINT,tenant_id BIGINT,status VARCHAR,frozen_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT,tenant_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_customs_completion_document(source_document_id BIGINT,customer_confirm_status VARCHAR)"); + jdbc.update("INSERT INTO fl_loading_plan VALUES(1,10,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_container_booking VALUES(10,'ACTIVE')"); + jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,'TAX_REFUND',0),(2,11,102,'TAX_REFUND',0),(3,22,103,'TAX_REFUND',0)"); + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(1,1,1,11,0,2),(2,1,2,11,0,2),(3,1,3,22,0,2)"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1)"); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(1,100,'PICKED',2),(2,100,'PICKED',2),(3,100,'PICKED',2)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(ContainerCustomsMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(ContainerCustomsMapper.class); + } + @Test void countsCabinetsOnceAndDoesNotCreateMissingCustomsRecords() { + assertThat(mapper.merchantPendingCount(11)).isEqualTo(1); + assertThat(mapper.merchantPendingCount(22)).isEqualTo(1); + assertThat(mapper.merchantPendingCount(33)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_customs_container",Integer.class)).isZero(); + jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN (1,2)"); + assertThat(mapper.merchantPendingCount(11)).isZero(); + assertThat(mapper.merchantPendingCount(22)).isEqualTo(1); + } + @Test void uploadAndConfirmationOnlyCountOwnActionableDocuments() { + jdbc.update("INSERT INTO fl_customs_container VALUES(90,1,'WAITING_CUSTOMER_DOCUMENTS')"); + jdbc.update("INSERT INTO fl_customs_customer_document VALUES(91,90,11,'ACTIVE',NULL),(92,90,22,'ACTIVE',NULL)"); + jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(91,1,11),(91,2,11),(92,3,22)"); + assertThat(mapper.merchantPendingCount(11)).isZero(); + jdbc.update("UPDATE fl_customs_container SET status='DECLARING'"); + jdbc.update("UPDATE fl_customs_customer_document SET frozen_at=CURRENT_TIMESTAMP"); + jdbc.update("INSERT INTO fl_customs_completion_document VALUES(91,'PENDING'),(92,'PENDING')"); + assertThat(mapper.merchantPendingCount(11)).isEqualTo(1); + jdbc.update("UPDATE fl_customs_completion_document SET customer_confirm_status='CONFIRMED' WHERE source_document_id=91"); + assertThat(mapper.merchantPendingCount(11)).isZero();assertThat(mapper.merchantPendingCount(22)).isEqualTo(1); + jdbc.update("UPDATE fl_customs_completion_document SET customer_confirm_status='REJECTED'"); + assertThat(mapper.merchantPendingCount(22)).isZero(); + jdbc.update("UPDATE fl_customs_completion_document SET customer_confirm_status='PENDING'"); + assertThat(mapper.merchantPendingCount(11)).isEqualTo(1); + jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'"); + assertThat(mapper.merchantPendingCount(11)).isZero(); + } + @Test void completedLatestPlanAndInactiveCabinetsDoNotLeaveStaleReminders() { + jdbc.update("INSERT INTO fl_loading_plan VALUES(2,20,'COMPLETED',TIMESTAMP '2099-01-01 00:00:00',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_container_booking VALUES(20,'ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_plan_item VALUES(4,2,1,11,0,2),(5,2,2,11,0,2)"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(200,2)"); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(4,200,'PICKED',2),(5,200,'PICKED',2)"); + jdbc.update("INSERT INTO fl_customs_container VALUES(95,2,'COMPLETED')"); + assertThat(mapper.merchantPendingCount(11)).isZero(); + jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); + assertThat(mapper.merchantPendingCount(22)).isZero(); + } +}