From 458a4cfa50bd16f7f7aca99c1ef980cff08219f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 17:20:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 8 +++ .../dashboard/DashboardMetricsDtos.java | 16 ++++++ .../dashboard/DashboardMetricsMapper.java | 52 +++++++++++++++++++ .../dashboard/DashboardMetricsService.java | 24 +++++++++ .../PlatformDashboardMetricsController.java | 25 +++++++++ .../DashboardMetricsContractTest.java | 33 ++++++++++++ .../DashboardMetricsServiceTest.java | 43 +++++++++++++++ .../QualificationSchemaContractTest.java | 7 ++- 8 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c1f6591d6..132ea8880 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1434,6 +1434,7 @@ CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMEN CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'"); CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'"); CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name"); +CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name', "VARCHAR(255) NULL COMMENT '上传时原始文件名' AFTER document_url"); CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'"); CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'"); CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'"); @@ -15239,6 +15240,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'created_at' CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'deleted', '逻辑删除标记'); CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'document_url', '资质文件存储地址'); CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'id', '资质记录ID'); +CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'original_file_name', '上传时原始文件名'); CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'qualification_type', '客户资质类型'); CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'review_comment', '客户资质审核意见'); CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'status', '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回'); @@ -15789,6 +15791,12 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant_qualification' + AND column_name='original_file_name') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:IAM客户资质缺少原始文件名列'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_fee' AND ((column_name='baseline_chargeable_quantity' AND is_nullable='NO' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java new file mode 100644 index 000000000..00e619e54 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java @@ -0,0 +1,16 @@ +package com.baiyue.firstleg.dashboard; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class DashboardMetricsDtos { + private DashboardMetricsDtos() {} + + public record OperationalMetrics( + long inTransitContainerCount, + BigDecimal inTransitTeu, + long unknownContainerTypeCount, + long pendingCustomsOrderCount, + LocalDateTime asOf + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java new file mode 100644 index 000000000..b803f27fc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.dashboard; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Mapper +public interface DashboardMetricsMapper { + @Select(""" + SELECT transit.container_count inTransitContainerCount, + transit.total_teu inTransitTeu, + transit.unknown_container_type_count unknownContainerTypeCount, + customs.pending_customs_order_count pendingCustomsOrderCount, + CURRENT_TIMESTAMP(3) asOf + FROM ( + SELECT COUNT(*) container_count, + COALESCE(SUM(container.teu), 0) total_teu, + COALESCE(SUM(CASE WHEN container.teu IS NULL THEN 1 ELSE 0 END), 0) unknown_container_type_count + FROM ( + SELECT DISTINCT booking.id, + CASE + WHEN UPPER(TRIM(booking.container_type)) LIKE '20%' THEN 1.0 + WHEN UPPER(TRIM(booking.container_type)) LIKE '40%' THEN 2.0 + WHEN UPPER(TRIM(booking.container_type)) LIKE '45%' THEN 2.25 + ELSE NULL + END teu + FROM fl_container_booking booking + JOIN fl_ocean_booking ocean ON ocean.id = booking.ocean_booking_id + WHERE booking.actual_sailing_at IS NOT NULL + AND booking.actual_warehouse_arrival_at IS NULL + AND booking.record_status = 'ACTIVE' + AND ocean.status <> 'CANCELLED' + ) container + ) transit + CROSS JOIN ( + SELECT COUNT(DISTINCT inbound.order_id) pending_customs_order_count + FROM fl_customs_container customs_container + JOIN fl_loading_plan_item item + ON item.loading_plan_id = customs_container.loading_plan_id AND item.deleted = 0 + JOIN fl_inbound_order inbound + ON inbound.id = item.inbound_id AND inbound.tenant_id = item.account_id AND inbound.deleted = 0 + WHERE customs_container.status <> 'COMPLETED' + ) customs + """) + MetricsProjection read(); + + record MetricsProjection(Long inTransitContainerCount, BigDecimal inTransitTeu, + Long unknownContainerTypeCount, Long pendingCustomsOrderCount, + LocalDateTime asOf) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java new file mode 100644 index 000000000..6f3671b3c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.dashboard; + +import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +@Service +public class DashboardMetricsService { + @Resource private DashboardMetricsMapper mapper; + + public OperationalMetrics read() { + var row = mapper.read(); + if (row == null) return new OperationalMetrics(0, BigDecimal.ZERO, 0, 0, null); + return new OperationalMetrics(value(row.inTransitContainerCount()), + row.inTransitTeu() == null ? BigDecimal.ZERO : row.inTransitTeu(), + value(row.unknownContainerTypeCount()), value(row.pendingCustomsOrderCount()), row.asOf()); + } + + private static long value(Long value) { + return value == null ? 0 : value; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java new file mode 100644 index 000000000..350b1ab22 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.dashboard; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/v1/first-leg/dashboard") +public class PlatformDashboardMetricsController { + @Resource private DashboardMetricsService service; + + @GetMapping("/operational-metrics") + @PreAuthorize("hasAuthority('platform:firstleg:container:list') and hasAuthority('platform:firstleg:customs:list')") + public ApiResponse metrics(@AuthenticationPrincipal AuthenticatedUser user) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.read()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java new file mode 100644 index 000000000..2521681d8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.dashboard; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class DashboardMetricsContractTest { + @Test void exposesThePlatformDashboardEndpointWithBothReadPermissions() throws Exception { + assertThat(PlatformDashboardMetricsController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/dashboard"); + var method = PlatformDashboardMetricsController.class.getDeclaredMethod( + "metrics", com.baiyue.common.security.AuthenticatedUser.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/operational-metrics"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .contains("platform:firstleg:container:list", "platform:firstleg:customs:list"); + } + + @Test void queryCountsPhysicalTransitContainersAndDistinctPendingOrders() { + var sql = Arrays.stream(DashboardMetricsMapper.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("read")) + .findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value(); + var statement = String.join("\n", sql); + assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL", + "booking.record_status = 'ACTIVE'", "ocean.status <> 'CANCELLED'", + "COUNT(DISTINCT inbound.order_id)", "customs_container.status <> 'COMPLETED'", + "item.deleted = 0", "inbound.deleted = 0", "LIKE '20%'", "LIKE '40%'", "LIKE '45%'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java new file mode 100644 index 000000000..c25fb5d20 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java @@ -0,0 +1,43 @@ +package com.baiyue.firstleg.dashboard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DashboardMetricsServiceTest { + @Mock DashboardMetricsMapper mapper; + @InjectMocks DashboardMetricsService service; + + @Test void preservesOperationalCountsAndDecimalTeu() { + var asOf = LocalDateTime.of(2026, 9, 11, 12, 0); + when(mapper.read()).thenReturn(new DashboardMetricsMapper.MetricsProjection( + 3L, new BigDecimal("4.25"), 1L, 7L, asOf)); + + var result = service.read(); + + assertThat(result.inTransitContainerCount()).isEqualTo(3); + assertThat(result.inTransitTeu()).isEqualByComparingTo("4.25"); + assertThat(result.unknownContainerTypeCount()).isEqualTo(1); + assertThat(result.pendingCustomsOrderCount()).isEqualTo(7); + assertThat(result.asOf()).isEqualTo(asOf); + } + + @Test void returnsZerosWhenTheDatabaseProjectionIsAbsent() { + when(mapper.read()).thenReturn(null); + + var result = service.read(); + + assertThat(result.inTransitContainerCount()).isZero(); + assertThat(result.inTransitTeu()).isEqualByComparingTo("0"); + assertThat(result.pendingCustomsOrderCount()).isZero(); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java index fff5b2223..547e94802 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java @@ -21,6 +21,11 @@ class QualificationSchemaContractTest { String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); - assertThat(unified).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); + assertThat(unified) + .contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'") + .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',") + .contains("table_name='iam_tenant_qualification'") + .contains("column_name='original_file_name'") + .contains("统一迁移失败:IAM客户资质缺少原始文件名列"); } }