Browse Source

✨ feat(clients): add custom client list API with advanced filtering

- create new get.ts route file with OpenAPI specification
- implement client list API with comprehensive query parameters
- add support for pagination, sorting and keyword search
- implement advanced filtering by multiple client attributes
- add date range filtering for startDate and createdAt fields
- override default CRUD list route with custom implementation
- add getList method to ClientService with custom query logic
- implement client statistics and upcoming clients methods in service

♻️ refactor(clients): optimize client routes structure

- modify index.ts to use OpenAPIHono for route aggregation
- combine custom and generic CRUD routes using route aggregation
- ensure custom list route overrides generic CRUD implementation
yourname 8 months ago
parent
commit
2bb0869c7a

+ 146 - 0
src/server/api/clients/get.ts

@@ -0,0 +1,146 @@
+import { OpenAPIHono, createRoute } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { ClientSchema } from '@/server/modules/clients/client.entity';
+import { ClientService } from '@/server/modules/clients/client.service';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { AppDataSource } from '@/server/data-source';
+import { AuthContext } from '@/server/types/context';
+
+const app = new OpenAPIHono<AuthContext>();
+
+// 查询参数Schema
+const ListQuery = z.object({
+  page: z.coerce.number().int().positive().default(1),
+  pageSize: z.coerce.number().int().positive().default(10),
+  keyword: z.string().optional(),
+  auditStatus: z.coerce.number().min(0).max(2).optional(),
+  status: z.coerce.number().min(0).max(1).optional(),
+  responsibleUserId: z.coerce.number().int().positive().optional(),
+  salesPersonId: z.coerce.number().int().positive().optional(),
+  operatorId: z.coerce.number().int().positive().optional(),
+  customerType: z.string().optional(),
+  industry: z.string().optional(),
+  source: z.string().optional(),
+  startDateFrom: z.coerce.date().optional(),
+  startDateTo: z.coerce.date().optional(),
+  createdAtFrom: z.coerce.date().optional(),
+  createdAtTo: z.coerce.date().optional(),
+  sortBy: z.enum([
+    'id', 'companyName', 'contactPerson', 'createdAt', 'updatedAt', 
+    'startDate', 'nextContactTime', 'auditStatus', 'status'
+  ]).default('createdAt'),
+  sortOrder: z.enum(['ASC', 'DESC']).default('DESC')
+});
+
+// 响应Schema
+const ListResponse = z.object({
+  data: z.array(ClientSchema),
+  pagination: z.object({
+    total: z.number(),
+    current: z.number(),
+    pageSize: z.number()
+  })
+});
+
+// 路由定义
+const route = createRoute({
+  method: 'get',
+  path: '/',
+  middleware: [authMiddleware],
+  request: {
+    query: ListQuery
+  },
+  responses: {
+    200: {
+      description: '成功获取客户列表',
+      content: {
+        'application/json': {
+          schema: ListResponse
+        }
+      }
+    }
+  }
+});
+
+// 路由实现
+app.openapi(route, async (c) => {
+  try {
+    const query = c.req.valid('query');
+    const service = new ClientService(AppDataSource);
+    
+    // 构建where条件
+    const where: any = {};
+    
+    if (query.auditStatus !== undefined) {
+      where.auditStatus = query.auditStatus;
+    }
+    if (query.status !== undefined) {
+      where.status = query.status;
+    }
+    if (query.responsibleUserId !== undefined) {
+      where.responsibleUserId = query.responsibleUserId;
+    }
+    if (query.salesPersonId !== undefined) {
+      where.salesPersonId = query.salesPersonId;
+    }
+    if (query.operatorId !== undefined) {
+      where.operatorId = query.operatorId;
+    }
+    if (query.customerType) {
+      where.customerType = query.customerType;
+    }
+    if (query.industry) {
+      where.industry = query.industry;
+    }
+    if (query.source) {
+      where.source = query.source;
+    }
+    
+    // 构建日期范围
+    if (query.startDateFrom || query.startDateTo) {
+      where.startDateRange = {
+        start: query.startDateFrom,
+        end: query.startDateTo
+      };
+    }
+    
+    if (query.createdAtFrom || query.createdAtTo) {
+      where.createdAtRange = {
+        start: query.createdAtFrom,
+        end: query.createdAtTo
+      };
+    }
+    
+    // 构建排序对象
+    const order: any = {};
+    order[query.sortBy] = query.sortOrder;
+    
+    // 调用自定义的getList方法
+    const [data, total] = await service.getList(
+      query.page,
+      query.pageSize,
+      query.keyword,
+      ['companyName', 'contactPerson', 'mobile'],
+      where,
+      ['responsibleUser', 'salesPerson', 'operator'],
+      order
+    );
+    
+    return c.json({
+      data,
+      pagination: {
+        total,
+        current: query.page,
+        pageSize: query.pageSize
+      }
+    });
+  } catch (error) {
+    console.error('获取客户列表失败:', error);
+    return c.json({ 
+      code: 500, 
+      message: error instanceof Error ? error.message : '获取列表失败' 
+    }, 500);
+  }
+});
+
+export default app;

