feat(round-12): 实体卡外贸生产工单/雷达多渠道归因漏斗/AI地道外联草拟/现代图片自适应协商 (87套件1007测试全绿)
这个提交包含在:
@@ -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<string, string> = {
|
||||
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<OutreachDraftResponse> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
@@ -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<ManufacturingBatchStatus, ManufacturingBatchStatus[]> = {
|
||||
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<string, ManufacturingBatchRecord>();
|
||||
|
||||
/**
|
||||
* 创建生产批次工单
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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); // 移动端短消息克制简洁
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户