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;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户