株式会社etika(エティカ)CRMサポートセンターです。
「この商談、似た過去案件はどう進めたっけ?」——商談を進めるとき、過去の受注事例が横に出ていれば提案の精度も見積もりの妥当性も上がります。この記事では、商談詳細画面の関連リストに、過去の受注商談を類似度%つきで自動表示するZoho CRMウィジェットを、Embedded App SDKを使って自作する方法を解説します。標準機能だけでは難しい「レコメンド」を、外部AIに頼らずCRM内で完結させる実装例です。
結論(先に要点)
- ウィジェット種類は「関連リスト(Related List)」を選ぶ
- 表示中の商談と過去の受注商談を 業種0.4 / 金額の近さ0.3 / 案件内容の類似0.3 で重み付けスコア化する
- 日本語の案件名・説明は文字バイグラムのJaccard係数で類似度を測る(形態素解析不要)
完成イメージ
商談詳細を開くと、関連リスト「類似商談」に、過去の受注商談が類似度の高い順で最大5件並びます。各カードには類似度%と「業種一致」「金額帯が近い」「案件内容が類似」といった根拠チップが表示されます。

スコアリングの考え方
「似ている」を数値化するために、3つの観点を重み付けして合算します。
const WEIGHTS = { industry: 0.4, amount: 0.3, keyword: 0.3 };
- 業種(0.4):業種が一致すれば1、違えば0。
- 金額(0.3):
小さい方 ÷ 大きい方で「金額帯の近さ」を0〜1で表す。 - 案件内容(0.3):案件名+説明の文字類似度。
日本語の類似度は文字バイグラムのJaccardで測る
日本語は単語の区切りが自動では分かりません。そこで形態素解析を使わず、2文字ずつのかたまり(バイグラム)の集合の重なり具合をJaccard係数で測ります。
function bigrams(s) {
const t = String(s || "").replace(/[\s、。・()()【】\[\]「」\/||]/g, "");
const set = new Set();
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
return set;
}
function jaccard(a, b) {
if (a.size === 0 || b.size === 0) return 0;
let inter = 0;
for (const x of a) if (b.has(x)) inter++;
return inter / (a.size + b.size - inter);
}
スコア計算の本体
function scoreDeal(cur, d, industryField) {
const indCur = industryField ? cur[industryField] : null;
const indD = industryField ? d[industryField] : null;
const industry = indCur && indD && indCur === indD ? 1 : 0;
const amount = d.Amount && cur.Amount
? Math.min(d.Amount, cur.Amount) / Math.max(d.Amount, cur.Amount) : 0;
const curText = bigrams((cur.Deal_Name || "") + (cur.Description || ""));
const kwRaw = jaccard(curText, bigrams((d.Deal_Name || "") + (d.Description || "")));
const kw = Math.min(kwRaw * 2.5, 1); // Jaccardは小さく出るためスケール補正
const score = WEIGHTS.industry * industry + WEIGHTS.amount * amount + WEIGHTS.keyword * kw;
const reasons = [];
if (industry) reasons.push("業種一致");
if (amount > 0.6) reasons.push("金額帯が近い");
if (kwRaw > 0.12) reasons.push("案件内容が類似");
return { pct: Math.round(score * 100), reasons };
}

Embedded App SDKでデータを取得する
ウィジェットはZOHO.embeddedAppのイベントで、表示中の商談と受注商談を取得します。受注商談は「ステージで検索 → 失敗時は全件取得+フィルタ」の二段構えにしておくと堅牢です。
ZOHO.embeddedApp.on("PageLoad", async (data) => {
const dealId = Array.isArray(data.EntityId) ? data.EntityId[0] : data.EntityId;
const curRes = await ZOHO.CRM.API.getRecord({ Entity: "Deals", RecordID: dealId });
const cur = curRes.data[0];
const won = (await fetchWonDeals()).filter((d) => String(d.id) !== String(dealId));
const industryField = detectIndustryField([cur, ...won]);
const scored = won
.map((d) => ({ deal: d, ...scoreDeal(cur, d, industryField) }))
.sort((a, b) => b.pct - a.pct)
.slice(0, 5); // 上位5件
render(scored);
});
ZOHO.embeddedApp.init();
async function fetchWonDeals() {
try {
const res = await ZOHO.CRM.API.searchRecord({
Entity: "Deals", Type: "criteria", Query: `(Stage:equals:受注)`,
});
if (res && res.data) return res.data;
} catch (e) { /* 検索が使えない環境ではフォールバック */ }
const res = await ZOHO.CRM.API.getAllRecords({
Entity: "Deals", sort_order: "desc", per_page: 200, page: 1,
});
return ((res && res.data) || []).filter((d) => d.Stage === "受注");
}
カードのクリックで該当商談へ遷移させるにはZOHO.CRM.UI.Record.openを使います。
ウィジェットの登録手順
- ウィジェット本体(
widget.html)とapp/js-sdk/にEmbedded App SDKを同梱してZIP化 - 設定 > 開発者向け情報 > ウィジェット > 新しいウィジェット
- 種類を**「関連リスト(Related List)」**、ホスティングを「Zoho(内部)」、Index URLを
/app/widget.htmlにしてZIPをアップロード - 任意の商談を開き、関連リストを追加 > ウィジェット > 作成したウィジェットを選択
ハマりどころ:ZIPアップロードが400エラーで弾かれる環境があります。その場合は外部ホスティング(Cloudflare Pagesなど)に置いてURL登録で回避できます。開発中は
zet runでローカル検証すると反復が速くなります。
まとめ
「レコメンド」というと大掛かりに聞こえますが、業種・金額・案件内容の重み付けと文字バイグラムのJaccardという枯れた手法だけで、実用十分な類似案件表示がCRM内で完結します。業種フィールドを「求人職種」や「物件エリア」に、対象を「求職者」「物件」に置き換えれば、人材紹介や不動産のマッチングにもそのまま応用できます。



