chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
'use strict';
const { ToolError } = require('./errors');
const STAGES = new Set(['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER']);
// 对齐旧 bridge leadPipeline:指纹去重、跨渠道复用/同步、阶段推进、CRM 故障 fail-closed。
function createOpportunityService({ cfg, twenty, leadStore }) {
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}>`,
`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);
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);
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 };