This commit is contained in:
2026-08-13 12:22:31 +08:00
parent 16dc03a2e2
commit e742abd1cf
49 changed files with 821 additions and 50 deletions
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.customerstatistics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/customers")
public class PlatformCustomerTransitStatisticsController {
@Resource private CustomerTransitStatisticsService service;
@GetMapping("/{customerId}/container-transit-summary")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<TransitSummary> summary(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long customerId) {
FirstlegScopeGuard.platform(user, customerId);
return ApiResponse.success(service.summarize(customerId));
}
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.customerstatistics.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class CustomerTransitStatisticsDtos {
private CustomerTransitStatisticsDtos() {}
public record TransitSummary(Long customerId, long containerCount, BigDecimal totalTeu,
long unknownContainerCount, LocalDateTime asOf, String definition) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.customerstatistics.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper
public interface CustomerTransitStatisticsMapper {
@Select("""
SELECT #{customerId} AS customerId,
COUNT(*) AS containerCount,
COALESCE(SUM(teu), 0) AS totalTeu,
SUM(CASE WHEN teu IS NULL THEN 1 ELSE 0 END) AS unknownContainerCount,
CURRENT_TIMESTAMP(3) AS asOf
FROM (
SELECT DISTINCT x.customer_id, x.container_booking_id,
CASE
WHEN UPPER(TRIM(x.container_type)) LIKE '20%' THEN 1.0
WHEN UPPER(TRIM(x.container_type)) LIKE '40%' THEN 2.0
WHEN UPPER(TRIM(x.container_type)) LIKE '45%' THEN 2.25
ELSE NULL
END AS teu
FROM (
SELECT b.customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan p
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id
WHERE p.container_booking_id = c.id AND i.deleted = 0
)
UNION ALL
SELECT i.account_id AS customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
JOIN fl_loading_plan p ON p.container_booking_id = c.id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND i.account_id IS NOT NULL AND i.account_id > 0
) x
WHERE x.customer_id = #{customerId}
) scoped
""")
SummaryRow summarize(@Param("customerId") long customerId);
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.customerstatistics.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class CustomerTransitStatisticsService {
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
@Resource private CustomerTransitStatisticsMapper mapper;
public TransitSummary summarize(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
var row = mapper.summarize(customerId);
return new TransitSummary(customerId,
value(row == null ? null : row.containerCount()),
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
value(row == null ? null : row.unknownContainerCount()),
row == null ? null : row.asOf(), DEFINITION);
}
private static long value(Long value) { return value == null ? 0L : value; }
}
@@ -256,4 +256,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code='platform:firstleg:ocean-booking:view';
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:ocean-booking:view';
USE baiyue_firstleg;
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
KEY idx_container_booking_container (container_no),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
@@ -1175,6 +1176,12 @@ WHERE role.tenant_id = 0
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELETE rp FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1
WHERE permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
@@ -138,4 +138,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code='platform:firstleg:truck-appointment:view';
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:truck-appointment:view';
USE baiyue_firstleg;
@@ -0,0 +1,35 @@
openapi: 3.0.3
info:
title: Firstleg 客户统计 API
version: 1.0.0
paths:
/api/platform/v1/customers/{customerId}/container-transit-summary:
get:
summary: 查询客户在途柜量(TEU)
description: 需要 platform:firstleg:container:list;按客户+柜次去重,整柜按订舱客户归属,拼柜按 active LoadingPlanItem.accountId 归属。
parameters:
- {name: customerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: 成功或业务失败,响应体 code 为字符串 200 时成功
content:
application/json:
schema: {$ref: '#/components/schemas/TransitSummaryEnvelope'}
components:
schemas:
TransitSummaryEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/TransitSummary'}
TransitSummary:
type: object
required: [customerId, containerCount, totalTeu, unknownContainerCount, asOf, definition]
properties:
customerId: {type: integer, format: int64, minimum: 1}
containerCount: {type: integer, format: int64, minimum: 0}
totalTeu: {type: number, format: decimal, multipleOf: 0.01, minimum: 0, description: 已知箱型TEU合计;未知箱型不计入}
unknownContainerCount: {type: integer, format: int64, minimum: 0}
asOf: {type: string, format: date-time}
definition: {type: string}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTransitStatisticsContractTest {
@Test void exposesPlatformCustomerSummaryWithStablePermission() throws Exception {
assertThat(PlatformCustomerTransitStatisticsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customers");
var method = PlatformCustomerTransitStatisticsController.class.getDeclaredMethod(
"summary", com.baiyue.common.security.AuthenticatedUser.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{customerId}/container-transit-summary");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:container:list')");
}
@Test void queryEnforcesTransitScopeOwnershipDedupAndTeuRules() throws Exception {
var sql = Arrays.stream(CustomerTransitStatisticsMapper.class.getDeclaredMethods())
.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",
"status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id",
"b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS",
"x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0",
"LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL");
}
@Test void schemaAddsTransitSummaryIndexAndDefinition() throws Exception {
var schema = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema).contains("idx_container_booking_transit_summary");
var openApi = new ClassPathResource("openapi/customer-statistics-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openApi).contains("containerCount:", "format: int64", "totalTeu:",
"multipleOf: 0.01", "unknownContainerCount:", "format: date-time", "definition:");
var service = CustomerTransitStatisticsService.class;
assertThat(service.getDeclaredFields()).anyMatch(f -> f.getName().equals("DEFINITION"));
}
}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
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.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerTransitStatisticsServiceTest {
@Mock CustomerTransitStatisticsMapper mapper;
@InjectMocks CustomerTransitStatisticsService service;
@Test void preservesDecimalTeuAndUnknownCount() {
var asOf = LocalDateTime.of(2026, 8, 13, 12, 0);
when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow(
88L, 4L, new BigDecimal("6.25"), 1L, asOf));
var result = service.summarize(88L);
assertThat(result.customerId()).isEqualTo(88L);
assertThat(result.containerCount()).isEqualTo(4);
assertThat(result.totalTeu()).isEqualByComparingTo("6.25");
assertThat(result.unknownContainerCount()).isEqualTo(1);
assertThat(result.asOf()).isEqualTo(asOf);
assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型");
verify(mapper).summarize(88L);
}
@Test void rejectsInvalidCustomerBeforeQuery() {
assertThatThrownBy(() -> service.summarize(0)).isInstanceOf(BizException.class);
}
}