feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)

这个提交包含在:
wikiboss
2026-09-21 09:22:14 +08:00
父节点 8c003b70cd
当前提交 e5c79082d3
共有 20 个文件被更改,包括 1452 次插入 和 143 次删除

查看文件

@@ -243,6 +243,48 @@ export interface RadarTrack {
createdAt: string | Date;
}
/**
* 商务概览工作台 KPI
*/
export interface WorkplaceKpi {
/** 今日 NFC 触碰量 */
tapCount: number;
/** 今日名片浏览量 */
viewCount: number;
/** 今日新线索 */
newLeadCount: number;
/** 成交转化率 (赢单 / 未归档线索, 0-100) */
conversionRate: number;
/** 触碰量较昨日差值 */
tapDelta: number;
/** 浏览量较昨日差值 */
viewDelta: number;
/** 新线索较昨日差值 */
newLeadDelta: number;
}
/**
* 工作台雷达动态项
*/
export interface WorkplaceRadarItem {
id: number | string;
cardId: number | string;
country: string;
city: string;
touchType: TouchType;
stayDurationMs: number;
viewedBlocks: string[];
createTime: Date | string;
}
/**
* 商务概览工作台聚合结果
*/
export interface WorkplaceOverviewResult {
kpis: WorkplaceKpi;
radarFeed: WorkplaceRadarItem[];
}
// ==========================================
// 3. NFC 实体卡设备台账契约 (NFC Fleet)
// ==========================================

查看文件

@@ -22,6 +22,7 @@ import { CrmRadarService } from '../../service/radar';
{ method: 'getRecentTracks', summary: '最近足迹' },
{ method: 'distribution', summary: '国家分布' },
{ method: 'analytics', summary: '大屏综合指标' },
{ method: 'getWorkplaceOverview', summary: '商务概览工作台' },
],
})
export class AdminCrmRadarController extends BaseController {
@@ -72,5 +73,25 @@ export class AdminCrmRadarController extends BaseController {
});
return this.ok(data);
}
/**
* 商务概览工作台
* 路由: GET/POST /admin/crm/radar/workplace
*/
@Get('/workplace', { summary: '商务概览工作台' })
@Post('/workplace', { summary: '商务概览工作台' })
async workplace(
@Body() body?: { feedLimit?: number },
@Query('feedLimit') queryFeedLimit?: number
) {
const feedLimit = body?.feedLimit ?? queryFeedLimit;
const data = await this.crmRadarService.getWorkplaceOverview({
feedLimit:
feedLimit !== undefined && feedLimit !== null && !isNaN(Number(feedLimit))
? Number(feedLimit)
: undefined,
});
return this.ok(data);
}
}

查看文件

@@ -3,9 +3,137 @@ import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CrmTrackEntity } from '../entity/track';
import { TouchType } from '../../../comm/types/omni';
import { CrmLeadEntity } from '../entity/lead';
import {
LeadStatus,
TouchType,
WorkplaceOverviewResult,
WorkplaceRadarItem,
} from '../../../comm/types/omni';
import { lookupGeoIp } from '../../../comm/geoip/lookup';
interface WorkplaceTrackInput {
id?: number | string;
cardId?: number | string;
country?: string;
city?: string;
touchType?: TouchType;
stayDurationMs?: number;
viewedBlocks?: string[];
createTime?: Date | string;
createdAt?: Date | string;
}
interface WorkplaceLeadInput {
status?: LeadStatus;
createTime?: Date | string;
createdAt?: Date | string;
}
function toTimestamp(value?: Date | string): number {
if (!value) {
return 0;
}
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function startOfLocalDay(now: Date): Date {
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function resolveEventTime(item: { createTime?: Date | string; createdAt?: Date | string }): number {
return toTimestamp(item.createTime ?? item.createdAt);
}
/**
* 纯函数:按今日窗口聚合工作台 KPI 与雷达动态
*/
export function buildWorkplaceOverview(
tracks: WorkplaceTrackInput[],
leads: WorkplaceLeadInput[],
now: Date,
feedLimit = 12
): WorkplaceOverviewResult {
const todayStart = startOfLocalDay(now).getTime();
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
const parsedLimit = Number(feedLimit);
const take = Number.isFinite(parsedLimit)
? Math.min(50, Math.max(1, Math.round(parsedLimit)))
: 12;
let todayTaps = 0;
let todayViews = 0;
let yesterdayTaps = 0;
let yesterdayViews = 0;
for (const item of tracks) {
const time = resolveEventTime(item);
const isNfc = item.touchType === 'nfc';
if (time >= todayStart) {
todayViews += 1;
if (isNfc) {
todayTaps += 1;
}
} else if (time >= yesterdayStart && time < todayStart) {
yesterdayViews += 1;
if (isNfc) {
yesterdayTaps += 1;
}
}
}
let todayLeads = 0;
let yesterdayLeads = 0;
let activeLeads = 0;
let wonLeads = 0;
for (const item of leads) {
const time = resolveEventTime(item);
if (time >= todayStart) {
todayLeads += 1;
} else if (time >= yesterdayStart && time < todayStart) {
yesterdayLeads += 1;
}
if (item.status !== 'archived') {
activeLeads += 1;
if (item.status === 'won') {
wonLeads += 1;
}
}
}
const radarFeed: WorkplaceRadarItem[] = [...tracks]
.sort((a, b) => resolveEventTime(b) - resolveEventTime(a))
.slice(0, take)
.map((item) => ({
id: item.id ?? 0,
cardId: item.cardId ?? 0,
country: item.country || 'Unknown',
city: item.city || 'Unknown',
touchType: item.touchType && ['nfc', 'qr', 'link'].includes(item.touchType)
? item.touchType
: 'link',
stayDurationMs: Math.max(0, Math.round(item.stayDurationMs || 0)),
viewedBlocks: Array.isArray(item.viewedBlocks) ? item.viewedBlocks.map(String) : [],
createTime: item.createTime ?? item.createdAt ?? now,
}));
return {
kpis: {
tapCount: todayTaps,
viewCount: todayViews,
newLeadCount: todayLeads,
conversionRate:
activeLeads > 0 ? Number(((wonLeads / activeLeads) * 100).toFixed(1)) : 0,
tapDelta: todayTaps - yesterdayTaps,
viewDelta: todayViews - yesterdayViews,
newLeadDelta: todayLeads - yesterdayLeads,
},
radarFeed,
};
}
/**
* 访客足迹上报 DTO
*/
@@ -81,6 +209,9 @@ export class CrmRadarService extends BaseService {
@InjectEntityModel(CrmTrackEntity)
crmTrackEntity: Repository<CrmTrackEntity>;
@InjectEntityModel(CrmLeadEntity)
crmLeadEntity: Repository<CrmLeadEntity>;
@Inject()
ctx: any;
@@ -403,4 +534,21 @@ export class CrmRadarService extends BaseService {
},
};
}
/**
* 商务概览工作台:今日 KPI + 最近雷达动态
*/
async getWorkplaceOverview(params?: {
feedLimit?: number;
}): Promise<WorkplaceOverviewResult> {
const feedLimit = params?.feedLimit;
const [tracks, leads] = await Promise.all([
this.crmTrackEntity.find({
order: { id: 'DESC' },
take: 500,
}),
this.crmLeadEntity.find(),
]);
return buildWorkplaceOverview(tracks, leads, new Date(), feedLimit);
}
}

查看文件

@@ -0,0 +1,155 @@
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
import {
buildWorkplaceOverview,
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 });
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();
});
});