import { getMetadataArgsStorage } from 'typeorm'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { CoolCommException } from '@cool-midway/core'; import { BaseEntity } from '../../../src/comm/entity/base'; import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead'; import { CrmLeadAuditEntity } from '../../../src/modules/crm/entity/audit'; import { CrmLeadPoolService } from '../../../src/modules/crm/service/lead-pool'; import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead'; describe('Point 12: 团队线索公海池流转与自动分配机制 单元测试', () => { const metadata = getMetadataArgsStorage(); // ========================================== // 1. 实体与数据表映射规范测试 // ========================================== describe('1. 实体定义与数据库字段映射测试', () => { it('CrmLeadEntity 应包含 user_id、is_pool 与 last_follow_up_time 映射', () => { const columns = metadata.columns.filter(c => c.target === CrmLeadEntity); const userCol = columns.find(c => c.propertyName === 'userId'); const isPoolCol = columns.find(c => c.propertyName === 'isPool'); const followUpCol = columns.find(c => c.propertyName === 'lastFollowUpTime'); expect(userCol).toBeDefined(); expect(userCol?.options.name || userCol?.propertyName).toBe('user_id'); expect(isPoolCol).toBeDefined(); expect(isPoolCol?.options.name || isPoolCol?.propertyName).toBe('is_pool'); expect(followUpCol).toBeDefined(); expect(followUpCol?.options.name || followUpCol?.propertyName).toBe('last_follow_up_time'); }); it('CrmLeadAuditEntity 表名映射应为 crm_lead_audit 且继承 BaseEntity', () => { const table = metadata.tables.find(t => t.target === CrmLeadAuditEntity); expect(table).toBeDefined(); expect(table?.name).toBe('crm_lead_audit'); expect(CrmLeadAuditEntity.prototype instanceof BaseEntity).toBe(true); }); it('CrmLeadAuditEntity 应映射 lead_id、action、operator_id、from_user_id、to_user_id、reason 列', () => { const columns = metadata.columns.filter(c => c.target === CrmLeadAuditEntity); const expected: Record = { leadId: 'lead_id', action: 'action', operatorId: 'operator_id', fromUserId: 'from_user_id', toUserId: 'to_user_id', reason: 'reason', }; for (const [prop, dbCol] of Object.entries(expected)) { const col = columns.find(c => c.propertyName === prop); expect(col).toBeDefined(); expect(col?.options.name || col?.propertyName).toBe(dbCol); } }); }); // ========================================== // 2. 模拟内存数据仓储与并发 CAS // ========================================== describe('2. 公海池核心流转业务逻辑测试 (CrmLeadPoolService)', () => { let service: CrmLeadPoolService; let leads: CrmLeadEntity[]; let audits: CrmLeadAuditEntity[]; let nextAuditId = 1; beforeEach(() => { leads = []; audits = []; nextAuditId = 1; // 预置线索数据 const lead1 = new CrmLeadEntity(); lead1.id = 1; lead1.cardId = 101; lead1.name = '公海线索-1'; lead1.status = 'new'; lead1.isPool = true; lead1.userId = null; leads.push(lead1); const lead2 = new CrmLeadEntity(); lead2.id = 2; lead2.cardId = 101; lead2.name = '销售私海线索-2'; lead2.status = 'contacted'; lead2.isPool = false; lead2.userId = 88; lead2.lastFollowUpTime = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); // 15天前未跟进 leads.push(lead2); const lead3 = new CrmLeadEntity(); lead3.id = 3; lead3.cardId = 101; lead3.name = '最近跟进私海线索-3'; lead3.status = 'hot'; lead3.isPool = false; lead3.userId = 88; lead3.lastFollowUpTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2天前跟进 leads.push(lead3); const lead4 = new CrmLeadEntity(); lead4.id = 4; lead4.cardId = 101; lead4.name = '已赢单线索-4'; lead4.status = 'won'; lead4.isPool = false; lead4.userId = 88; lead4.lastFollowUpTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30天前,但已赢单不回收 leads.push(lead4); // Mock CrmLeadEntity Repository const mockLeadRepo = { findOne: jest.fn(async (options: { where?: { id?: number } }) => { const id = options?.where?.id; const found = leads.find(l => l.id === Number(id)); return found ? Object.assign(new CrmLeadEntity(), found) : null; }), find: jest.fn(async (options?: { where?: Partial }) => { if (!options?.where) return [...leads]; return leads.filter(item => { const itemMap = item as unknown as Record; for (const [key, val] of Object.entries(options.where!)) { if (itemMap[key] !== val) return false; } return true; }); }), save: jest.fn(async (entity: CrmLeadEntity) => { const idx = leads.findIndex(l => l.id === entity.id); if (idx >= 0) { Object.assign(leads[idx], entity); return leads[idx]; } leads.push(entity); return entity; }), // 模拟原子更新 CAS 操作 update: jest.fn(async (criteria: { id: number; isPool?: boolean }, values: Partial) => { const idx = leads.findIndex(l => { if (l.id !== Number(criteria.id)) return false; if (criteria.isPool !== undefined && l.isPool !== criteria.isPool) return false; return true; }); if (idx === -1) { return { affected: 0 }; } Object.assign(leads[idx], values); return { affected: 1 }; }), }; // Mock CrmLeadAuditEntity Repository const mockAuditRepo = { save: jest.fn(async (entity: CrmLeadAuditEntity) => { const saved = Object.assign(new CrmLeadAuditEntity(), entity, { id: nextAuditId++, createTime: new Date(), updateTime: new Date(), }); audits.push(saved); return saved; }), find: jest.fn(async (options?: { where?: { leadId?: number } }) => { if (options?.where?.leadId) { return audits.filter(a => a.leadId === options.where!.leadId); } return [...audits]; }), }; service = new CrmLeadPoolService(); service.crmLeadEntity = mockLeadRepo as unknown as CrmLeadPoolService['crmLeadEntity']; service.crmLeadAuditEntity = mockAuditRepo as unknown as CrmLeadPoolService['crmLeadAuditEntity']; }); // ---------------------------------------------------- // 2.1 recycleToPool 回收线索至公海 // ---------------------------------------------------- describe('2.1 recycleToPool - 线索手动回收入公海池', () => { it('线索不存在时应抛出 404 异常', async () => { await expect(service.recycleToPool(9999, '测试不存在')).rejects.toThrow( CoolCommException ); }); it('成功回收私海线索,置 isPool=true, userId=null 并记录审计日志', async () => { const recycled = await service.recycleToPool(2, '客户暂时无需跟进'); expect(recycled.isPool).toBe(true); expect(recycled.userId).toBeNull(); const leadLogs = audits.filter(a => a.leadId === 2); expect(leadLogs.length).toBe(1); expect(leadLogs[0].action).toBe('recycle'); expect(leadLogs[0].fromUserId).toBe(88); expect(leadLogs[0].toUserId).toBeNull(); expect(leadLogs[0].reason).toBe('客户暂时无需跟进'); }); }); // ---------------------------------------------------- // 2.2 scanAndAutoRecycle 超时未跟进自动批量回收 // ---------------------------------------------------- describe('2.2 scanAndAutoRecycle - 超时未跟进自动回收机制', () => { it('应自动将超过 inactivityDays 未跟进且非公海、非 won/archived 线索批量回收', async () => { // 设置 7 天未跟进为阈值 const result = await service.scanAndAutoRecycle(7); // lead2 (15天未跟进) 应被回收 // lead3 (2天未跟进) 不应被回收 // lead4 (30天未跟进但为 won) 不应被回收 // lead1 本来就是公海,不重复回收 expect(result.recycledCount).toBe(1); expect(result.leadIds).toContain(2); const targetLead = leads.find(l => l.id === 2); expect(targetLead?.isPool).toBe(true); expect(targetLead?.userId).toBeNull(); // 验证审计日志 const autoLog = audits.find(a => a.leadId === 2 && a.action === 'auto_recycle'); expect(autoLog).toBeDefined(); expect(autoLog?.fromUserId).toBe(88); expect(autoLog?.reason).toMatch(/超过 7 天未跟进/); }); it('天数未达到阈值时不应回收任何线索', async () => { const result = await service.scanAndAutoRecycle(30); expect(result.recycledCount).toBe(0); expect(result.leadIds.length).toBe(0); }); }); // ---------------------------------------------------- // 2.3 claimLead 公海抢单与原子性防并发重抢 // ---------------------------------------------------- describe('2.3 claimLead - 销售抢单领用与并发保护', () => { it('线索不存在时应抛出 404 异常', async () => { await expect(service.claimLead(9999, 100)).rejects.toThrow( CoolCommException ); }); it('线索若已被领用(不在公海池),再次抢单应抛出 409 异常被拦截', async () => { // lead2 是私海线索 (isPool: false, userId: 88) await expect(service.claimLead(2, 100)).rejects.toThrow( CoolCommException ); }); it('正常抢单公海线索应成功,置 isPool=false, userId=领用人,并记录审计日志', async () => { // lead1 是公海线索 const claimed = await service.claimLead(1, 100); expect(claimed.isPool).toBe(false); expect(claimed.userId).toBe(100); const claimLog = audits.find(a => a.leadId === 1 && a.action === 'claim'); expect(claimLog).toBeDefined(); expect(claimLog?.toUserId).toBe(100); expect(claimLog?.operatorId).toBe(100); }); it('高并发场景防重抢:多个销售并发争抢同一公海线索,严格保证只有 1 人成功', async () => { // 确保 lead1 处于公海 const lead = leads.find(l => l.id === 1); lead!.isPool = true; lead!.userId = null; const salesUsers = [201, 202, 203, 204, 205]; const results = await Promise.allSettled( salesUsers.map(uid => service.claimLead(1, uid)) ); const fulfilled = results.filter(r => r.status === 'fulfilled'); const rejected = results.filter(r => r.status === 'rejected'); // 仅有 1 个请求能成功抢单 expect(fulfilled.length).toBe(1); // 其余 4 个并发争抢者全部被拦截拒绝 expect(rejected.length).toBe(4); // 最终数据库状态必须归属于成功的销售,且不在公海 const finalLead = leads.find(l => l.id === 1); expect(finalLead?.isPool).toBe(false); expect(salesUsers).toContain(finalLead?.userId); }); }); // ---------------------------------------------------- // 2.4 assignLead 管理员指定派发 // ---------------------------------------------------- describe('2.4 assignLead - 管理员指定分配线索', () => { it('线索不存在时应抛出 404 异常', async () => { await expect(service.assignLead(9999, 100, 1)).rejects.toThrow( CoolCommException ); }); it('缺少目标指派用户 targetUserId 时抛出 400 异常', async () => { await expect(service.assignLead(1, 0, 1)).rejects.toThrow( CoolCommException ); }); it('管理员成功指派线索给目标销售,记录分配前后人及管理员审计日志', async () => { const assigned = await service.assignLead(1, 105, 1); expect(assigned.isPool).toBe(false); expect(assigned.userId).toBe(105); const assignLog = audits.find(a => a.leadId === 1 && a.action === 'assign'); expect(assignLog).toBeDefined(); expect(assignLog?.fromUserId).toBeNull(); expect(assignLog?.toUserId).toBe(105); expect(assignLog?.operatorId).toBe(1); }); }); // ---------------------------------------------------- // 2.5 审计轨迹完整性 (Audit Trail) // ---------------------------------------------------- describe('2.5 getAuditLogs - 流转审计链路追踪', () => { it('应能完整检索线索全生命周期的流转审计轨迹', async () => { // 先分配给销售 101 await service.assignLead(1, 101, 1); // 销售 101 退回公海 await service.recycleToPool(1, '重新流转'); // 销售 102 抢单 await service.claimLead(1, 102); const logs = await service.getAuditLogs(1); expect(logs.length).toBe(3); expect(logs.map(l => l.action)).toEqual(['assign', 'recycle', 'claim']); }); }); }); // ========================================== // 3. 管理后台控制器接口规范与路由调用测试 // ========================================== describe('3. 管理后台接口暴露 (AdminCrmLeadController)', () => { let controller: AdminCrmLeadController; let poolService: CrmLeadPoolService; beforeEach(() => { poolService = new CrmLeadPoolService(); poolService.claimLead = jest.fn(async (leadId, userId) => { const lead = new CrmLeadEntity(); lead.id = Number(leadId); lead.isPool = false; lead.userId = Number(userId); return lead; }); poolService.assignLead = jest.fn(async (leadId, targetUserId, assignerId) => { const lead = new CrmLeadEntity(); lead.id = Number(leadId); lead.isPool = false; lead.userId = Number(targetUserId); return lead; }); controller = new AdminCrmLeadController(); controller.crmLeadPoolService = poolService; // 模拟 Cool-Admin BaseController 上下文与 ok 方法 controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data, }); (controller as unknown as { ctx: { admin: { userId: number } } }).ctx = { admin: { userId: 9 }, }; }); it('控制器应包含 poolClaim 与 poolAssign 接口方法', () => { expect(typeof controller.poolClaim).toBe('function'); expect(typeof controller.poolAssign).toBe('function'); }); it('POST /pool-claim 认领公海线索成功返回标准 1000 响应', async () => { const res = await controller.poolClaim({ leadId: 1, userId: 101 }); expect(res.code).toBe(1000); expect(poolService.claimLead).toHaveBeenCalledWith(1, 101); const resData = (res as unknown as { data: CrmLeadEntity }).data; expect(resData.userId).toBe(101); }); it('POST /pool-claim 未提供 userId 时优先采用上下文登录用户 ID', async () => { const res = await controller.poolClaim({ leadId: 1 }); expect(res.code).toBe(1000); expect(poolService.claimLead).toHaveBeenCalledWith(1, 9); }); it('POST /pool-assign 管理员派发成功返回标准 1000 响应', async () => { const res = await controller.poolAssign({ leadId: 1, targetUserId: 202 }); expect(res.code).toBe(1000); expect(poolService.assignLead).toHaveBeenCalledWith(1, 202, 9); const resData = (res as unknown as { data: CrmLeadEntity }).data; expect(resData.userId).toBe(202); }); }); });