diff --git a/backend/src/modules/ai/service/outreach.ts b/backend/src/modules/ai/service/outreach.ts new file mode 100644 index 0000000..eab7abf --- /dev/null +++ b/backend/src/modules/ai/service/outreach.ts @@ -0,0 +1,111 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export interface OutreachDraftRequest { + leadName: string; + company?: string; + targetLanguage: string; // 'en' | 'ar' | 'es' | 'zh' | 'fr' | 'ja' + interestTopics?: string[]; + calendarUrl?: string; + senderName?: string; + senderTitle?: string; + tone?: 'professional' | 'friendly' | 'respectful'; + channel?: 'email' | 'whatsapp' | 'linkedin'; +} + +export interface OutreachDraftResponse { + channel: 'email' | 'whatsapp' | 'linkedin'; + subject?: string; + body: string; + targetLanguage: string; + generatedAt: number; +} + +/** + * 组装出海销售跟进提示词 + */ +export function formatOutreachPrompt(req: OutreachDraftRequest): string { + const langCode = (req.targetLanguage || 'en').toLowerCase(); + const langMap: Record = { + ar: 'Arabic', + en: 'English', + es: 'Spanish', + fr: 'French', + ja: 'Japanese', + zh: 'Chinese', + }; + const lang = langMap[langCode] || langCode; + const topics = req.interestTopics?.join(', ') || 'Smart Digital Cards & Mobile Portal'; + const tone = req.tone || 'professional'; + + return `Task: Draft a high-converting B2B outreach message. +Target Language: ${lang} (${langCode}) +Prospect Name: ${req.leadName} +Prospect Company: ${req.company || 'Their Esteemed Firm'} +Topics of Interest: ${topics} +Tone: ${tone} +Channel: ${req.channel || 'email'} +Call to Action: Include meeting booking link if available: ${req.calendarUrl || 'Available on request'} +Guidelines: +1. Swiss internationalist style: concise, credible, no hype, no emojis. +2. Maximize relevance to their browsing interests.`; +} + +/** + * AI 销售潜客智能跟进邮件/消息地道草拟引擎 + */ +@Provide() +export class AiOutreachDraftService extends BaseService { + /** + * 生成地道跟进草稿 + */ + async generateDraft(req: OutreachDraftRequest): Promise { + const channel = req.channel || 'email'; + const lang = (req.targetLanguage || 'en').toLowerCase(); + const sender = req.senderName || 'OmniCard Team'; + const topics = req.interestTopics && req.interestTopics.length > 0 + ? req.interestTopics.join(', ') + : 'OmniCard AI'; + + if (channel === 'whatsapp') { + let body = ''; + if (lang === 'es') { + body = `Hola ${req.leadName}, un gusto saludarte. Vi tu interés en ${topics} en nuestra web. ¿Te gustaría agendar una breve llamada de 10 min? Saludos, ${sender}.`; + } else if (lang === 'ar') { + body = `مرحباً ${req.leadName}، تشرفنا باهتمامكم بـ ${topics}. يسعدنا ترتيب محادثة سريعة لمناقشة التفاصيل. تحياتي، ${sender}.`; + } else { + body = `Hi ${req.leadName}, great connecting. Noticed your interest in ${topics}. Would love to share a 10-min overview this week. Best, ${sender}.`; + } + + return { + channel: 'whatsapp', + body, + targetLanguage: lang, + generatedAt: Date.now(), + }; + } + + // Default Email + let subject = ''; + let body = ''; + + if (lang === 'ar') { + subject = `متابعة حلول ${topics} لشركة ${req.company || ''}`; + body = `عزيزي/عزيزتي ${req.leadName}،\n\nتحية طيبة وبعد،\n\nيسعدنا تواصلكم واهتمامكم بـ ${topics}. نود تقديم عرض مخصص يلبي تطلعات شركتكم الموقرة ${req.company || ''}.\n\nيمكنكم حجز موعد مناسب عبر الرابط:\n${req.calendarUrl || 'https://omnicard.ai/book'}\n\nوتفضلوا بقبول فائق الاحترام،\n${sender}\n${req.senderTitle || ''}`; + } else if (lang === 'es') { + subject = `Seguimiento de ${topics} para ${req.company || req.leadName}`; + body = `Estimado/a ${req.leadName},\n\nEspero que se encuentre bien. Me pongo en contacto tras notar su interés en ${topics}.\n\nNos encantaría explorar cómo nuestra solución puede potenciar las operaciones de ${req.company || 'su empresa'}.\n\nPuede seleccionar un horario conveniente aquí:\n${req.calendarUrl || 'https://omnicard.ai/book'}\n\nAtentamente,\n${sender}\n${req.senderTitle || ''}`; + } else { + subject = `Following up: ${topics} for ${req.company || req.leadName}`; + body = `Hi ${req.leadName},\n\nI hope this note finds you well.\n\nI noticed you explored our ${topics} at ${req.company || 'your organization'}. We have helped leading global trade teams streamline their international networking and lead conversion.\n\nFeel free to choose a time that suits your schedule:\n${req.calendarUrl || 'https://omnicard.ai/book'}\n\nBest regards,\n${sender}\n${req.senderTitle || ''}`; + } + + return { + channel: 'email', + subject, + body, + targetLanguage: lang, + generatedAt: Date.now(), + }; + } +} diff --git a/backend/src/modules/card/service/media-negotiator.ts b/backend/src/modules/card/service/media-negotiator.ts new file mode 100644 index 0000000..5f5444c --- /dev/null +++ b/backend/src/modules/card/service/media-negotiator.ts @@ -0,0 +1,91 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type SupportedImageFormat = 'avif' | 'webp' | 'png' | 'jpg'; + +export interface MediaNegotiationContext { + acceptHeader?: string; + networkType?: 'wifi' | '5g' | '4g' | '3g' | '2g' | 'unknown' | string; + targetWidth?: number; + dpr?: number; +} + +export interface OptimizedMediaResult { + format: SupportedImageFormat; + width?: number; + quality: number; + optimizedUrl: string; +} + +/** + * 协商最佳媒体交付 URL 与压缩参数 + */ +export function negotiateOptimalMediaUrl( + url: string, + ctx: MediaNegotiationContext +): OptimizedMediaResult { + if (!url || typeof url !== 'string' || !url.trim()) { + return { + format: 'jpg', + quality: 80, + optimizedUrl: '', + }; + } + + const accept = (ctx.acceptHeader || '').toLowerCase(); + const net = (ctx.networkType || 'wifi').toLowerCase(); + const isSlowNetwork = net === '2g' || net === '3g'; + + // 1. 协商格式: avif > webp > 原图扩展名(png/jpg) + let format: SupportedImageFormat = 'jpg'; + if (accept.includes('image/avif')) { + format = 'avif'; + } else if (accept.includes('image/webp')) { + format = 'webp'; + } else if (url.toLowerCase().endsWith('.png') || accept.includes('image/png')) { + format = 'png'; + } + + // 2. 协商质量与宽度 + let quality = 85; + if (isSlowNetwork) { + quality = 65; + } + + let width = ctx.targetWidth; + if (width != null && width > 0) { + const dpr = ctx.dpr && ctx.dpr > 1 ? ctx.dpr : 1; + if (isSlowNetwork) { + // 弱网降规 75% + width = Math.round(width * 0.75); + } else { + width = Math.round(width * dpr); + } + } + + // 3. 构造优化 URL 参数 + const separator = url.includes('?') ? '&' : '?'; + const queryParts: string[] = [`format=${format}`, `q=${quality}`]; + if (width != null && width > 0) { + queryParts.push(`w=${width}`); + } + + const optimizedUrl = `${url}${separator}${queryParts.join('&')}`; + + return { + format, + width, + quality, + optimizedUrl, + }; +} + +/** + * 微官网现代图片自适应协商服务 + */ +@Provide() +export class PortalMediaNegotiatorService extends BaseService { + negotiate(url: string, ctx: MediaNegotiationContext): OptimizedMediaResult { + return negotiateOptimalMediaUrl(url, ctx); + } +} diff --git a/backend/src/modules/crm/service/attribution.ts b/backend/src/modules/crm/service/attribution.ts new file mode 100644 index 0000000..e350197 --- /dev/null +++ b/backend/src/modules/crm/service/attribution.ts @@ -0,0 +1,169 @@ +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; + stageRates: Record; + 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(); + const stageSets: Record> = { + 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 = { + 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 = { + 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 { + const channelMap: Record; leads: Set; meetings: Set }> = {}; + + 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 = {}; + 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; + } +} diff --git a/backend/src/modules/nfc/service/manufacturing.ts b/backend/src/modules/nfc/service/manufacturing.ts new file mode 100644 index 0000000..025b3c1 --- /dev/null +++ b/backend/src/modules/nfc/service/manufacturing.ts @@ -0,0 +1,180 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type ManufacturingBatchStatus = + | 'draft' + | 'ordered' + | 'manufacturing' + | 'qa_inspected' + | 'in_stock' + | 'rejected' + | 'cancelled'; + +export interface CreateManufacturingBatchDto { + batchNo: string; + materialType: string; + targetQuantity: number; + factoryName: string; + chipModel: string; + cardFinishing?: string; +} + +export interface ManufacturingBatchRecord extends CreateManufacturingBatchDto { + id: string; + status: ManufacturingBatchStatus; + qualifiedQuantity: number; + defectQuantity: number; + yieldRate?: number; + createdAt: number; + updatedAt: number; +} + +export interface BatchYieldResult { + yieldRate: number; + defectRate: number; + isQualified: boolean; + defectCount: number; +} + +/** + * 生产批次工单生命周期合法转换图 + */ +const ALLOWED_TRANSITIONS: Record = { + draft: ['ordered', 'cancelled'], + ordered: ['manufacturing', 'cancelled'], + manufacturing: ['qa_inspected', 'cancelled'], + qa_inspected: ['in_stock', 'rejected'], + in_stock: [], + rejected: ['manufacturing'], // 返工重制 + cancelled: [], +}; + +/** + * 判定工单状态机流转是否合法 + */ +export function canTransitionBatchStatus( + from: ManufacturingBatchStatus, + to: ManufacturingBatchStatus +): boolean { + const allowed = ALLOWED_TRANSITIONS[from]; + return allowed != null && allowed.includes(to); +} + +/** + * 计算生产良率与报废率 + */ +export function calculateBatchYieldRate( + totalPlanned: number, + qualified: number, + defect: number, + qualificationThreshold = 0.95 +): BatchYieldResult { + if (totalPlanned <= 0) { + throw new Error('计划生产总量必须大于零'); + } + if (qualified < 0 || defect < 0) { + throw new Error('合格数与不良品数不能为负数'); + } + if (qualified > totalPlanned) { + throw new Error('合格品数量不能超出计划生产总量'); + } + + const inspectedTotal = qualified + defect; + if (inspectedTotal > totalPlanned * 1.05) { + // 允许少量备损溢产,超出过多则报错 + throw new Error('质检总数超出合理批次生产总数范围'); + } + + const yieldRate = Number((qualified / totalPlanned).toFixed(3)); + const defectRate = Number((defect / totalPlanned).toFixed(3)); + const isQualified = yieldRate >= qualificationThreshold; + + return { + yieldRate, + defectRate, + isQualified, + defectCount: defect, + }; +} + +/** + * 跨境实体卡供应链生产批次工单管理服务 + */ +@Provide() +export class NfcManufacturingService extends BaseService { + private readonly batches = new Map(); + + /** + * 创建生产批次工单 + */ + createBatch(dto: CreateManufacturingBatchDto): ManufacturingBatchRecord { + if (!dto.batchNo || !dto.targetQuantity || dto.targetQuantity <= 0) { + throw new Error('生产批次号与计划生产数量为必填项'); + } + + const now = Date.now(); + const record: ManufacturingBatchRecord = { + id: `mfg_${now}_${Math.random().toString(36).slice(2, 6)}`, + ...dto, + status: 'draft', + qualifiedQuantity: 0, + defectQuantity: 0, + createdAt: now, + updatedAt: now, + }; + + this.batches.set(dto.batchNo, record); + return record; + } + + /** + * 获取生产工单 + */ + getBatch(batchNo: string): ManufacturingBatchRecord | undefined { + return this.batches.get(batchNo); + } + + /** + * 流转工单生命周期状态 + */ + updateBatchStatus( + batchNo: string, + targetStatus: ManufacturingBatchStatus + ): ManufacturingBatchRecord { + const batch = this.batches.get(batchNo); + if (!batch) { + throw new Error(`未找到批次工单: ${batchNo}`); + } + + if (!canTransitionBatchStatus(batch.status, targetStatus)) { + throw new Error(`非法状态流转: 不能从 ${batch.status} 流转至 ${targetStatus}`); + } + + batch.status = targetStatus; + batch.updatedAt = Date.now(); + return batch; + } + + /** + * 录入出厂质检数据 + */ + recordBatchInspection( + batchNo: string, + qualified: number, + defect: number + ): ManufacturingBatchRecord { + const batch = this.batches.get(batchNo); + if (!batch) { + throw new Error(`未找到批次工单: ${batchNo}`); + } + + const stats = calculateBatchYieldRate(batch.targetQuantity, qualified, defect); + batch.qualifiedQuantity = qualified; + batch.defectQuantity = defect; + batch.yieldRate = stats.yieldRate; + batch.status = 'qa_inspected'; + batch.updatedAt = Date.now(); + + return batch; + } +} diff --git a/backend/test/modules/ai/outreach.test.ts b/backend/test/modules/ai/outreach.test.ts new file mode 100644 index 0000000..db7ee53 --- /dev/null +++ b/backend/test/modules/ai/outreach.test.ts @@ -0,0 +1,82 @@ +import { + AiOutreachDraftService, + OutreachDraftRequest, + formatOutreachPrompt, +} from '../../../src/modules/ai/service/outreach'; + +describe('Point 47: AI 销售潜客智能跟进邮件/消息地道草拟引擎', () => { + let service: AiOutreachDraftService; + + beforeEach(() => { + service = new AiOutreachDraftService(); + }); + + describe('1. formatOutreachPrompt (出海商务邮件地道提示词组装)', () => { + it('英语场景应包含地道商务开场、兴趣针对性提及与日程预约呼吁', () => { + const req: OutreachDraftRequest = { + leadName: 'Arthur Pendelton', + company: 'Global Retail Alliance', + targetLanguage: 'en', + interestTopics: ['Bento Grid Micro-portal', 'Smart NFC Card'], + calendarUrl: 'https://calendar.google.com/render?action=TEMPLATE...', + tone: 'professional', + }; + + const prompt = formatOutreachPrompt(req); + expect(prompt).toContain('Arthur Pendelton'); + expect(prompt).toContain('Global Retail Alliance'); + expect(prompt).toContain('Bento Grid Micro-portal'); + expect(prompt).toContain('professional'); + expect(prompt).toContain('Call to Action'); + }); + + it('阿拉伯语场景应要求符合中东商业礼仪与敬语规范', () => { + const req: OutreachDraftRequest = { + leadName: 'Sheikh Tariq Al-Mansoor', + company: 'Riyadh Holdings', + targetLanguage: 'ar', + interestTopics: ['NFC VIP Cards'], + tone: 'respectful', + }; + + const prompt = formatOutreachPrompt(req); + expect(prompt).toContain('Sheikh Tariq Al-Mansoor'); + expect(prompt).toContain('Arabic'); + expect(prompt).toContain('respectful'); + }); + }); + + describe('2. AiOutreachDraftService 草稿生成与结构化解析', () => { + it('应生成包含邮件主题、正文与预约链接的结构化草案', async () => { + const draft = await service.generateDraft({ + leadName: 'Elena Rostova', + company: 'Nordic Trade Hub', + targetLanguage: 'en', + interestTopics: ['Visitor Radar CRM'], + senderName: 'Alex Zheng', + senderTitle: 'VP of International Sales', + }); + + expect(draft.subject).toBeDefined(); + expect(draft.subject.length).toBeGreaterThan(0); + expect(draft.body).toContain('Elena Rostova'); + expect(draft.body).toContain('Visitor Radar CRM'); + expect(draft.body).toContain('Alex Zheng'); + expect(draft.targetLanguage).toBe('en'); + }); + + it('支持生成 WhatsApp 极简短消息格式 (short_im)', async () => { + const draft = await service.generateDraft({ + leadName: 'Carlos Gomez', + company: 'Madrid Ventures', + targetLanguage: 'es', + channel: 'whatsapp', + senderName: 'Alex', + }); + + expect(draft.channel).toBe('whatsapp'); + expect(draft.body).toContain('Carlos Gomez'); + expect(draft.body.length).toBeLessThan(300); // 移动端短消息克制简洁 + }); + }); +}); diff --git a/backend/test/modules/card/media-negotiator.test.ts b/backend/test/modules/card/media-negotiator.test.ts new file mode 100644 index 0000000..f726291 --- /dev/null +++ b/backend/test/modules/card/media-negotiator.test.ts @@ -0,0 +1,78 @@ +import { + negotiateOptimalMediaUrl, + MediaNegotiationContext, + SupportedImageFormat, +} from '../../../src/modules/card/service/media-negotiator'; + +describe('Point 48: 移动微官网现代图片自适应协商与弱网资源压缩', () => { + const originalUrl = 'https://cdn.omnicard.ai/uploads/cards/vip-avatar.png'; + + describe('1. 格式协商 (Image Format Negotiation: AVIF > WebP > JPEG/PNG)', () => { + it('客户端声明支持 AVIF 时应优先协商为 AVIF 格式', () => { + const ctx: MediaNegotiationContext = { + acceptHeader: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + networkType: '4g', + }; + + const result = negotiateOptimalMediaUrl(originalUrl, ctx); + expect(result.format).toBe('avif'); + expect(result.optimizedUrl).toContain('format=avif'); + }); + + it('客户端不支持 AVIF 但支持 WebP 时应协商为 WebP', () => { + const ctx: MediaNegotiationContext = { + acceptHeader: 'image/webp,*/*', + networkType: 'wifi', + }; + + const result = negotiateOptimalMediaUrl(originalUrl, ctx); + expect(result.format).toBe('webp'); + expect(result.optimizedUrl).toContain('format=webp'); + }); + + it('旧版客户端不包含新现代格式时保留原有格式或转为 jpg', () => { + const ctx: MediaNegotiationContext = { + acceptHeader: 'image/png,image/*,*/*', + }; + + const result = negotiateOptimalMediaUrl(originalUrl, ctx); + expect(result.format).toBe('png'); + expect(result.optimizedUrl).toContain('format=png'); + }); + }); + + describe('2. 网络环境与屏幕自适应 (Network & DPR Adaptive Scaling)', () => { + it('弱网 (2g/3g) 环境应自动降低图片质量与宽度以保障极致加载速度', () => { + const ctx: MediaNegotiationContext = { + acceptHeader: 'image/webp,*/*', + networkType: '3g', + targetWidth: 800, + }; + + const result = negotiateOptimalMediaUrl(originalUrl, ctx); + expect(result.quality).toBeLessThanOrEqual(70); + expect(result.width).toBeLessThanOrEqual(600); + expect(result.optimizedUrl).toContain(`w=${result.width}`); + expect(result.optimizedUrl).toContain(`q=${result.quality}`); + }); + + it('高速 WiFi 且高 DPR (dpr >= 2) 时应保障高清晰度', () => { + const ctx: MediaNegotiationContext = { + acceptHeader: 'image/avif,image/webp', + networkType: 'wifi', + targetWidth: 400, + dpr: 2, + }; + + const result = negotiateOptimalMediaUrl(originalUrl, ctx); + expect(result.width).toBe(800); // 400 * 2 = 800 + expect(result.quality).toBeGreaterThanOrEqual(85); + expect(result.optimizedUrl).toContain('w=800'); + }); + + it('无效或空 URL 时应安全回退', () => { + const res = negotiateOptimalMediaUrl('', {}); + expect(res.optimizedUrl).toBe(''); + }); + }); +}); diff --git a/backend/test/modules/crm/attribution.test.ts b/backend/test/modules/crm/attribution.test.ts new file mode 100644 index 0000000..2ab1303 --- /dev/null +++ b/backend/test/modules/crm/attribution.test.ts @@ -0,0 +1,82 @@ +import { + RadarAttributionService, + normalizeTrafficChannel, + calculateAttributionFunnel, + ChannelAttributionEvent, +} from '../../../src/modules/crm/service/attribution'; + +describe('Point 46: 访客雷达多渠道来源归因与出海转化漏斗分析', () => { + let service: RadarAttributionService; + + beforeEach(() => { + service = new RadarAttributionService(); + }); + + describe('1. 流量渠道归一化解析 (normalizeTrafficChannel)', () => { + it('应精准识别 NFC 碰一碰与展位二维码', () => { + expect(normalizeTrafficChannel('nfc', 'tap')).toBe('nfc_tap'); + expect(normalizeTrafficChannel('booth', 'qrcode')).toBe('booth_qr'); + }); + + it('应精准识别 WhatsApp 与 LinkedIn 社交出海渠道', () => { + expect(normalizeTrafficChannel('whatsapp', 'im')).toBe('whatsapp_chat'); + expect(normalizeTrafficChannel('linkedin', 'social')).toBe('linkedin_share'); + expect(normalizeTrafficChannel('email', 'sig')).toBe('email_signature'); + }); + + it('未知或直接访问归入 direct_or_organic', () => { + expect(normalizeTrafficChannel('', '')).toBe('direct_or_organic'); + expect(normalizeTrafficChannel(undefined, undefined)).toBe('direct_or_organic'); + }); + }); + + describe('2. calculateAttributionFunnel (5阶段留资转化漏斗计算)', () => { + const mockEvents: ChannelAttributionEvent[] = [ + // 访客 1: 全链路完成 + { visitorId: 'v1', channel: 'nfc_tap', stage: 'impression' }, + { visitorId: 'v1', channel: 'nfc_tap', stage: 'interactive' }, + { visitorId: 'v1', channel: 'nfc_tap', stage: 'ai_consult' }, + { visitorId: 'v1', channel: 'nfc_tap', stage: 'lead_submitted' }, + { visitorId: 'v1', channel: 'nfc_tap', stage: 'meeting_booked' }, + + // 访客 2: 停留于 AI 咨询 + { visitorId: 'v2', channel: 'nfc_tap', stage: 'impression' }, + { visitorId: 'v2', channel: 'nfc_tap', stage: 'interactive' }, + { visitorId: 'v2', channel: 'nfc_tap', stage: 'ai_consult' }, + + // 访客 3: WhatsApp 渠道完成留资 + { visitorId: 'v3', channel: 'whatsapp_chat', stage: 'impression' }, + { visitorId: 'v3', channel: 'whatsapp_chat', stage: 'lead_submitted' }, + + // 访客 4: 仅浏览展位 + { visitorId: 'v4', channel: 'booth_qr', stage: 'impression' }, + ]; + + it('应精确统计各漏斗阶段的独立访客数与流失率', () => { + const funnel = calculateAttributionFunnel(mockEvents); + + expect(funnel.totalVisitors).toBe(4); + expect(funnel.stages.impression).toBe(4); + expect(funnel.stages.interactive).toBe(2); + expect(funnel.stages.ai_consult).toBe(2); + expect(funnel.stages.lead_submitted).toBe(2); + expect(funnel.stages.meeting_booked).toBe(1); + + // 留资总体转化率: 2 / 4 = 50% + expect(funnel.overallConversionRate).toBe(0.5); + }); + + it('应支持按渠道拆分多维度漏斗表现', () => { + const breakdown = service.analyzeChannelPerformance(mockEvents); + + expect(breakdown['nfc_tap']).toBeDefined(); + expect(breakdown['nfc_tap'].visitors).toBe(2); + expect(breakdown['nfc_tap'].leads).toBe(1); + expect(breakdown['nfc_tap'].leadRate).toBe(0.5); + + expect(breakdown['whatsapp_chat'].visitors).toBe(1); + expect(breakdown['whatsapp_chat'].leads).toBe(1); + expect(breakdown['whatsapp_chat'].leadRate).toBe(1.0); + }); + }); +}); diff --git a/backend/test/modules/nfc/manufacturing.test.ts b/backend/test/modules/nfc/manufacturing.test.ts new file mode 100644 index 0000000..f037f8e --- /dev/null +++ b/backend/test/modules/nfc/manufacturing.test.ts @@ -0,0 +1,104 @@ +import { + NfcManufacturingService, + ManufacturingBatchStatus, + CreateManufacturingBatchDto, + calculateBatchYieldRate, + canTransitionBatchStatus, +} from '../../../src/modules/nfc/service/manufacturing'; + +describe('Point 45: 跨境实体卡供应链生产批次工单生命周期与良率检验', () => { + let service: NfcManufacturingService; + + beforeEach(() => { + service = new NfcManufacturingService(); + }); + + describe('1. 生产工单状态机流转规则 (canTransitionBatchStatus)', () => { + it('应允许合法正向生命周期流转: draft -> ordered -> manufacturing -> qa_inspected -> in_stock', () => { + expect(canTransitionBatchStatus('draft', 'ordered')).toBe(true); + expect(canTransitionBatchStatus('ordered', 'manufacturing')).toBe(true); + expect(canTransitionBatchStatus('manufacturing', 'qa_inspected')).toBe(true); + expect(canTransitionBatchStatus('qa_inspected', 'in_stock')).toBe(true); + }); + + it('质检不合格可流转为 rejected 废品或返工', () => { + expect(canTransitionBatchStatus('qa_inspected', 'rejected')).toBe(true); + expect(canTransitionBatchStatus('manufacturing', 'cancelled')).toBe(true); + expect(canTransitionBatchStatus('draft', 'cancelled')).toBe(true); + }); + + it('逆向或跨阶段非法流转应坚决拒绝', () => { + expect(canTransitionBatchStatus('in_stock', 'draft')).toBe(false); + expect(canTransitionBatchStatus('draft', 'in_stock')).toBe(false); + expect(canTransitionBatchStatus('rejected', 'in_stock')).toBe(false); + }); + }); + + describe('2. calculateBatchYieldRate (出厂质检良率与损耗统计)', () => { + it('应精确计算生产良品率 (Yield Rate)', () => { + // 计划生产 1000 张,实际合格 985 张,报废 15 张 + const result = calculateBatchYieldRate(1000, 985, 15); + expect(result.yieldRate).toBe(0.985); + expect(result.defectRate).toBe(0.015); + expect(result.isQualified).toBe(true); // 默认合格线 >= 95% + }); + + it('良率低于阈值 (如 95%) 时应标记为未达标', () => { + const result = calculateBatchYieldRate(1000, 920, 80, 0.95); + expect(result.yieldRate).toBe(0.92); + expect(result.isQualified).toBe(false); + expect(result.defectCount).toBe(80); + }); + + it('非法入参处理 (如数量为负或超过总数) 应安全抛出异常', () => { + expect(() => calculateBatchYieldRate(0, 0, 0)).toThrow(); + expect(() => calculateBatchYieldRate(100, 105, 0)).toThrow(); + expect(() => calculateBatchYieldRate(100, -5, 0)).toThrow(); + }); + }); + + describe('3. NfcManufacturingService 批次工单管理', () => { + it('应能创建生产批次并初始化为 draft 状态', () => { + const batchDto: CreateManufacturingBatchDto = { + batchNo: 'MFG-2026-Q4-001', + materialType: 'MATTE_BLACK_METAL', // 磨砂黑金属卡 + targetQuantity: 500, + factoryName: 'Shenzhen Smart Chip Technology Co.', + chipModel: 'NTAG216', + cardFinishing: 'GOLD_LASER_ENGRAVING', // 激光金雕 + }; + + const batch = service.createBatch(batchDto); + expect(batch.id).toBeDefined(); + expect(batch.batchNo).toBe('MFG-2026-Q4-001'); + expect(batch.status).toBe('draft'); + expect(batch.targetQuantity).toBe(500); + expect(batch.qualifiedQuantity).toBe(0); + }); + + it('更新生产状态并在达到 qa_inspected 时记录质检指标', () => { + const batch = service.createBatch({ + batchNo: 'MFG-2026-Q4-002', + materialType: 'ECO_BAMBOO_WOOD', // 环保竹木卡 + targetQuantity: 200, + factoryName: 'EcoCard Global Ltd.', + chipModel: 'NTAG213', + }); + + // 流转为 ordered -> manufacturing + service.updateBatchStatus(batch.batchNo, 'ordered'); + service.updateBatchStatus(batch.batchNo, 'manufacturing'); + + // 质检录入 + const updated = service.recordBatchInspection(batch.batchNo, 196, 4); + expect(updated.status).toBe('qa_inspected'); + expect(updated.qualifiedQuantity).toBe(196); + expect(updated.defectQuantity).toBe(4); + expect(updated.yieldRate).toBe(0.98); + + // 入库 + const inStock = service.updateBatchStatus(batch.batchNo, 'in_stock'); + expect(inStock.status).toBe('in_stock'); + }); + }); +});