import { CrmTrackEntity } from '../../../src/modules/crm/entity/track'; import { CrmRadarService } from '../../../src/modules/crm/service/radar'; import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar'; describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.test.ts)', () => { let radarService: CrmRadarService; let trackList: CrmTrackEntity[]; beforeEach(() => { trackList = []; // Mock TypeORM Repository const mockRepo: any = { find: jest.fn().mockImplementation(async (options?: any) => { let list = [...trackList]; if (options?.where) { const where = options.where; if (where.cardId !== undefined && where.cardId !== null) { list = list.filter((item) => Number(item.cardId) === Number(where.cardId)); } if (where.createTime && typeof where.createTime === 'object') { // 支持 TypeORM MoreThanOrEqual / Between 等操作符过滤 if (where.createTime._type === 'moreThanOrEqual' || where.createTime._value) { const minTime = where.createTime._value || where.createTime; list = list.filter((item) => new Date(item.createTime) >= new Date(minTime)); } } } return list; }), createQueryBuilder: jest.fn().mockReturnValue(null), }; radarService = new CrmRadarService(); radarService.crmTrackEntity = mockRepo; }); describe('1. 国家分布统计 (getCountryDistribution)', () => { it('空数据时应安全兜底返回空数组 []', async () => { const result = await radarService.getCountryDistribution(); expect(result).toEqual([]); }); it('应准确按国家聚合访客数、计算占比与平均停留时长,并按人次降序排列', async () => { trackList = [ { id: 1, cardId: 10, country: 'United States', stayDurationMs: 30000, createTime: new Date(), } as CrmTrackEntity, { id: 2, cardId: 10, country: 'United States', stayDurationMs: 10000, createTime: new Date(), } as CrmTrackEntity, { id: 3, cardId: 10, country: 'Singapore', stayDurationMs: 20000, createTime: new Date(), } as CrmTrackEntity, { id: 4, cardId: 10, country: 'Germany', stayDurationMs: 40000, createTime: new Date(), } as CrmTrackEntity, ]; const result = await radarService.getCountryDistribution(); expect(result.length).toBe(3); // US 有 2 次访问 (50%), Singapore 1 次 (25%), Germany 1 次 (25%) expect(result[0].country).toBe('United States'); expect(result[0].visitorCount).toBe(2); expect(result[0].percentage).toBe(50); expect(result[0].avgStayDurationMs).toBe(20000); expect(result[1].visitorCount).toBe(1); expect(result[1].percentage).toBe(25); }); it('当国家字段为空或缺失时,应统一兜底为 Unknown', async () => { trackList = [ { id: 1, cardId: 10, country: '', stayDurationMs: 5000, createTime: new Date(), } as CrmTrackEntity, { id: 2, cardId: 10, country: undefined, stayDurationMs: 15000, createTime: new Date(), } as CrmTrackEntity, ]; const result = await radarService.getCountryDistribution(); expect(result.length).toBe(1); expect(result[0].country).toBe('Unknown'); expect(result[0].visitorCount).toBe(2); expect(result[0].percentage).toBe(100); expect(result[0].avgStayDurationMs).toBe(10000); }); it('应支持根据指定 cardId 过滤数据', async () => { trackList = [ { id: 1, cardId: 10, country: 'Japan', stayDurationMs: 10000, createTime: new Date(), } as CrmTrackEntity, { id: 2, cardId: 20, country: 'Japan', stayDurationMs: 20000, createTime: new Date(), } as CrmTrackEntity, ]; const result = await radarService.getCountryDistribution(10); expect(result.length).toBe(1); expect(result[0].visitorCount).toBe(1); expect(result[0].country).toBe('Japan'); }); }); describe('2. 停留时长时段分布 (getHourlyStayStats)', () => { it('返回结果必须严格包含 0 至 23 共 24 个时段,空时段以 0 填充兜底', async () => { const stats = await radarService.getHourlyStayStats(7); expect(stats.length).toBe(24); for (let h = 0; h < 24; h++) { expect(stats[h].hour).toBe(h); expect(stats[h].visitorCount).toBe(0); expect(stats[h].avgStayDurationMs).toBe(0); } }); it('应能正确将足迹按创建时间的小时进行归类与停留时长均值统计', async () => { const now = new Date(); const dateHour14 = new Date(now); dateHour14.setHours(14, 30, 0, 0); const dateHour14_2 = new Date(now); dateHour14_2.setHours(14, 45, 0, 0); trackList = [ { id: 1, cardId: 10, stayDurationMs: 10000, createTime: dateHour14, } as CrmTrackEntity, { id: 2, cardId: 10, stayDurationMs: 30000, createTime: dateHour14_2, } as CrmTrackEntity, ]; const stats = await radarService.getHourlyStayStats(7); expect(stats.length).toBe(24); expect(stats[14].visitorCount).toBe(2); expect(stats[14].avgStayDurationMs).toBe(20000); expect(stats[0].visitorCount).toBe(0); }); }); describe('3. 终端渠道分析 (getDeviceStats)', () => { it('空数据时应安全返回空数组 []', async () => { const result = await radarService.getDeviceStats(); expect(Array.isArray(result)).toBe(true); expect(result).toEqual([]); }); it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => { trackList = [ { id: 1, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity, { id: 2, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity, { id: 3, touchType: 'qr', createTime: new Date() } as CrmTrackEntity, { id: 4, touchType: 'link', createTime: new Date() } as CrmTrackEntity, ]; const result = await radarService.getDeviceStats(); const nfcItem = result.find((item) => item.type === 'nfc'); const qrItem = result.find((item) => item.type === 'qr'); const linkItem = result.find((item) => item.type === 'link'); expect(nfcItem?.count).toBe(2); expect(nfcItem?.percentage).toBe(50); expect(qrItem?.count).toBe(1); expect(qrItem?.percentage).toBe(25); expect(linkItem?.count).toBe(1); expect(linkItem?.percentage).toBe(25); }); }); describe('4. 控制器接口契约 (AdminCrmRadarController)', () => { let controller: AdminCrmRadarController; let mockService: any; beforeEach(() => { mockService = { getCountryDistribution: jest.fn().mockResolvedValue([ { country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 }, ]), getHourlyStayStats: jest.fn().mockResolvedValue([]), getDeviceStats: jest.fn().mockResolvedValue([]), getAnalytics: jest.fn().mockResolvedValue({ countryDistribution: [ { country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 }, ], hourlyStats: [], deviceStats: [], summary: { totalVisitors: 10, avgStayDurationMs: 15000, topCountry: 'Singapore', countriesCount: 1, }, }), }; controller = new AdminCrmRadarController(); (controller as any).crmRadarService = mockService; (controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data }); (controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg }); }); it('GET /admin/crm/radar/distribution 应返回标准 1000 成功响应与国家分布列表', async () => { const res: any = await controller.distribution({ cardId: 10 }); expect(res.code).toBe(1000); expect(res.data[0].country).toBe('Singapore'); expect(mockService.getCountryDistribution).toHaveBeenCalledWith(10); }); it('GET/POST /admin/crm/radar/analytics 应聚合返回大屏综合数据与 KPI 汇总', async () => { const res: any = await controller.analytics({ cardId: 10, days: 7 }); expect(res.code).toBe(1000); expect(res.data.countryDistribution).toBeDefined(); expect(res.data.hourlyStats).toBeDefined(); expect(res.data.deviceStats).toBeDefined(); expect(res.data.summary).toBeDefined(); expect(res.data.summary.totalVisitors).toBe(10); expect(res.data.summary.topCountry).toBe('Singapore'); }); }); });