feat(firstleg): count actionable customer customs cabinets

This commit is contained in:
2026-10-09 18:14:17 +08:00
parent d11abb78ac
commit 1202cafadf
7 changed files with 161 additions and 0 deletions
@@ -26,6 +26,12 @@ import java.util.List;
public class MerchantContainerCustomsController { public class MerchantContainerCustomsController {
@Resource private ContainerCustomsService service; @Resource private ContainerCustomsService service;
@GetMapping("/pending-summary")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<PendingSummary> pendingSummary(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.merchantPendingSummary(FirstlegScopeGuard.merchant(user)));
}
@GetMapping("/orders/{orderId}") @GetMapping("/orders/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')") @PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<ContainerView> order(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerView> order(@AuthenticationPrincipal AuthenticatedUser user,
@@ -74,4 +74,5 @@ public final class ContainerCustomsDtos {
@Size(max = 1000) String resolution) {} @Size(max = 1000) String resolution) {}
public record Download(String fileName, String contentType, byte[] content) {} public record Download(String fileName, String contentType, byte[] content) {}
public record PendingSummary(long pendingCount) {}
} }
@@ -87,6 +87,42 @@ public interface ContainerCustomsMapper {
""") """)
Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); 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("""
INSERT INTO fl_customs_container INSERT INTO fl_customs_container
(id,loading_plan_id,container_booking_id,status,created_at,updated_at,version) (id,loading_plan_id,container_booking_id,status,created_at,updated_at,version)
@@ -44,6 +44,11 @@ public class ContainerCustomsService {
@Resource private ApplicationEventPublisher events; @Resource private ApplicationEventPublisher events;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional(readOnly = true)
public PendingSummary merchantPendingSummary(long tenantId) {
return new PendingSummary(mapper.merchantPendingCount(tenantId));
}
@Transactional @Transactional
public ContainerView platformView(long planId) { public ContainerView platformView(long planId) {
List<InboundRow> rows = actualPlanRows(planId); List<InboundRow> rows = actualPlanRows(planId);
@@ -1,6 +1,28 @@
openapi: 3.0.3 openapi: 3.0.3
info: {title: Firstleg Container Customs API, version: 2.0.0} info: {title: Firstleg Container Customs API, version: 2.0.0}
paths: 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}: /api/platform/v1/customs/loading-plans/{planId}:
get: get:
summary: 查询整柜报关状态、缺资料票及文件映射 summary: 查询整柜报关状态、缺资料票及文件映射
@@ -30,6 +30,7 @@ class ContainerCustomsControllerContractTest {
@Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() { @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/merchant/v1/customs"); .containsExactly("/api/merchant/v1/customs");
assertPermission(MerchantContainerCustomsController.class,"pendingSummary","merchant:order:view");
assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view"); assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view");
assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
@@ -47,6 +48,18 @@ class ContainerCustomsControllerContractTest {
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION"); .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) { 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(); Method value=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals(method)).findFirst().orElseThrow();
assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission); assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission);
@@ -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();
}
}