优化
This commit is contained in:
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.TenantOrderCountResponse;
|
||||
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
@@ -134,6 +135,16 @@ public class PlatformFirstLegOrderController {
|
||||
return ApiResponse.success(service.countByTenantIds(tenantIds));
|
||||
}
|
||||
|
||||
@GetMapping("/tenant-statistics")
|
||||
@PreAuthorize("hasAuthority('platform:order:list')")
|
||||
@Operation(summary = "查询客户累计及本月运单数", description = "仅统计已提交的 MASTER 根运单;草稿和 CHILD 拆分单不计入,累计不受后续取消或驳回影响。月份按 Asia/Shanghai 半开区间计算。")
|
||||
public ApiResponse<TenantOrderStatisticsResponse> tenantStatistics(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.tenantSubmittedStatistics(scopedTenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "TenantOrderStatisticsResponse", description = "平台客户已提交主运单统计")
|
||||
public record TenantOrderStatisticsResponse(
|
||||
@Schema(description = "客户 ID") Long tenantId,
|
||||
@Schema(description = "历史累计已提交主运单数") Long totalSubmittedOrders,
|
||||
@Schema(description = "当前上海时区月份已提交主运单数") Long currentMonthSubmittedOrders,
|
||||
@Schema(description = "本月起始时间(含),ISO-8601") String monthStart,
|
||||
@Schema(description = "下月起始时间(不含),ISO-8601") String nextMonthStart,
|
||||
@Schema(description = "统计时区") String zoneId,
|
||||
@Schema(description = "统计快照时间,ISO-8601") String asOf
|
||||
) { }
|
||||
@@ -1,4 +1,25 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
/**
|
||||
* 订单主表 Mapper,提供 {@code ord_order} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderMapper extends BaseMapper<OrderEntity>{}
|
||||
public interface OrderMapper extends BaseMapper<OrderEntity>{
|
||||
@Select("""
|
||||
SELECT COUNT(*) AS total_submitted_orders,
|
||||
COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart}
|
||||
AND submitted_at < #{nextMonthStart}
|
||||
THEN 1 ELSE 0 END), 0) AS current_month_submitted_orders
|
||||
FROM ord_order
|
||||
WHERE tenant_id = #{tenantId}
|
||||
AND deleted = 0
|
||||
AND order_role = 'MASTER'
|
||||
AND submitted_at IS NOT NULL
|
||||
""")
|
||||
Map<String, Object> selectTenantSubmittedStatistics(
|
||||
@Param("tenantId") long tenantId,
|
||||
@Param("monthStart") LocalDateTime monthStart,
|
||||
@Param("nextMonthStart") LocalDateTime nextMonthStart);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,15 @@ public class OrderRepository {
|
||||
));
|
||||
}
|
||||
|
||||
/** 一次条件聚合客户已提交主运单,取消/驳回状态不会影响累计值。 */
|
||||
public Map<String, Object> tenantSubmittedStatistics(long tenantId,
|
||||
LocalDateTime monthStart,
|
||||
LocalDateTime nextMonthStart) {
|
||||
Map<String, Object> aggregate = orderMapper.selectTenantSubmittedStatistics(
|
||||
tenantId, monthStart, nextMonthStart);
|
||||
return aggregate == null ? Map.of() : aggregate;
|
||||
}
|
||||
|
||||
public List<OrderCargoEntity> findCargo(long tenantId, long orderId) {
|
||||
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
.eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.TenantOrderCountResponse;
|
||||
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.entity.*;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
@@ -23,6 +24,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.time.*;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -154,6 +156,30 @@ public class OrderApplicationService {
|
||||
.map(tenantId -> new TenantOrderCountResponse(tenantId, counts.getOrDefault(tenantId, 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static final ZoneId STATISTICS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
public TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId) {
|
||||
return tenantSubmittedStatistics(tenantId, Clock.system(STATISTICS_ZONE));
|
||||
}
|
||||
|
||||
TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId, Clock clock) {
|
||||
ZonedDateTime asOf = ZonedDateTime.now(clock).withZoneSameInstant(STATISTICS_ZONE);
|
||||
YearMonth month = YearMonth.from(asOf);
|
||||
ZonedDateTime monthStart = month.atDay(1).atStartOfDay(STATISTICS_ZONE);
|
||||
ZonedDateTime nextMonthStart = month.plusMonths(1).atDay(1).atStartOfDay(STATISTICS_ZONE);
|
||||
var aggregate = repository.tenantSubmittedStatistics(tenantId,
|
||||
monthStart.toLocalDateTime(), nextMonthStart.toLocalDateTime());
|
||||
long total = number(aggregate.get("total_submitted_orders"));
|
||||
long current = number(aggregate.get("current_month_submitted_orders"));
|
||||
return new TenantOrderStatisticsResponse(tenantId, total, current,
|
||||
monthStart.toOffsetDateTime().toString(), nextMonthStart.toOffsetDateTime().toString(),
|
||||
STATISTICS_ZONE.getId(), asOf.toOffsetDateTime().toString());
|
||||
}
|
||||
|
||||
private static long number(Object value) {
|
||||
return value instanceof Number n ? n.longValue() : 0L;
|
||||
}
|
||||
public OrderDetailResponse detail(long tenantId, long id) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
|
||||
|
||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
|
||||
UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code),
|
||||
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
|
||||
KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at),
|
||||
KEY idx_order_tenant_customer (tenant_id, customer_id),
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
|
||||
|
||||
@@ -231,6 +231,25 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:create 权限}
|
||||
/api/platform/v1/orders/first-leg/tenant-statistics:
|
||||
get:
|
||||
tags: [Platform first-leg orders]
|
||||
summary: 查询客户累计及本月运单数
|
||||
description: 仅统计 submitted_at 非空的 MASTER 根运单,草稿和 CHILD 拆分单不计入;累计不受取消或驳回影响;月份按 Asia/Shanghai 半开区间计算。
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功;需要 platform:order:list 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
data: {$ref: '#/components/schemas/TenantOrderStatisticsResponse'}
|
||||
'403': {description: 需要平台身份和 platform:order:list 权限}
|
||||
|
||||
/api/platform/v1/orders/first-leg/tenant-counts:
|
||||
get:
|
||||
summary: 批量查询客户运单数
|
||||
@@ -327,6 +346,18 @@ paths:
|
||||
'403': {description: 需要 merchant:order:view 权限}
|
||||
|
||||
components:
|
||||
schemas:
|
||||
TenantOrderStatisticsResponse:
|
||||
type: object
|
||||
required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: Long 按字符串序列化以避免 JavaScript 精度丢失}
|
||||
totalSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化}
|
||||
currentMonthSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化}
|
||||
monthStart: {type: string, format: date-time}
|
||||
nextMonthStart: {type: string, format: date-time}
|
||||
zoneId: {type: string, example: Asia/Shanghai}
|
||||
asOf: {type: string, format: date-time}
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TenantOrderStatisticsControllerContractTest {
|
||||
@Test
|
||||
void endpointUsesStablePathListPermissionAndResponseType() throws Exception {
|
||||
Method method = PlatformFirstLegOrderController.class.getMethod("tenantStatistics",
|
||||
com.baiyue.common.security.AuthenticatedUser.class, long.class);
|
||||
|
||||
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/tenant-statistics");
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:order:list')");
|
||||
assertThat(method.getGenericReturnType().getTypeName())
|
||||
.contains(TenantOrderStatisticsResponse.class.getName());
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.order.repository;
|
||||
|
||||
import com.baiyue.order.mapper.OrderMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TenantOrderStatisticsSqlContractTest {
|
||||
@Test
|
||||
void oneConditionalAggregateEnforcesTenantSubmittedMasterAndHalfOpenMonth() throws Exception {
|
||||
Method method = OrderMapper.class.getMethod("selectTenantSubmittedStatistics",
|
||||
long.class, java.time.LocalDateTime.class, java.time.LocalDateTime.class);
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value())
|
||||
.replaceAll("\\s+", " ").toLowerCase();
|
||||
|
||||
assertThat(sql)
|
||||
.contains("count(*) as total_submitted_orders")
|
||||
.contains("sum(case when submitted_at >= #{monthstart}")
|
||||
.contains("submitted_at < #{nextmonthstart}")
|
||||
.contains("tenant_id = #{tenantid}")
|
||||
.contains("deleted = 0")
|
||||
.contains("order_role = 'master'")
|
||||
.contains("submitted_at is not null")
|
||||
.doesNotContain("created_at")
|
||||
.doesNotContain("status =")
|
||||
.doesNotContain("status in");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class})
|
||||
class TenantOrderStatisticsServiceTest {
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderEligibilityGuard eligibilityGuard;
|
||||
@MockBean private OrderMasterDataGuard masterDataGuard;
|
||||
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@MockBean private ProductImageGuard productImageGuard;
|
||||
@Resource private OrderApplicationService service;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TestConfig {
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesShanghaiHalfOpenCalendarMonthAndReturnsAggregate() {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-08-31T16:30:00Z"), ZoneId.of("UTC"));
|
||||
when(repository.tenantSubmittedStatistics(88L,
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 10, 1, 0, 0)))
|
||||
.thenReturn(Map.of("total_submitted_orders", 12L,
|
||||
"current_month_submitted_orders", 3L));
|
||||
|
||||
TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(88L, clock);
|
||||
|
||||
assertThat(response).isEqualTo(new TenantOrderStatisticsResponse(
|
||||
88L, 12L, 3L, "2026-09-01T00:00+08:00", "2026-10-01T00:00+08:00",
|
||||
"Asia/Shanghai", "2026-09-01T00:30+08:00"));
|
||||
verify(repository).tenantSubmittedStatistics(88L,
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 10, 1, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsZeroWhenNoSubmittedMasterOrdersExist() {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-01-15T00:00:00Z"), ZoneId.of("UTC"));
|
||||
when(repository.tenantSubmittedStatistics(99L,
|
||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 2, 1, 0, 0))).thenReturn(Map.of());
|
||||
|
||||
TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(99L, clock);
|
||||
|
||||
assertThat(response.totalSubmittedOrders()).isZero();
|
||||
assertThat(response.currentMonthSubmittedOrders()).isZero();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user