文件
OmniCard/backend/src/modules/crm/service/attribution.ts

170 行
4.5 KiB
TypeScript

import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
export type TrafficChannelType =
| 'nfc_tap'
| 'booth_qr'
| 'whatsapp_chat'
| 'linkedin_share'
| 'email_signature'
| 'direct_or_organic';
export type FunnelStageType =
| 'impression'
| 'interactive'
| 'ai_consult'
| 'lead_submitted'
| 'meeting_booked';
export interface ChannelAttributionEvent {
visitorId: string;
channel: TrafficChannelType | string;
stage: FunnelStageType;
timestamp?: number;
}
export interface AttributionFunnelReport {
totalVisitors: number;
stages: Record<FunnelStageType, number>;
stageRates: Record<FunnelStageType, number>;
overallConversionRate: number;
}
export interface ChannelPerformanceItem {
channel: string;
visitors: number;
leads: number;
leadRate: number;
meetings: number;
}
/**
* 流量来源与渠道归一化解析
*/
export function normalizeTrafficChannel(
source?: string,
medium?: string
): TrafficChannelType {
const s = (source || '').toLowerCase().trim();
const m = (medium || '').toLowerCase().trim();
if (s.includes('nfc') || m.includes('nfc') || m.includes('tap')) {
return 'nfc_tap';
}
if (s.includes('booth') || m.includes('booth') || m.includes('qrcode')) {
return 'booth_qr';
}
if (s.includes('whatsapp') || m.includes('whatsapp')) {
return 'whatsapp_chat';
}
if (s.includes('linkedin') || m.includes('linkedin')) {
return 'linkedin_share';
}
if (s.includes('email') || m.includes('sig')) {
return 'email_signature';
}
return 'direct_or_organic';
}
/**
* 计算全链路出海留资转化漏斗指标
*/
export function calculateAttributionFunnel(
events: ChannelAttributionEvent[]
): AttributionFunnelReport {
const visitorSet = new Set<string>();
const stageSets: Record<FunnelStageType, Set<string>> = {
impression: new Set(),
interactive: new Set(),
ai_consult: new Set(),
lead_submitted: new Set(),
meeting_booked: new Set(),
};
for (const e of events) {
visitorSet.add(e.visitorId);
if (stageSets[e.stage]) {
stageSets[e.stage].add(e.visitorId);
}
}
const total = visitorSet.size;
const stages: Record<FunnelStageType, number> = {
impression: stageSets.impression.size,
interactive: stageSets.interactive.size,
ai_consult: stageSets.ai_consult.size,
lead_submitted: stageSets.lead_submitted.size,
meeting_booked: stageSets.meeting_booked.size,
};
const stageRates: Record<FunnelStageType, number> = {
impression: total > 0 ? Number((stages.impression / total).toFixed(2)) : 0,
interactive: total > 0 ? Number((stages.interactive / total).toFixed(2)) : 0,
ai_consult: total > 0 ? Number((stages.ai_consult / total).toFixed(2)) : 0,
lead_submitted: total > 0 ? Number((stages.lead_submitted / total).toFixed(2)) : 0,
meeting_booked: total > 0 ? Number((stages.meeting_booked / total).toFixed(2)) : 0,
};
const overallConversionRate =
total > 0 ? Number((stages.lead_submitted / total).toFixed(2)) : 0;
return {
totalVisitors: total,
stages,
stageRates,
overallConversionRate,
};
}
/**
* 访客雷达多渠道归因与出海转化分析服务
*/
@Provide()
export class RadarAttributionService extends BaseService {
/**
* 渠道转化表现分析
*/
analyzeChannelPerformance(
events: ChannelAttributionEvent[]
): Record<string, ChannelPerformanceItem> {
const channelMap: Record<string, { visitors: Set<string>; leads: Set<string>; meetings: Set<string> }> = {};
for (const e of events) {
const ch = e.channel;
if (!channelMap[ch]) {
channelMap[ch] = {
visitors: new Set(),
leads: new Set(),
meetings: new Set(),
};
}
channelMap[ch].visitors.add(e.visitorId);
if (e.stage === 'lead_submitted') {
channelMap[ch].leads.add(e.visitorId);
}
if (e.stage === 'meeting_booked') {
channelMap[ch].meetings.add(e.visitorId);
}
}
const report: Record<string, ChannelPerformanceItem> = {};
for (const [channel, data] of Object.entries(channelMap)) {
const vCount = data.visitors.size;
const lCount = data.leads.size;
const mCount = data.meetings.size;
const rate = vCount > 0 ? Number((lCount / vCount).toFixed(2)) : 0;
report[channel] = {
channel,
visitors: vCount,
leads: lCount,
leadRate: rate,
meetings: mCount,
};
}
return report;
}
}