This commit is contained in:
2026-09-11 17:20:56 +08:00
parent c8e8245853
commit 458a4cfa50
8 changed files with 207 additions and 1 deletions
@@ -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'
@@ -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
) {}
}
@@ -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) {}
}
@@ -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;
}
}
@@ -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<OperationalMetrics> metrics(@AuthenticationPrincipal AuthenticatedUser user) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.read());
}
}
@@ -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%'");
}
}
@@ -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();
}
}
@@ -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客户资质缺少原始文件名列");
}
}