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