feat(firstleg): count actionable customer customs cabinets
This commit is contained in:
+6
@@ -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,
|
||||||
|
|||||||
+1
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -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)
|
||||||
|
|||||||
+5
@@ -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: 查询整柜报关状态、缺资料票及文件映射
|
||||||
|
|||||||
+13
@@ -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);
|
||||||
|
|||||||
+78
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user