+ 9 - 1
src/server/api/clients/index.ts

@@ -1,8 +1,11 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
 import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
 import { Client } from '@/server/modules/clients/client.entity';
 import { ClientSchema, CreateClientDto, UpdateClientDto } from '@/server/modules/clients/client.entity';
 import { authMiddleware } from '@/server/middleware/auth.middleware';
+import customListRoute from './get'; // 自定义的get路由
 
+// 创建通用CRUD路由
 const clientRoutes = createCrudRoutes({
   entity: Client,
   createSchema: CreateClientDto,
@@ -18,4 +21,9 @@ const clientRoutes = createCrudRoutes({
   middleware: [authMiddleware]
 });
 
-export default clientRoutes;
+// 使用OpenAPIHono聚合路由,自定义路由会覆盖通用CRUD的对应路由
+const app = new OpenAPIHono()
+  .route('/', customListRoute)  // 自定义的GET / 路由(会覆盖通用CRUD的列表路由)
+  .route('/', clientRoutes);    // 通用CRUD的其他路由
+
+export default app;

+ 211 - 1
src/server/modules/clients/client.service.ts

@@ -1,9 +1,219 @@
 import { GenericCrudService } from '@/server/utils/generic-crud.service';
-import { DataSource } from 'typeorm';
+import { DataSource, SelectQueryBuilder } from 'typeorm';
 import { Client } from './client.entity';
 
 export class ClientService extends GenericCrudService<Client> {
   constructor(dataSource: DataSource) {
     super(dataSource, Client);
   }
+
+  /**
+   * 重写getList方法,实现自定义客户列表查询
+   * @param page 页码
+   * @param pageSize 每页数量
+   * @param keyword 关键词搜索
+   * @param searchFields 搜索字段
+   * @param where 过滤条件
+   * @param relations 关联查询
+   * @param order 排序
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields: string[] = ['companyName', 'contactPerson', 'mobile'],
+    where: Partial<Client> = {},
+    relations: string[] = ['responsibleUser', 'salesPerson', 'operator'],
+    order: { [P in keyof Client]?: 'ASC' | 'DESC' } = { createdAt: 'DESC' }
+  ): Promise<[Client[], number]> {
+    const queryBuilder = this.repository.createQueryBuilder('client');
+    
+    // 预加载关联数据
+    if (relations && relations.length > 0) {
+      relations.forEach(relation => {
+        queryBuilder.leftJoinAndSelect(`client.${relation}`, relation);
+      });
+    }
+    
+    // 过滤已删除的数据
+    queryBuilder.where('client.isDeleted = :isDeleted', { isDeleted: 0 });
+    
+    // 应用where条件
+    Object.keys(where).forEach(key => {
+      const value = where[key as keyof Client];
+      if (value !== undefined && value !== null) {
+        queryBuilder.andWhere(`client.${key} = :${key}`, { [key]: value });
+      }
+    });
+    
+    // 关键词搜索
+    if (keyword && searchFields.length > 0) {
+      const conditions = searchFields.map(field => 
+        `client.${field} LIKE :keyword`
+      ).join(' OR ');
+      queryBuilder.andWhere(`(${conditions})`, { keyword: `%${keyword}%` });
+    }
+    
+    // 处理特殊过滤条件
+    this.applyAdvancedFilters(queryBuilder, where);
+    
+    // 应用排序
+    Object.keys(order).forEach(key => {
+      const direction = order[key as keyof Client];
+      if (direction) {
+        queryBuilder.orderBy(`client.${key}`, direction);
+      }
+    });
+    
+    // 分页
+    const skip = (page - 1) * pageSize;
+    queryBuilder.skip(skip).take(pageSize);
+    
+    const [data, total] = await queryBuilder.getManyAndCount();
+    return [data, total];
+  }
+
+  /**
+   * 应用高级过滤条件
+   */
+  private applyAdvancedFilters(
+    queryBuilder: SelectQueryBuilder<Client>,
+    where: Partial<Client & { dateRange?: { start?: Date; end?: Date } }>
+  ): void {
+    // 日期范围过滤
+    if (where['dateRange'] && typeof where['dateRange'] === 'object') {
+      const dateRange = where['dateRange'] as { start?: Date; end?: Date };
+      if (dateRange.start) {
+        queryBuilder.andWhere('client.createdAt >= :startDate', { 
+          startDate: dateRange.start 
+        });
+      }
+      if (dateRange.end) {
+        queryBuilder.andWhere('client.createdAt <= :endDate', { 
+          endDate: dateRange.end 
+        });
+      }
+    }
+    
+    // 下次联系时间过滤
+    if (where['nextContactTime']) {
+      const nextContactTime = where['nextContactTime'];
+      if (nextContactTime instanceof Date) {
+        const startOfDay = new Date(nextContactTime);
+        startOfDay.setHours(0, 0, 0, 0);
+        
+        const endOfDay = new Date(nextContactTime);
+        endOfDay.setHours(23, 59, 59, 999);
+        
+        queryBuilder.andWhere(
+          'client.nextContactTime BETWEEN :startOfDay AND :endOfDay',
+          { startOfDay, endOfDay }
+        );
+      }
+    }
+    
+    // 合作起始日期过滤
+    if (where['startDate']) {
+      const startDate = where['startDate'];
+      if (startDate instanceof Date) {
+        queryBuilder.andWhere('client.startDate >= :startDate', { startDate });
+      }
+    }
+    
+    // 负责人过滤
+    if (where['responsibleUserId']) {
+      queryBuilder.andWhere('client.responsibleUserId = :responsibleUserId', {
+        responsibleUserId: where['responsibleUserId']
+      });
+    }
+    
+    // 业务员过滤
+    if (where['salesPersonId']) {
+      queryBuilder.andWhere('client.salesPersonId = :salesPersonId', {
+        salesPersonId: where['salesPersonId']
+      });
+    }
+    
+    // 操作员过滤
+    if (where['operatorId']) {
+      queryBuilder.andWhere('client.operatorId = :operatorId', {
+        operatorId: where['operatorId']
+      });
+    }
+    
+    // 客户类型过滤
+    if (where['customerType']) {
+      queryBuilder.andWhere('client.customerType = :customerType', {
+        customerType: where['customerType']
+      });
+    }
+    
+    // 行业过滤
+    if (where['industry']) {
+      queryBuilder.andWhere('client.industry = :industry', {
+        industry: where['industry']
+      });
+    }
+    
+    // 客户来源过滤
+    if (where['source']) {
+      queryBuilder.andWhere('client.source = :source', {
+        source: where['source']
+      });
+    }
+  }
+
+  /**
+   * 获取客户统计信息
+   */
+  async getClientStats(): Promise<{
+    total: number;
+    pendingAudit: number;
+    active: number;
+    inactive: number;
+  }> {
+    const queryBuilder = this.repository.createQueryBuilder('client');
+    
+    const total = await queryBuilder
+      .where('client.isDeleted = 0')
+      .getCount();
+    
+    const pendingAudit = await queryBuilder
+      .where('client.isDeleted = 0')
+      .andWhere('client.auditStatus = 0')
+      .getCount();
+    
+    const active = await queryBuilder
+      .where('client.isDeleted = 0')
+      .andWhere('client.status = 1')
+      .getCount();
+    
+    const inactive = await queryBuilder
+      .where('client.isDeleted = 0')
+      .andWhere('client.status = 0')
+      .getCount();
+    
+    return { total, pendingAudit, active, inactive };
+  }
+
+  /**
+   * 获取即将联系的客户列表
+   */
+  async getUpcomingClients(days: number = 7): Promise<Client[]> {
+    const queryBuilder = this.repository.createQueryBuilder('client');
+    
+    const today = new Date();
+    const futureDate = new Date();
+    futureDate.setDate(today.getDate() + days);
+    
+    return queryBuilder
+      .leftJoinAndSelect('client.responsibleUser', 'responsibleUser')
+      .where('client.isDeleted = 0')
+      .andWhere('client.nextContactTime BETWEEN :today AND :futureDate', {
+        today,
+        futureDate
+      })
+      .orderBy('client.nextContactTime', 'ASC')
+      .getMany();
+  }
 }