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
@@ -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),
+1
View File
@@ -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
@@ -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());
}
}
@@ -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();
}
}