213 lines
9.1 KiB
JavaScript
213 lines
9.1 KiB
JavaScript
'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 };
|