'use strict'; const { ToolError } = require('./errors'); const { attachChatToOpportunity } = require('./chat-log'); const STAGES = new Set(['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER']); // 对齐旧 bridge leadPipeline:指纹去重、跨渠道复用/同步、阶段推进、CRM 故障 fail-closed。 function createOpportunityService({ cfg, twenty, leadStore, client }) { function splitName(full) { const parts = String(full || '').trim().split(/[\s.]+/).filter(Boolean); if (parts.length >= 2) return { firstName: parts[0], lastName: parts.slice(1).join(' ') }; return { firstName: parts[0] || '', lastName: '' }; } function slotFingerprint(slots) { return ['company', 'productNeed', 'quantity', 'budget', 'email', 'closeDate'] .map((k) => String(slots[k] || '').trim().toLowerCase()) .join('|'); } function ready(slots) { return Boolean(slots.company && (slots.contactName || slots.email || slots.phone) && slots.productNeed); } function stillNeeded(slots) { const out = []; if (!slots.company) out.push('company'); if (!slots.contactName && !slots.email && !slots.phone) out.push('contact'); if (!slots.productNeed) out.push('productNeed'); return out; } async function persist(state) { if (leadStore.saveState) return leadStore.saveState(state.conversationUuid, state); return leadStore.save(state.conversationUuid, state.slots); } async function findExisting(slots) { if (!slots.email) return null; const personId = await twenty.findPersonByEmail(slots.email); if (!personId) return null; const opps = await twenty.findOpportunitiesByPersonId(personId); if (!opps || !opps.length) return null; opps.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); const needWords = String(slots.productNeed || '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter((w) => w.length > 1); if (needWords.length) { for (const opp of opps) { const text = `${opp.name || ''} ${opp.stage || ''}`.toLowerCase(); const overlap = needWords.filter((w) => text.includes(w)).length; if (overlap >= Math.min(2, needWords.length)) return opp; } } return opps[0]; } function summaryFor({ ctx, slots, email, profile }) { return [ `Company: ${slots.company}`, `Contact: ${slots.contactName || '(not provided)'}${email ? ` <${email}>` : ''}`, `Need: ${slots.productNeed}`, slots.quantity ? `Quantity: ${slots.quantity}` : null, slots.budget ? `Budget: ${slots.budget}` : null, slots.closeDate ? `Expected close: ${slots.closeDate}` : null, `Site: ${profile.site}${profile.tenantId != null ? ` (tenant ${profile.tenantId})` : ''}`, `Channel: LibreDesk inbox ${ctx.inboxId || 'n/a'} / conversation ${ctx.conversationUuid || 'n/a'}`, ].filter(Boolean).join('\n'); } function nextStage(stage, slots, email) { const hasContact = Boolean(email || slots.phone || slots.email); if (stage === 'NEW') { if (slots.quantity && slots.budget) return 'MEETING'; if (slots.productNeed && hasContact) return 'SCREENING'; } if (stage === 'SCREENING') { if (slots.budget) return 'PROPOSAL'; if (slots.quantity) return 'MEETING'; } return null; } async function advanceStage({ ctx, state, slots, email }) { if (!state.opportunityId || !twenty.updateOpportunityStage) return null; const target = nextStage(String(state.stage || 'NEW').toUpperCase(), slots, email); if (!target || !STAGES.has(target)) return null; try { await twenty.updateOpportunityStage(state.opportunityId, target); state.stage = target; await persist(state); console.log(JSON.stringify({ ts: new Date().toISOString(), crm: 'stage_advanced', conversation: ctx.conversationUuid, opportunity: state.opportunityId, stage: target })); return target; } catch (err) { console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `stage advance failed: ${err.message}` })); return null; } } async function createOrReuse({ ctx, argSlots, email, profile }) { const conversationUuid = String(ctx.conversationUuid || ''); const state = (await leadStore.get(conversationUuid)) || { conversationUuid, slots: {}, createdFingerprints: [], opportunityId: null, stage: 'NEW', }; state.conversationUuid = conversationUuid; state.slots = { ...(state.slots || {}), ...(argSlots || {}) }; const slots = state.slots; if (!ready(slots)) throw new ToolError(400, 'invalid_arguments', `Missing lead fields: ${stillNeeded(slots).join(', ')}`); const fingerprint = slotFingerprint({ ...slots, email: slots.email || email }); state.createdFingerprints = Array.isArray(state.createdFingerprints) ? state.createdFingerprints : []; if (state.createdFingerprints.includes(fingerprint)) { return { created: false, reason: 'already_created', fingerprint, opportunity_id: state.opportunityId, stage: state.stage }; } // CRM 查询失败时 fail-closed:不创建,避免去重被绕过产生重复商机。 let existing = null; try { existing = await findExisting({ ...slots, email: slots.email || email }); } catch (err) { console.error(JSON.stringify({ ts: new Date().toISOString(), crm: 'lookup_failed', conversation: conversationUuid, error: err.message })); return { created: false, reason: 'crm_unavailable', error: err.message, message: 'CRM temporarily unavailable; no new opportunity was created.' }; } const summary = summaryFor({ ctx, slots, email: slots.email || email, profile }); if (existing) { // 跨渠道复用:同步后补字段 + 把聊天摘要挂到已有商机 state.opportunityId = existing.id; state.stage = existing.stage || 'NEW'; state.createdFingerprints.push(fingerprint); await persist(state); const patch = {}; if (slots.closeDate) patch.closeDate = slots.closeDate; const budget = Number(String(slots.budget || '').replace(/[^\d.]/g, '')); if (Number.isFinite(budget) && budget > 0) patch.amount = budget; if (twenty.updateOpportunity && Object.keys(patch).length) { await twenty.updateOpportunity(existing.id, patch).catch(() => {}); } const noteId = await twenty.createNote(summary); if (noteId) await twenty.linkNoteToOpportunity(noteId, existing.id); setImmediate(() => attachChatToOpportunity({ client, twenty, convUuid: conversationUuid, opportunityId: existing.id }).catch(() => {})); const stage = await advanceStage({ ctx, state, slots, email: slots.email || email }); return { created: false, reason: 'cross_channel_existing', opportunity_id: existing.id, opportunity_name: existing.name, stage: stage || state.stage, note_id: noteId, message: 'Existing opportunity reused; chat summary attached.', }; } // 创建:公司 → 联系人(关联公司)→ 商机 → 备注 → 任务 let companyId = await twenty.findCompanyByName(slots.company); if (!companyId) { const createdCompany = await twenty.createCompany(slots.company); companyId = createdCompany && createdCompany.id; } let personId = await twenty.findPersonByEmail(slots.email || email); if (!personId) { const name = splitName(slots.contactName); const person = await twenty.createPerson({ ...name, email: slots.email || email, phone: slots.phone || null, companyId }); personId = person && person.id; } const budget = Number(String(slots.budget || '').replace(/[^\d.]/g, '')); const closeDate = /^\d{4}-\d{2}-\d{2}$/.test(String(slots.closeDate || '')) ? slots.closeDate : null; const opp = await twenty.createOpportunity({ name: `${slots.company} — ${slots.productNeed}`, companyId, personId, amount: Number.isFinite(budget) && budget > 0 ? budget : null, closeDate, stage: 'NEW', conversationId: conversationUuid, }); if (!opp || !opp.id) throw new ToolError(502, 'crm_create_failed', 'Twenty did not return an opportunity id'); state.opportunityId = opp.id; state.stage = 'NEW'; state.createdFingerprints.push(fingerprint); await persist(state); const noteId = await twenty.createNote(summary); if (noteId) await twenty.linkNoteToOpportunity(noteId, opp.id); const dueAt = new Date(Date.now() + 3 * 86400 * 1000).toISOString(); const taskId = await twenty.createTask(`Follow up: ${slots.company}`, summary, dueAt, opp.id); setImmediate(() => attachChatToOpportunity({ client, twenty, convUuid: conversationUuid, opportunityId: opp.id }).catch(() => {})); const stage = await advanceStage({ ctx, state, slots, email: slots.email || email }); return { created: true, opportunity_id: opp.id, opportunity_name: opp.name, company_id: companyId, person_id: personId, note_id: noteId, task_id: taskId, task_due_at: dueAt, stage: stage || 'NEW', fingerprint, }; } return { createOrReuse, advanceStage, slotFingerprint, findExisting, nextStage }; } module.exports = { createOpportunityService };