feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户