- Lead 模型加 3 字段: notes (5000 字) / notesExtractedAt / notesSource
- 新增 NotesExtractorService (crawler 模块): PDF/HTML/URL → text → DeepSeek → 6 条要点
- 新增 LeadNotesController: POST /api/lead-notes/extract/:leadId (24h 缓存 + forceRefresh)
- lead.service.ts publishToDirev 优先用 notes 填 publishArgs.description + 同步传 notes
- Java publish() 已有逻辑: notes 优先 → description, 不需要改
- 端到端验证: Lead.notes=6 条要点 → publishToDirev → B2B tb_inquiry.note 正确落地
依赖: pdf-parse (新加, 1 个 npm 包, 25KB)
API 端点:
- POST /api/lead-notes/extract/:leadId?sourcePath=<URL|路径>&forceRefresh=false
- 返 { notes: string, source: 'pdf'|'html'|'url', cached: boolean }
环境变量: DEEPSEEK_API_KEY (从 .env 读, 缺时报错)
关联: F002-W31-交接文档.md §4 P1
本地备份: ~/projects/_meta/f002-fix/w31-platform-assistant-2026-06-23/
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common'
|
|
import { PrismaService } from '../prisma/prisma.service'
|
|
import { IntegrationService } from '../integration/integration.service'
|
|
|
|
@Injectable()
|
|
export class LeadService {
|
|
private readonly logger = new Logger(LeadService.name)
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private integrationService: IntegrationService,
|
|
) {}
|
|
|
|
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string }) {
|
|
const where: any = {}
|
|
if (params?.status) where.status = params.status
|
|
if (params?.level) where.level = params.level
|
|
if (params?.search) {
|
|
where.OR = [
|
|
{ title: { contains: params.search } },
|
|
{ organization: { contains: params.search } },
|
|
{ region: { contains: params.search } },
|
|
]
|
|
}
|
|
if (params?.star) {
|
|
const starVal = parseInt(params.star, 10)
|
|
if (params.star === '2星及以下') where.star = { lte: 2 }
|
|
else if (!isNaN(starVal)) where.star = starVal
|
|
}
|
|
if (params?.source) where.source = params.source
|
|
if (params?.category) where.category = params.category
|
|
if (params?.assignedTo) {
|
|
where.assignedTo = params.assignedTo === '未分配' ? null : params.assignedTo
|
|
}
|
|
return this.prisma.lead.findMany({
|
|
where,
|
|
orderBy: [
|
|
{ level: 'asc' },
|
|
{ createdAt: 'desc' },
|
|
],
|
|
})
|
|
}
|
|
|
|
async findOne(id: string) {
|
|
const lead = await this.prisma.lead.findUnique({ where: { id } })
|
|
if (!lead) throw new NotFoundException('线索不存在')
|
|
return lead
|
|
}
|
|
|
|
async create(data: any) {
|
|
if (!data.aiScore && data.title) {
|
|
data.aiScore = this.calculateAIScore(data)
|
|
}
|
|
return this.prisma.lead.create({ data })
|
|
}
|
|
|
|
async update(id: string, data: any) {
|
|
await this.findOne(id)
|
|
return this.prisma.lead.update({ where: { id }, data })
|
|
}
|
|
|
|
async remove(id: string) {
|
|
await this.findOne(id)
|
|
return this.prisma.lead.delete({ where: { id } })
|
|
}
|
|
|
|
async getStats() {
|
|
const leads = await this.prisma.lead.findMany()
|
|
return {
|
|
total: leads.length,
|
|
newToday: leads.filter(l => l.createdAt >= new Date(Date.now() - 86400000)).length,
|
|
byStatus: {
|
|
captured: leads.filter(l => l.status === '已抓取').length,
|
|
analyzed: leads.filter(l => l.status === '已研判').length,
|
|
pendingReview: leads.filter(l => l.status === '待复核').length,
|
|
pendingFollow: leads.filter(l => l.status === '待跟进').length,
|
|
following: leads.filter(l => l.status === '跟进中').length,
|
|
toRFQ: leads.filter(l => l.status === '已转询价').length,
|
|
toCollab: leads.filter(l => l.status === '已转协同').length,
|
|
success: leads.filter(l => l.status === '已成交').length,
|
|
invalid: leads.filter(l => l.status === '无效').length,
|
|
},
|
|
byLevel: {
|
|
s: leads.filter(l => l.level === 'S').length,
|
|
a: leads.filter(l => l.level === 'A').length,
|
|
b: leads.filter(l => l.level === 'B').length,
|
|
c: leads.filter(l => l.level === 'C').length,
|
|
d: leads.filter(l => l.level === 'D').length,
|
|
},
|
|
published: leads.filter(l => l.publish === '已发布').length,
|
|
notPublished: leads.filter(l => l.publish !== '已发布').length,
|
|
}
|
|
}
|
|
|
|
async toRFQ(id: string, assignee: string, note: string) {
|
|
const lead = await this.findOne(id)
|
|
const now = new Date()
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
|
|
const b2bIntegration = await this.integrationService.findActiveMCP()
|
|
|
|
let rfqRemoteId: string | null = null
|
|
let mcpLogExtra = ''
|
|
let mcpStatus: 'mcp_success' | 'mcp_failed' | 'local_only' = 'local_only'
|
|
|
|
if (b2bIntegration) {
|
|
const result = await this.integrationService.executeTool<any>(
|
|
b2bIntegration.id,
|
|
'publish_rfq',
|
|
{
|
|
title: lead.title || '平台助手转询价',
|
|
description: lead.summary || lead.title || '',
|
|
category_id: 1,
|
|
qty: 1,
|
|
unit: '件',
|
|
deadline: lead.deadline || '2026-12-31',
|
|
attachments: [],
|
|
},
|
|
{ retries: 2, retryDelayMs: 1000 },
|
|
)
|
|
|
|
if (result.success && result.data) {
|
|
const data = result.data as any
|
|
rfqRemoteId = data.rfqId || data.id || null
|
|
mcpStatus = 'mcp_success'
|
|
mcpLogExtra = ` [MCP 成功,远程询价 ID=${rfqRemoteId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]`
|
|
this.logger.log(`toRFQ MCP 成功: leadId=${id}, rfqId=${rfqRemoteId}`)
|
|
} else {
|
|
mcpStatus = 'mcp_failed'
|
|
mcpLogExtra = ` [MCP 失败,降级本地模式: ${result.error || '未知错误'},尝试 ${result.attempts} 次]`
|
|
this.logger.warn(`toRFQ MCP 失败: leadId=${id}, error=${result.error}`)
|
|
}
|
|
} else {
|
|
mcpLogExtra = ' [本地模式,未配置 B2B 集成]'
|
|
}
|
|
|
|
logs.unshift({
|
|
date: now.toISOString(),
|
|
content: `已转询价——询价单已创建,指派给 ${assignee},备注:${note}${mcpLogExtra}`,
|
|
operator: '系统',
|
|
mcpStatus,
|
|
})
|
|
|
|
return this.prisma.lead.update({
|
|
where: { id },
|
|
data: {
|
|
status: '已转询价',
|
|
assignedTo: assignee,
|
|
followLogs: JSON.stringify(logs),
|
|
...(rfqRemoteId && { inquiryNo: rfqRemoteId }),
|
|
},
|
|
})
|
|
}
|
|
|
|
async toCollab(id: string, assignee: string, note: string) {
|
|
const lead = await this.findOne(id)
|
|
const now = new Date()
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
|
|
const b2bIntegration = await this.integrationService.findActiveMCP()
|
|
|
|
let collabRemoteId: string | null = null
|
|
let mcpLogExtra = ''
|
|
let mcpStatus: 'mcp_success' | 'mcp_failed' | 'local_only' = 'local_only'
|
|
|
|
if (b2bIntegration) {
|
|
const result = await this.integrationService.executeTool<any>(
|
|
b2bIntegration.id,
|
|
'create_collab_from_lead',
|
|
{
|
|
leadId: lead.id,
|
|
title: lead.title || '平台助手转协同',
|
|
description: lead.summary || lead.title || '',
|
|
organization: lead.organization || '',
|
|
region: lead.region || '',
|
|
assignee,
|
|
note,
|
|
},
|
|
{ retries: 2, retryDelayMs: 1000 },
|
|
)
|
|
|
|
if (result.success && result.data) {
|
|
const data = result.data as any
|
|
collabRemoteId = data.collabId || data.id || null
|
|
mcpStatus = 'mcp_success'
|
|
mcpLogExtra = ` [MCP 成功,远程协同 ID=${collabRemoteId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]`
|
|
this.logger.log(`toCollab MCP 成功: leadId=${id}, collabId=${collabRemoteId}`)
|
|
} else {
|
|
mcpStatus = 'mcp_failed'
|
|
mcpLogExtra = ` [MCP 失败,降级本地模式: ${result.error || '未知错误'},尝试 ${result.attempts} 次]`
|
|
this.logger.warn(`toCollab MCP 失败: leadId=${id}, error=${result.error}`)
|
|
}
|
|
} else {
|
|
mcpLogExtra = ' [本地模式,未配置 B2B 集成]'
|
|
}
|
|
|
|
logs.unshift({
|
|
date: now.toISOString(),
|
|
content: `已转协同——协同任务已创建,指派给 ${assignee},备注:${note}${mcpLogExtra}`,
|
|
operator: '系统',
|
|
mcpStatus,
|
|
})
|
|
|
|
return this.prisma.lead.update({
|
|
where: { id },
|
|
data: {
|
|
status: '已转协同',
|
|
assignedTo: assignee,
|
|
followLogs: JSON.stringify(logs),
|
|
...(collabRemoteId && { collabId: collabRemoteId }),
|
|
},
|
|
})
|
|
}
|
|
|
|
async publishToDirev(id: string) {
|
|
const lead = await this.findOne(id)
|
|
const now = new Date()
|
|
|
|
const b2bIntegration = await this.integrationService.findActiveMCP()
|
|
|
|
if (!b2bIntegration) {
|
|
throw new ServiceUnavailableException(
|
|
'未配置 B2B 集成(运营管理 → 系统配置 → MCP 对接配置)',
|
|
)
|
|
}
|
|
|
|
const publishArgs: any = {
|
|
title: lead.title || '平台助手发布询价',
|
|
// W31 P1: 优先用 notes (老 GUI 6 条商务要点) 作为 description
|
|
// 备选 summary (来源/公告ID/链接 3 行), 最后 fallback title
|
|
description: lead.notes || lead.summary || lead.title || '',
|
|
qty: lead.quantity || 1,
|
|
// W31 P1: 同步把 notes 传过去 (B2B note 字段会用它, 商家报价时看到)
|
|
notes: lead.notes || undefined,
|
|
}
|
|
|
|
if (lead.attachments) {
|
|
try {
|
|
const parsedAttachments = JSON.parse(lead.attachments)
|
|
if (Array.isArray(parsedAttachments) && parsedAttachments.length > 0) {
|
|
publishArgs.attachments = parsedAttachments
|
|
}
|
|
} catch {
|
|
if (lead.detailUrl) publishArgs.attachments = [lead.detailUrl]
|
|
}
|
|
} else if (lead.detailUrl) {
|
|
publishArgs.attachments = [lead.detailUrl]
|
|
}
|
|
|
|
if (lead.deadline) {
|
|
const deadlineStr = String(lead.deadline).slice(0, 10)
|
|
publishArgs.deadline = deadlineStr
|
|
const d = new Date(deadlineStr)
|
|
d.setDate(d.getDate() + 30)
|
|
publishArgs.deliveryDate = d.toISOString().slice(0, 10)
|
|
}
|
|
if (lead.regionDetail) publishArgs.shippingAddress = lead.regionDetail
|
|
else if (lead.region) publishArgs.shippingAddress = lead.region
|
|
|
|
if (lead.contact) publishArgs.contactName = lead.contact
|
|
if (lead.phone) publishArgs.contactPhone = lead.phone
|
|
|
|
publishArgs.inquiryParty = '郑州巨变网络科技有限公司'
|
|
publishArgs.freightFlag = '含运费'
|
|
publishArgs.procurementType = '定量采购'
|
|
publishArgs.invoiceType = '增值税专票'
|
|
publishArgs.paymentMethod = '货到付款'
|
|
|
|
// Auto-fill enterprise identity from local Integration config
|
|
if (b2bIntegration.companyUserId) {
|
|
publishArgs.userId = b2bIntegration.companyUserId
|
|
publishArgs.enterpriseId = b2bIntegration.companyUserId
|
|
publishArgs.enterpriseName = `${b2bIntegration.name}公司`
|
|
}
|
|
|
|
const result = await this.integrationService.executeTool<any>(
|
|
b2bIntegration.id,
|
|
'publish_rfq',
|
|
publishArgs,
|
|
{ retries: 2, retryDelayMs: 1000 },
|
|
)
|
|
|
|
if (!result.success || !result.data) {
|
|
const errorMsg = result.error || '未知错误'
|
|
this.logger.error(
|
|
`MCP 发布失败 (leadId=${id}, attempts=${result.attempts}): ${errorMsg}`,
|
|
)
|
|
throw new ServiceUnavailableException(
|
|
`发布失败: B2B 平台未收到请求。原因: ${errorMsg}。请检查运营管理 → 系统配置 → MCP 对接配置(密钥/状态)`,
|
|
)
|
|
}
|
|
|
|
const data = result.data as any
|
|
// MCP 返回的 content[0].text 解析后是 {code, msg, data: {orderId, rfqId, status}}
|
|
// 业务对象嵌在 data.data 里(外层是 B2B 通用 Result 包装)
|
|
const payload = data.data || data
|
|
const rfqId = payload.rfqId || payload.id || null
|
|
if (!rfqId) {
|
|
throw new ServiceUnavailableException(
|
|
`发布失败: B2B 平台返回了响应但未包含询价 ID。响应: ${JSON.stringify(payload).slice(0, 200)}`,
|
|
)
|
|
}
|
|
const inquiryNo = payload.orderId || rfqId
|
|
const direvUrl = `${b2bIntegration.endpoint}/inquiry/${rfqId}`
|
|
const mcpStatus = 'mcp_success'
|
|
const mcpLogExtra = ` [MCP 成功,远程询价 ID=${rfqId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]`
|
|
|
|
this.logger.log(`询价发布成功: leadId=${id}, rfqId=${rfqId}, orderId=${inquiryNo}`)
|
|
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
logs.unshift({
|
|
date: now.toISOString(),
|
|
content: `一键发布——已发布到 B2B 平台,询价 ID: ${rfqId}${mcpLogExtra}`,
|
|
operator: '系统',
|
|
mcpStatus,
|
|
})
|
|
|
|
return this.prisma.lead.update({
|
|
where: { id },
|
|
data: {
|
|
publish: '已发布',
|
|
publishedAt: now,
|
|
inquiryNo,
|
|
direvUrl,
|
|
followLogs: JSON.stringify(logs),
|
|
},
|
|
})
|
|
}
|
|
|
|
async batchPublish(ids: string[]) {
|
|
const results: any[] = []
|
|
for (const id of ids) {
|
|
try {
|
|
const lead = await this.publishToDirev(id)
|
|
results.push({ id, success: true, inquiryNo: lead.inquiryNo })
|
|
} catch (e: any) {
|
|
results.push({ id, success: false, error: e.message })
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
async addFollowUp(id: string, content: string, operator: string, action?: string) {
|
|
const lead = await this.findOne(id)
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
const logContent = action ? `${action}——${content}` : content
|
|
logs.unshift({
|
|
date: new Date().toISOString(),
|
|
content: logContent,
|
|
operator: operator || '用户',
|
|
})
|
|
const updateData: any = { followLogs: JSON.stringify(logs) }
|
|
if (lead.status === '待复核' || lead.status === '待跟进') {
|
|
updateData.status = '跟进中'
|
|
}
|
|
return this.prisma.lead.update({ where: { id }, data: updateData })
|
|
}
|
|
|
|
async deleteFollowUp(id: string, index: number) {
|
|
const lead = await this.findOne(id)
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
if (index >= 0 && index < logs.length) {
|
|
logs.splice(index, 1)
|
|
return this.prisma.lead.update({
|
|
where: { id },
|
|
data: { followLogs: JSON.stringify(logs) },
|
|
})
|
|
}
|
|
return lead
|
|
}
|
|
|
|
async updateLevel(id: string, level: string) {
|
|
return this.update(id, { level })
|
|
}
|
|
|
|
async followIntent(id: string) {
|
|
const lead = await this.findOne(id)
|
|
if (lead.status !== '待复核') throw new Error('只有待复核状态的线索可以提交跟进意向')
|
|
const logs = this.parseFollowLogs(lead.followLogs)
|
|
logs.unshift({
|
|
date: new Date().toISOString(),
|
|
content: '提交跟进意向——确认值得跟进,状态推进至待跟进',
|
|
operator: '系统',
|
|
})
|
|
return this.prisma.lead.update({
|
|
where: { id },
|
|
data: { status: '待跟进', followLogs: JSON.stringify(logs) },
|
|
})
|
|
}
|
|
|
|
calculateAIScore(data: any): number {
|
|
let score = 0
|
|
if (data.level === 'S') score += 20
|
|
else if (data.level === 'A') score += 10
|
|
const prefTypes = ['水利', '南水北调', '引水', '河道治理', '电力', '输变电']
|
|
if (data.summary && prefTypes.some(t => data.summary.includes(t))) score += 10
|
|
const coreCategories = ['防水', '涂料', '钢材', '水泥', '电缆']
|
|
if (data.category && coreCategories.some(c => data.category.includes(c))) score += 15
|
|
if (data.amount && data.amount !== '待确认') score += 5
|
|
return Math.min(score / 10, 10)
|
|
}
|
|
|
|
private parseFollowLogs(logs: string | null): any[] {
|
|
if (!logs) return []
|
|
try {
|
|
return JSON.parse(logs)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
}
|