优化
This commit is contained in:
+31
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
}
|
||||
+56
@@ -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) {}
|
||||
}
|
||||
+28
@@ -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}
|
||||
+50
@@ -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"));
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user