platform-assistant/apps/api/src/lead/lead.service.ts
Sisyphus 4ab0040e55 feat(F002-P1): Lead 加 notes 字段 + crawler 提取 PDF 6 条要点 (老 GUI 同款)
- 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/
2026-06-24 11:52:59 +08:00

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 []
}
}
}