import { BaseController } from '@cool-midway/core'; import { BaseSysUserEntity } from '../../../src/modules/base/entity/sys/user'; import { CardInfoEntity } from '../../../src/modules/card/entity/info'; import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead'; import { CrmTrackEntity } from '../../../src/modules/crm/entity/track'; import { AdminTeamMembersController, adminTeamMembersCurdOption, } from '../../../src/modules/team/controller/admin/members'; import { TeamLiveRankService, TEAM_LIVE_RANK_EVENT, } from '../../../src/modules/team/service/live-rank'; import type { TeamMemberRankItem, TeamRankBroadcastPayload, } from '../../../src/comm/types/omni'; describe('Point 28: 展会现场团队获客实时排行榜大屏广播 (Live Rank & Broadcast)', () => { let service: TeamLiveRankService; let users: BaseSysUserEntity[]; let cards: CardInfoEntity[]; let leads: CrmLeadEntity[]; let tracks: CrmTrackEntity[]; beforeEach(() => { users = []; cards = []; leads = []; tracks = []; // Mock Repositories const mockUserRepo = { find: jest.fn(async (options?: { where?: Record }) => { if (options?.where?.tenantId !== undefined) { return users.filter( u => Number(u.tenantId) === Number(options.where?.tenantId) ); } return [...users]; }), }; const mockCardRepo = { find: jest.fn(async (options?: { where?: Record }) => { if (options?.where?.tenantId !== undefined) { return cards.filter( c => Number(c.tenantId) === Number(options.where?.tenantId) ); } return [...cards]; }), }; const mockLeadRepo = { find: jest.fn(async (options?: { where?: Record }) => { if (options?.where?.tenantId !== undefined) { return leads.filter( l => Number(l.tenantId) === Number(options.where?.tenantId) ); } return [...leads]; }), }; const mockTrackRepo = { find: jest.fn(async (options?: { where?: Record }) => { if (options?.where?.tenantId !== undefined) { return tracks.filter( t => Number(t.tenantId) === Number(options.where?.tenantId) ); } return [...tracks]; }), }; service = new TeamLiveRankService(); service.baseSysUserEntity = mockUserRepo as unknown as typeof service.baseSysUserEntity; service.cardInfoEntity = mockCardRepo as unknown as typeof service.cardInfoEntity; service.crmLeadEntity = mockLeadRepo as unknown as typeof service.crmLeadEntity; service.crmTrackEntity = mockTrackRepo as unknown as typeof service.crmTrackEntity; }); // ========================================== // 1. 排行榜加权计分与降序排序测试 // ========================================== describe('1. 排行榜加权计分与降序排序 (Weighted Score & Ranking)', () => { it('1.1 正确按每线索 10 分、每触碰 1 分计算总分并降序排列', async () => { // 成员 1: Alex (User 101, Card 1) const u1 = new BaseSysUserEntity(); u1.id = 101; u1.name = 'Alex Rivera'; u1.tenantId = 1; u1.departmentName = '国际业务一队'; const c1 = new CardInfoEntity(); c1.id = 1; c1.userId = 101; c1.tenantId = 1; // 成员 2: Sarah (User 102, Card 2) const u2 = new BaseSysUserEntity(); u2.id = 102; u2.name = 'Sarah Wang'; u2.tenantId = 1; u2.departmentName = '国际业务二队'; const c2 = new CardInfoEntity(); c2.id = 2; c2.userId = 102; c2.tenantId = 1; // 成员 3: Tariq (User 103, Card 3) const u3 = new BaseSysUserEntity(); u3.id = 103; u3.name = 'Tariq Mansour'; u3.tenantId = 1; u3.departmentName = '中东中枢'; const c3 = new CardInfoEntity(); c3.id = 3; c3.userId = 103; c3.tenantId = 1; users.push(u1, u2, u3); cards.push(c1, c2, c3); // Alex: 2 leads (20分), 5 touches (5分) => 25分 leads.push( Object.assign(new CrmLeadEntity(), { id: 1, cardId: 1, userId: 101, tenantId: 1 }), Object.assign(new CrmLeadEntity(), { id: 2, cardId: 1, userId: 101, tenantId: 1 }) ); for (let i = 0; i < 5; i++) { tracks.push( Object.assign(new CrmTrackEntity(), { id: 10 + i, cardId: 1, tenantId: 1 }) ); } // Sarah: 5 leads (50分), 12 touches (12分) => 62分 for (let i = 0; i < 5; i++) { leads.push( Object.assign(new CrmLeadEntity(), { id: 20 + i, cardId: 2, userId: 102, tenantId: 1 }) ); } for (let i = 0; i < 12; i++) { tracks.push( Object.assign(new CrmTrackEntity(), { id: 30 + i, cardId: 2, tenantId: 1 }) ); } // Tariq: 0 leads (0分), 8 touches (8分) => 8分 for (let i = 0; i < 8; i++) { tracks.push( Object.assign(new CrmTrackEntity(), { id: 50 + i, cardId: 3, tenantId: 1 }) ); } const ranks = await service.getTeamLiveRank('1'); expect(ranks).toHaveLength(3); // 第 1 名: Sarah (62分) expect(ranks[0].memberId).toBe(102); expect(ranks[0].memberName).toBe('Sarah Wang'); expect(ranks[0].leads).toBe(5); expect(ranks[0].touches).toBe(12); expect(ranks[0].score).toBe(62); expect(ranks[0].rank).toBe(1); // 第 2 名: Alex (25分) expect(ranks[1].memberId).toBe(101); expect(ranks[1].memberName).toBe('Alex Rivera'); expect(ranks[1].leads).toBe(2); expect(ranks[1].touches).toBe(5); expect(ranks[1].score).toBe(25); expect(ranks[1].rank).toBe(2); // 第 3 名: Tariq (8分) expect(ranks[2].memberId).toBe(103); expect(ranks[2].memberName).toBe('Tariq Mansour'); expect(ranks[2].leads).toBe(0); expect(ranks[2].touches).toBe(8); expect(ranks[2].score).toBe(8); expect(ranks[2].rank).toBe(3); }); it('1.2 成员关联多张名片时,正确合并统计多名片的线索与触碰', async () => { const u = new BaseSysUserEntity(); u.id = 201; u.name = 'Multi Card Member'; u.tenantId = 1; users.push(u); const cA = new CardInfoEntity(); cA.id = 11; cA.userId = 201; cA.tenantId = 1; const cB = new CardInfoEntity(); cB.id = 12; cB.userId = 201; cB.tenantId = 1; cards.push(cA, cB); leads.push( Object.assign(new CrmLeadEntity(), { id: 101, cardId: 11, userId: 201, tenantId: 1 }), Object.assign(new CrmLeadEntity(), { id: 102, cardId: 12, userId: 201, tenantId: 1 }) ); tracks.push( Object.assign(new CrmTrackEntity(), { id: 201, cardId: 11, tenantId: 1 }), Object.assign(new CrmTrackEntity(), { id: 202, cardId: 12, tenantId: 1 }), Object.assign(new CrmTrackEntity(), { id: 203, cardId: 12, tenantId: 1 }) ); const ranks = await service.getTeamLiveRank('1'); expect(ranks).toHaveLength(1); expect(ranks[0].leads).toBe(2); expect(ranks[0].touches).toBe(3); expect(ranks[0].score).toBe(2 * 10 + 3 * 1); // 23分 }); }); // ========================================== // 2. 同分并列规则测试 // ========================================== describe('2. 同分并列规则测试 (Tie-Break & Shared Rank Rules)', () => { it('2.1 当两位成员总分相同时,应获得相同 rank 并标记 isTied', async () => { const u1 = Object.assign(new BaseSysUserEntity(), { id: 301, name: 'Alice', tenantId: 2 }); const u2 = Object.assign(new BaseSysUserEntity(), { id: 302, name: 'Bob', tenantId: 2 }); const u3 = Object.assign(new BaseSysUserEntity(), { id: 303, name: 'Charlie', tenantId: 2 }); users.push(u1, u2, u3); const c1 = Object.assign(new CardInfoEntity(), { id: 31, userId: 301, tenantId: 2 }); const c2 = Object.assign(new CardInfoEntity(), { id: 32, userId: 302, tenantId: 2 }); const c3 = Object.assign(new CardInfoEntity(), { id: 33, userId: 303, tenantId: 2 }); cards.push(c1, c2, c3); // Alice: 3 leads (30), 10 touches (10) => 40 分 for (let i = 0; i < 3; i++) { leads.push(Object.assign(new CrmLeadEntity(), { id: 300 + i, cardId: 31, userId: 301, tenantId: 2 })); } for (let i = 0; i < 10; i++) { tracks.push(Object.assign(new CrmTrackEntity(), { id: 400 + i, cardId: 31, tenantId: 2 })); } // Bob: 4 leads (40), 0 touches (0) => 40 分 (与 Alice 同分!) for (let i = 0; i < 4; i++) { leads.push(Object.assign(new CrmLeadEntity(), { id: 310 + i, cardId: 32, userId: 302, tenantId: 2 })); } // Charlie: 1 lead (10), 5 touches (5) => 15 分 leads.push(Object.assign(new CrmLeadEntity(), { id: 320, cardId: 33, userId: 303, tenantId: 2 })); for (let i = 0; i < 5; i++) { tracks.push(Object.assign(new CrmTrackEntity(), { id: 420 + i, cardId: 33, tenantId: 2 })); } const ranks = await service.getTeamLiveRank('2'); expect(ranks).toHaveLength(3); // 前两名总分都是 40 分 expect(ranks[0].score).toBe(40); expect(ranks[1].score).toBe(40); // 并列第 1 名 expect(ranks[0].rank).toBe(1); expect(ranks[1].rank).toBe(1); expect(ranks[0].isTied).toBe(true); expect(ranks[1].isTied).toBe(true); // 第 3 位 Charlie 的排名递推为第 3 名 (标准竞赛并列: 1, 1, 3) expect(ranks[2].score).toBe(15); expect(ranks[2].rank).toBe(3); expect(ranks[2].isTied).toBe(false); }); it('2.2 中间位置同分并列:第 2 名并列时第 4 名为 rank 4', async () => { const u1 = Object.assign(new BaseSysUserEntity(), { id: 401, name: 'Leader', tenantId: 3 }); const u2 = Object.assign(new BaseSysUserEntity(), { id: 402, name: 'Runner1', tenantId: 3 }); const u3 = Object.assign(new BaseSysUserEntity(), { id: 403, name: 'Runner2', tenantId: 3 }); const u4 = Object.assign(new BaseSysUserEntity(), { id: 404, name: 'Fourth', tenantId: 3 }); users.push(u1, u2, u3, u4); cards.push( Object.assign(new CardInfoEntity(), { id: 41, userId: 401, tenantId: 3 }), Object.assign(new CardInfoEntity(), { id: 42, userId: 402, tenantId: 3 }), Object.assign(new CardInfoEntity(), { id: 43, userId: 403, tenantId: 3 }), Object.assign(new CardInfoEntity(), { id: 44, userId: 404, tenantId: 3 }) ); // Leader: 10 leads = 100分 for (let i = 0; i < 10; i++) { leads.push(Object.assign(new CrmLeadEntity(), { id: 500 + i, cardId: 41, userId: 401, tenantId: 3 })); } // Runner1: 5 leads = 50分 for (let i = 0; i < 5; i++) { leads.push(Object.assign(new CrmLeadEntity(), { id: 520 + i, cardId: 42, userId: 402, tenantId: 3 })); } // Runner2: 50 touches = 50分 for (let i = 0; i < 50; i++) { tracks.push(Object.assign(new CrmTrackEntity(), { id: 600 + i, cardId: 43, tenantId: 3 })); } // Fourth: 1 lead = 10分 leads.push(Object.assign(new CrmLeadEntity(), { id: 550, cardId: 44, userId: 404, tenantId: 3 })); const ranks = await service.getTeamLiveRank('3'); expect(ranks[0].rank).toBe(1); expect(ranks[0].score).toBe(100); expect(ranks[1].rank).toBe(2); expect(ranks[1].score).toBe(50); expect(ranks[1].isTied).toBe(true); expect(ranks[2].rank).toBe(2); expect(ranks[2].score).toBe(50); expect(ranks[2].isTied).toBe(true); expect(ranks[3].rank).toBe(4); expect(ranks[3].score).toBe(10); expect(ranks[3].isTied).toBe(false); }); }); // ========================================== // 3. 展会事件 (eventId) 筛选测试 // ========================================== describe('3. 展会现场事件筛选 (Event Filtering)', () => { it('3.1 传入 eventId 时,仅聚合该展会事件的线索与触碰量', async () => { const u1 = Object.assign(new BaseSysUserEntity(), { id: 501, name: 'Exhibitor', tenantId: 4 }); users.push(u1); const c1 = Object.assign(new CardInfoEntity(), { id: 51, userId: 501, tenantId: 4 }); cards.push(c1); // CES-2026 线索 (2条) 与 触碰 (3条) const leadCes1 = Object.assign(new CrmLeadEntity(), { id: 701, cardId: 51, userId: 501, tenantId: 4, notes: 'Met at #CES-2026 Booth 808', }); (leadCes1 as unknown as { eventId?: string }).eventId = 'CES-2026'; const leadCes2 = Object.assign(new CrmLeadEntity(), { id: 702, cardId: 51, userId: 501, tenantId: 4, notes: '#CES-2026 Client', }); (leadCes2 as unknown as { eventId?: string }).eventId = 'CES-2026'; // MWC-2026 线索 (1条,不同展会) const leadMwc = Object.assign(new CrmLeadEntity(), { id: 703, cardId: 51, userId: 501, tenantId: 4, notes: '#MWC-2026 Lead', }); (leadMwc as unknown as { eventId?: string }).eventId = 'MWC-2026'; leads.push(leadCes1, leadCes2, leadMwc); // 触碰: 3条 CES-2026, 2条 MWC-2026 for (let i = 0; i < 3; i++) { const t = Object.assign(new CrmTrackEntity(), { id: 800 + i, cardId: 51, tenantId: 4 }); (t as unknown as { eventId?: string }).eventId = 'CES-2026'; tracks.push(t); } for (let i = 0; i < 2; i++) { const t = Object.assign(new CrmTrackEntity(), { id: 810 + i, cardId: 51, tenantId: 4 }); (t as unknown as { eventId?: string }).eventId = 'MWC-2026'; tracks.push(t); } // 按 CES-2026 查询 const ranksCes = await service.getTeamLiveRank('4', 'CES-2026'); expect(ranksCes).toHaveLength(1); expect(ranksCes[0].leads).toBe(2); expect(ranksCes[0].touches).toBe(3); expect(ranksCes[0].score).toBe(2 * 10 + 3 * 1); // 23分 // 按 MWC-2026 查询 const ranksMwc = await service.getTeamLiveRank('4', 'MWC-2026'); expect(ranksMwc).toHaveLength(1); expect(ranksMwc[0].leads).toBe(1); expect(ranksMwc[0].touches).toBe(2); expect(ranksMwc[0].score).toBe(1 * 10 + 2 * 1); // 12分 // 不传 eventId 时全部统计 const ranksAll = await service.getTeamLiveRank('4'); expect(ranksAll[0].leads).toBe(3); expect(ranksAll[0].touches).toBe(5); expect(ranksAll[0].score).toBe(35); }); }); // ========================================== // 4. 空数据容错测试 // ========================================== describe('4. 空数据与边界容错测试 (Empty Data Tolerance)', () => { it('4.1 租户无任何成员时安全返回空数组,不报错', async () => { const ranks = await service.getTeamLiveRank('9999'); expect(Array.isArray(ranks)).toBe(true); expect(ranks).toHaveLength(0); }); it('4.2 成员没有任何名片、线索与触碰时,全零并列第 1 名', async () => { const u = Object.assign(new BaseSysUserEntity(), { id: 901, name: 'Newbie', tenantId: 5 }); users.push(u); const ranks = await service.getTeamLiveRank('5'); expect(ranks).toHaveLength(1); expect(ranks[0].memberId).toBe(901); expect(ranks[0].leads).toBe(0); expect(ranks[0].touches).toBe(0); expect(ranks[0].score).toBe(0); expect(ranks[0].rank).toBe(1); }); }); // ========================================== // 5. WebSocket 广播事件载荷格式测试 // ========================================== describe('5. WebSocket 广播事件载荷生成 (formatRankBroadcastPayload)', () => { it('5.1 正确生成包含 event、timestamp、ranks、data 与 meta 的结构化广播载荷', () => { const mockRanks: TeamMemberRankItem[] = [ { memberId: 101, memberName: 'Sarah', departmentName: 'BD', cardId: 1, leads: 6, touches: 10, score: 70, rank: 1, isTied: false, }, { memberId: 102, memberName: 'Alex', departmentName: 'BD', cardId: 2, leads: 3, touches: 10, score: 40, rank: 2, isTied: false, }, ]; const before = Date.now(); const payload: TeamRankBroadcastPayload = service.formatRankBroadcastPayload( mockRanks, { tenantId: '10', eventId: 'GITEX_2026' } ); const after = Date.now(); expect(payload.event).toBe(TEAM_LIVE_RANK_EVENT); expect(payload.event).toBe('team.live_rank'); expect(payload.timestamp).toBeGreaterThanOrEqual(before); expect(payload.timestamp).toBeLessThanOrEqual(after); expect(payload.total).toBe(2); expect(payload.ranks).toEqual(mockRanks); expect(payload.data).toEqual(mockRanks); expect(payload.meta?.tenantId).toBe('10'); expect(payload.meta?.eventId).toBe('GITEX_2026'); expect(payload.meta?.topScore).toBe(70); expect(payload.meta?.totalLeads).toBe(9); expect(payload.meta?.totalTouches).toBe(20); }); it('5.2 空数组 ranks 能够安全生成结构化载荷,各项指标为 0', () => { const payload = service.formatRankBroadcastPayload([]); expect(payload.event).toBe('team.live_rank'); expect(payload.total).toBe(0); expect(payload.ranks).toEqual([]); expect(payload.meta?.topScore).toBe(0); expect(payload.meta?.totalLeads).toBe(0); expect(payload.meta?.totalTouches).toBe(0); }); }); // ========================================== // 6. 控制器方法与 CRUD 配置测试 // ========================================== describe('6. 控制器接口暴露与调用测试 (AdminTeamMembersController /live-rank)', () => { let controller: AdminTeamMembersController; let mockLiveRankService: TeamLiveRankService; beforeEach(() => { mockLiveRankService = new TeamLiveRankService(); mockLiveRankService.getTeamLiveRank = jest.fn(async () => [ { memberId: 1, memberName: 'Champion', leads: 10, touches: 5, score: 105, rank: 1, }, ]); controller = new AdminTeamMembersController(); controller.teamLiveRankService = mockLiveRankService; controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data, }); }); it('6.1 adminTeamMembersCurdOption 包含 liveRank 接口说明', () => { const methods = (adminTeamMembersCurdOption.serviceApis || []).map( (api: { method: string }) => api.method ); expect(methods).toContain('liveRank'); }); it('6.2 postLiveRank 接口正常响应 code 1000 并返回排行榜', async () => { const res = (await controller.postLiveRank({ tenantId: '8', eventId: 'EXPO-2026', })) as unknown as { code: number; message: string; data: TeamMemberRankItem[] }; expect(res.code).toBe(1000); expect(mockLiveRankService.getTeamLiveRank).toHaveBeenCalledWith( '8', 'EXPO-2026' ); const data = res.data; expect(data).toHaveLength(1); expect(data[0].score).toBe(105); }); it('6.3 postLiveRank 在未传参时默认兜底 tenantId 为 1', async () => { const res = (await controller.postLiveRank()) as unknown as { code: number; message: string; }; expect(res.code).toBe(1000); expect(mockLiveRankService.getTeamLiveRank).toHaveBeenCalledWith( '1', undefined ); }); }); });