import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar'; import { buildWorkplaceOverview, calculateTrackIntentScore, CrmRadarService, } from '../../../src/modules/crm/service/radar'; import { CrmTrackEntity } from '../../../src/modules/crm/entity/track'; import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead'; function track(partial: Partial & { id: number; createTime: Date }): CrmTrackEntity { const item = new CrmTrackEntity(); item.id = partial.id; item.cardId = partial.cardId ?? 1; item.country = partial.country ?? 'AE'; item.city = partial.city ?? 'Dubai'; item.touchType = partial.touchType ?? 'link'; item.stayDurationMs = partial.stayDurationMs ?? 30000; item.viewedBlocks = partial.viewedBlocks ?? ['hero']; item.createTime = partial.createTime; return item; } function lead( partial: Partial & { id: number; createTime: Date } ): CrmLeadEntity { const item = new CrmLeadEntity(); item.id = partial.id; item.status = partial.status ?? 'new'; item.createTime = partial.createTime; return item; } describe('商务概览工作台聚合 (getWorkplaceOverview)', () => { const now = new Date('2026-09-21T15:00:00'); const today = new Date('2026-09-21T09:30:00'); const yesterday = new Date('2026-09-20T11:00:00'); const older = new Date('2026-09-18T08:00:00'); it('应按今日窗口统计触碰量、浏览量、新线索,并计算成交转化', () => { const result = buildWorkplaceOverview( [ track({ id: 1, touchType: 'nfc', createTime: today, country: 'AE', city: 'Dubai' }), track({ id: 2, touchType: 'qr', createTime: today, country: 'SG', city: 'Singapore' }), track({ id: 3, touchType: 'nfc', createTime: yesterday }), track({ id: 4, touchType: 'link', createTime: older }), ], [ lead({ id: 1, status: 'new', createTime: today }), lead({ id: 2, status: 'won', createTime: older }), lead({ id: 3, status: 'archived', createTime: older }), lead({ id: 4, status: 'contacted', createTime: yesterday }), ], now, 10 ); expect(result.kpis.tapCount).toBe(1); expect(result.kpis.viewCount).toBe(2); expect(result.kpis.newLeadCount).toBe(1); expect(result.kpis.conversionRate).toBe(33.3); expect(result.kpis.tapDelta).toBe(0); expect(result.kpis.viewDelta).toBe(1); expect(result.kpis.newLeadDelta).toBe(0); }); it('空数据时应返回零值 KPI 与空雷达动态', () => { const result = buildWorkplaceOverview([], [], now, 12); expect(result.kpis.tapCount).toBe(0); expect(result.kpis.viewCount).toBe(0); expect(result.kpis.newLeadCount).toBe(0); expect(result.kpis.conversionRate).toBe(0); expect(result.radarFeed).toEqual([]); }); it('雷达动态应按时间倒序截断,且不要求 cardId', () => { const result = buildWorkplaceOverview( [ track({ id: 1, createTime: yesterday, country: 'US', city: 'Austin' }), track({ id: 3, createTime: today, country: 'DE', city: 'Berlin', viewedBlocks: ['cases'] }), track({ id: 2, createTime: today, country: 'JP', city: 'Tokyo' }), ], [], now, 2 ); expect(result.radarFeed).toHaveLength(2); expect(result.radarFeed[0].id).toBe(3); expect(result.radarFeed[0].city).toBe('Berlin'); expect(result.radarFeed[1].id).toBe(2); }); it('归档线索不计入成交转化分母', () => { const result = buildWorkplaceOverview( [], [ lead({ id: 1, status: 'won', createTime: older }), lead({ id: 2, status: 'archived', createTime: older }), lead({ id: 3, status: 'archived', createTime: older }), ], now ); expect(result.kpis.conversionRate).toBe(100); }); it('服务应聚合足迹与线索仓库并返回工作台结果', async () => { const service = new CrmRadarService(); const tracks = [ track({ id: 9, touchType: 'nfc', createTime: today, viewedBlocks: ['hero', 'cases'] }), ]; const leads = [lead({ id: 7, status: 'new', createTime: today })]; service.crmTrackEntity = { find: jest.fn().mockResolvedValue(tracks), } as never; service.crmLeadEntity = { find: jest.fn().mockResolvedValue(leads), } as never; const result = await service.getWorkplaceOverview({ feedLimit: 8, now: today }); expect(result.kpis.tapCount).toBe(1); expect(result.kpis.newLeadCount).toBe(1); expect(result.radarFeed[0].viewedBlocks).toEqual(['hero', 'cases']); }); it('管理端控制器必须暴露 /workplace 聚合接口', async () => { const controller = new AdminCrmRadarController(); const overview = { kpis: { tapCount: 1, viewCount: 2, newLeadCount: 1, conversionRate: 10, tapDelta: 0, viewDelta: 1, newLeadDelta: 0, }, radarFeed: [], }; controller.crmRadarService = { getWorkplaceOverview: jest.fn().mockResolvedValue(overview), } as never; (controller as unknown as { ok: (data: unknown) => unknown }).ok = (data) => ({ code: 1000, data, }); const res = (await controller.workplace({}, 12)) as unknown as { code: number; data: typeof overview; }; expect(res.code).toBe(1000); expect(res.data.kpis.viewCount).toBe(2); expect(controller.crmRadarService.getWorkplaceOverview).toHaveBeenCalled(); }); describe('数据专家重构:科学意向度与会话降噪增强', () => { it('科学意向分算法 (calculateTrackIntentScore) 必须符合多因子阶梯模型', () => { // 1. 直链 0 秒跳出,无任何浏览与滚动:低意向分 (10分) const bounceScore = calculateTrackIntentScore({ touchType: 'link', stayDurationMs: 0, scrollDepth: 0, viewedBlocks: [], }); expect(bounceScore).toBe(10); // 2. NFC 面对面触碰初始分:天然高权重 (35分) const nfcInitialScore = calculateTrackIntentScore({ touchType: 'nfc', stayDurationMs: 0, }); expect(nfcInitialScore).toBe(35); // 3. QR 扫码 + 30秒研读 + 2个模块 + 深度滚动:高意向买家 (25 + 20 + 10 + 10 = 65分) const engagedScore = calculateTrackIntentScore({ touchType: 'qr', stayDurationMs: 30000, viewedBlocks: ['hero', 'products'], scrollDepth: 75, }); expect(engagedScore).toBe(65); // 4. NFC 触碰 + 150秒深读 + 4个模块 + 深度滚动:极高意向买家 (封顶 99分) const hotScore = calculateTrackIntentScore({ touchType: 'nfc', stayDurationMs: 150000, viewedBlocks: ['hero', 'products', 'cases', 'contact'], scrollDepth: 95, }); expect(hotScore).toBe(99); }); it('buildWorkplaceOverview 应实现会话去噪与合并,并填充名片名称和本地标记', () => { const cardList = [ { id: 1, name: 'Alex Chen', title: '全球商务总监' }, ]; // 同一访客 vis_101 先产生了 0 秒 PV,又更新了 45 秒有效停留 const t1 = track({ id: 10, cardId: 1, touchType: 'link', country: 'Local', city: 'Local', stayDurationMs: 0, createTime: new Date('2026-09-21T10:00:00'), }); t1.visitorId = 'vis_101'; const t2 = track({ id: 11, cardId: 1, touchType: 'link', country: 'Local', city: 'Local', stayDurationMs: 45000, viewedBlocks: ['products'], createTime: new Date('2026-09-21T10:01:00'), }); t2.visitorId = 'vis_101'; const result = buildWorkplaceOverview( [t1, t2], [], now, 10, cardList ); // 验证 1:去噪合并后同一会话只保留 1 条记录 expect(result.radarFeed).toHaveLength(1); const feedItem = result.radarFeed[0]; // 验证 2:停留时长采用合并后的最高值 45 秒 expect(feedItem.stayDurationMs).toBe(45000); expect(feedItem.viewedBlocks).toContain('products'); // 验证 3:名片名称正确注入 expect(feedItem.cardTitle).toBe('Alex Chen · 全球商务总监'); // 验证 4:识别为本地环境 expect(feedItem.isLocal).toBe(true); // 验证 5:意向分由后端自动算出并大于 0 秒跳出分 expect(feedItem.intentScore).toBeGreaterThan(10); }); }); });