文件
OmniCard/backend/test/modules/crm/workplace.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

250 行
8.4 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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<CrmTrackEntity> & { 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<CrmLeadEntity> & { 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);
});
});
});