サンドボックスのデータの衝突チェックとは、サンドボックス(本番と切り離された検証用の環境)で整えたデータを本番へ入れる前に、本番の既存データと重複や食い違いがないかを確かめる作業です。Zoho CRMでは、読み取り専用のAPIだけでこの確認ができます。本番に1件も書き込まずに、「入れてよいか」「入れる前に何を決めるべきか」を判断できます。

当社は、西日本の食品卸(中小企業)の支援で、過去数年分の販売実績をサンドボックスへ取り込み、内容を確かめてから本番へ移す計画を進めていました。この記事では、そのとき行った衝突チェックの手順と観点を、そのまま使えるスクリプトとともにまとめます。ワークフローや組織変数など「設定」をサンドボックスから本番へ反映するときの注意は、「Zoho CRMのサンドボックスから本番へ反映する落とし穴」で扱っています。本記事は「データ」を移す前の確認に絞ります。

なぜ本番適用の前に衝突チェックが要るのか

サンドボックスの中だけで見ると、データは正しく入っています。問題は、本番には長年の運用で積み上がった既存データがあることです。

  • 本番には、同じ相手が表記違いで2件登録されていることがある
  • 本番の取引先コードと、取り込むデータの得意先コードが、同じ相手を指しているとは限らない
  • 過去に一部だけ手作業で入れた取引データが、本番に残っていることがある

サンドボックスで「新規作成」と判断した取引先が、本番ではすでに存在するかもしれません。そのまま入れると、取引先が二重に登録され、販売実績が2か所に分かれてしまいます。後から統合するには、取引データの付け替えまで必要になります。

確認の全体像

読み取り専用で両方の環境に接続し、項目設定を比べ、全件を取得して突き合わせ、衝突を数え、自動処理の差を確かめる確認の流れを示した図

当社は次の順で確認しました。

  1. 両環境に、読み取り専用のトークンで別々に接続する
  2. 項目の設定(型・参照先・必須・一意の制約)を比べる
  3. サンドボックスの取引先と取引データを全件取得する
  4. 本番の取引先を全件、取引データを同じ期間で全件取得する
  5. 取引先と取引データを突き合わせて、衝突を数える
  6. 自動処理(ワークフロー)の設定差と、それが既存データを書き換えないかを確かめる
  7. 結果を「そのまま適用できる/判断が要る/作業が残っている」に分けて報告する

CRMへの作成・更新・削除は一度も行いません。

手順1:読み取り専用のトークンを環境ごとに用意する

Zoho CRMのAPIは、組織(環境)ごとにアクセストークンが必要です。Zohoの公式解説(Kaizen #120 サンドボックスのAPI呼び出し)によると、サンドボックスへのAPI呼び出しは https://sandbox.zohoapis.{ドメイン} を使い、トークンの発行先は本番と同じ https://accounts.zoho.{ドメイン} です。日本のデータセンターなら、本番は https://www.zohoapis.jp、サンドボックスは https://sandbox.zohoapis.jp になります。

トークンを発行するときは、次の READ のスコープだけを選びます。

  • ZohoCRM.coql.READ(COQLで検索する)
  • ZohoCRM.modules.READ(レコードを読む)
  • ZohoCRM.settings.fields.READ(項目の設定を読む)

書き込みのスコープを付けなければ、スクリプトに誤りがあっても本番は変わりません。確認作業そのものを安全にするための、いちばん効く対策です。スクリプトからAPIを呼ぶためのクライアントの設定は「Zoho APIをスクリプトから叩く最短ルート」で紹介しています。

手順2:取引先と取引データの何を突き合わせるか

当社は、取引先と取引データ(販売実績)について、次の観点で数えました。

取引先

取引先には、取り込み用の一意キー(「帳簿コード+元の得意先コード」を連結した、重複しない値)を持たせていました。一意キーで本番の取引先と1対1に決まるものは問題ありません。決まらないものについて、次の2つを洗い出します。

観点 何が起きるか 判断すること
同名が本番に複数ある どちらに紐づけるか機械的に決められない 紐づける本番の取引先を選ぶ
コードは同じだが名称が違う 名前で照合する仕組みだと「新規」と判定され、二重登録になる 同じ相手として紐づけるか、別の取引先として新規作成するか

名称は、全角・半角、空白、「株式会社」と「(株)」の違いなどをそろえてから比べます。

取引データ

観点 0件であるべき理由
外部ID(データ処理用の一意な値)が本番と一致する 同じデータを二重に入れようとしている
外部IDが一致し、中身が違う どちらかの取込に誤りがある
外部IDは違うが、日付・取引先・商品・数量・金額が一致する 過去に別の方法で入れた同じ取引が本番にある
取込の単位(取込番号)が本番にすでにある 同じ単位を二度入れようとしている
サンドボックス側で外部IDが空欄・重複 本番で一意制約に引っかかる
サンドボックス側で取引先の参照が空欄 取引先に紐づかないデータが入る

取引先の参照は、環境ごとにレコードIDが違います。そのため取引データの「取引先」は、レコードIDではなく、参照先の取引先が持つ一意キーで比べます。COQLでは 参照項目.項目名 の形で、参照先の値を一緒に取得できます(COQLの公式ドキュメント)。

手順3:スクリプトで全件取得して突き合わせる

ここまでを1本にまとめたNode.js(18以上)のスクリプトです。項目名は一般的な名前に置き換えています。Account_Key__c は取引先の一意キー、Account_Code__c は取引先コード、Sales_Records は販売実績のカスタムモジュール、Account__c はそこから取引先への参照項目の例です。ご自身の組織の項目名に読み替えてください。

// collision-check.mjs
// サンドボックスと本番を読み取り専用で突き合わせる(Node.js 18 以上)
import { writeFile } from "node:fs/promises";

const ENVS = {
  prod: { domain: process.env.PROD_API_DOMAIN, token: process.env.PROD_TOKEN },
  sandbox: { domain: process.env.SANDBOX_API_DOMAIN, token: process.env.SANDBOX_TOKEN },
};
const PERIOD = { start: process.env.START_DATE, end: process.env.END_DATE }; // 例: 2025-04-01
const PAGE_SIZE = 200; // v8 は最大 2,000 件。件数を増やすと呼び出し回数は減ります

const ACCOUNT_FIELDS = ["id", "Account_Name", "Account_Key__c", "Account_Code__c"];
const SALES_FIELDS = [
  "id", "External_ID__c", "Import_Batch__c", "Sales_Date__c",
  "Account__c.Account_Key__c", "Product_Code__c", "Quantity__c", "Amount__c",
];

async function api(env, method, path, body) {
  const { domain, token } = ENVS[env];
  const res = await fetch(`${domain}/crm/v8/${path}`, {
    method,
    headers: { Authorization: `Zoho-oauthtoken ${token}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status === 204) return { data: [], info: { more_records: false } }; // 0件
  const json = await res.json();
  if (!res.ok) throw new Error(`${env} ${path}: ${res.status} ${JSON.stringify(json)}`);
  return json;
}

// id の昇順で全件取得する(id > 直前の最後の id で次ページへ進む)
async function coqlAll(env, moduleName, fields, where) {
  const records = [];
  let lastId = null;
  while (true) {
    const cond = lastId ? `((${where}) and id > ${lastId})` : `(${where})`;
    const query = `select ${fields.join(", ")} from ${moduleName} where ${cond} order by id asc limit ${PAGE_SIZE}`;
    const res = await api(env, "POST", "coql", { select_query: query });
    const page = res.data || [];
    records.push(...page);
    if (!res.info?.more_records || page.length === 0) return records;
    lastId = page[page.length - 1].id;
  }
}

// 項目の型と参照先を比べる
async function compareFields(moduleName) {
  const [p, s] = await Promise.all(
    ["prod", "sandbox"].map((env) => api(env, "GET", `settings/fields?module=${moduleName}`)),
  );
  const shape = (f) => `${f.data_type}|${f.lookup?.module?.api_name || ""}`;
  const prodMap = new Map(p.fields.map((f) => [f.api_name, shape(f)]));
  return s.fields
    .filter((f) => prodMap.get(f.api_name) !== shape(f))
    .map((f) => ({ module: moduleName, field: f.api_name, sandbox: shape(f), prod: prodMap.get(f.api_name) ?? "(本番に無い)" }));
}

const norm = (v) => String(v ?? "")
  .normalize("NFKC")
  .replace(/^【[^】]*】/, "")              // 取込時に付けた接頭辞を外す
  .replace(/株式会社|\(株\)|㈱/g, "")
  .replace(/\s+/g, "")
  .toLowerCase();

function groupBy(items, keyFn) {
  const map = new Map();
  for (const item of items) {
    const key = keyFn(item);
    if (!key) continue;
    map.set(key, [...(map.get(key) || []), item]);
  }
  return map;
}

function checkAccounts(sbAccounts, prodAccounts) {
  const prodByKey = groupBy(prodAccounts, (a) => a.Account_Key__c);
  const prodByName = groupBy(prodAccounts, (a) => norm(a.Account_Name));
  const prodByCode = groupBy(prodAccounts, (a) => a.Account_Code__c);
  const issues = [];
  for (const sb of sbAccounts) {
    if (prodByKey.has(sb.Account_Key__c)) continue; // 一意キーで1対1に決まる
    const byName = prodByName.get(norm(sb.Account_Name)) || [];
    const byCode = prodByCode.get(sb.Account_Code__c) || [];
    if (byName.length > 1) {
      issues.push({ type: "同名が本番に複数", key: sb.Account_Key__c, candidates: byName.map((a) => a.id) });
    } else if (byName.length === 0 && byCode.length > 0) {
      issues.push({ type: "コードは一致・名称が不一致", key: sb.Account_Key__c, candidates: byCode.map((a) => a.id) });
    }
  }
  return issues;
}

function checkSales(sbSales, prodSales) {
  const contentKey = (r) => [r.Sales_Date__c, r["Account__c.Account_Key__c"], r.Product_Code__c, r.Quantity__c, r.Amount__c].join("|");
  const prodById = new Map(prodSales.filter((r) => r.External_ID__c).map((r) => [r.External_ID__c.toLowerCase(), r]));
  const prodContents = new Set(prodSales.map(contentKey));
  const prodBatches = new Set(prodSales.map((r) => r.Import_Batch__c).filter(Boolean));
  const sbIds = sbSales.map((r) => (r.External_ID__c || "").toLowerCase());

  const sameId = sbSales.filter((r) => r.External_ID__c && prodById.has(r.External_ID__c.toLowerCase()));
  return {
    外部ID一致: sameId.length,
    外部ID一致で内容差あり: sameId.filter((r) => contentKey(r) !== contentKey(prodById.get(r.External_ID__c.toLowerCase()))).length,
    外部ID違いで内容一致: sbSales.filter((r) => !prodById.has((r.External_ID__c || "").toLowerCase()) && prodContents.has(contentKey(r))).length,
    取込番号が本番に存在: [...new Set(sbSales.map((r) => r.Import_Batch__c))].filter((b) => prodBatches.has(b)).length,
    外部ID空欄: sbIds.filter((id) => !id).length,
    外部ID重複: sbIds.filter(Boolean).length - new Set(sbIds.filter(Boolean)).size,
    取引先参照の空欄: sbSales.filter((r) => !r["Account__c.Account_Key__c"]).length,
  };
}

const salesWhere = `Sales_Date__c between '${PERIOD.start}' and '${PERIOD.end}'`;
const [fieldDiffs, sbAccounts, prodAccounts, sbSales, prodSales] = await Promise.all([
  Promise.all(["Accounts", "Sales_Records"].map(compareFields)).then((list) => list.flat()),
  coqlAll("sandbox", "Accounts", ACCOUNT_FIELDS, "Account_Name is not null"),
  coqlAll("prod", "Accounts", ACCOUNT_FIELDS, "Account_Name is not null"),
  coqlAll("sandbox", "Sales_Records", SALES_FIELDS, salesWhere),
  coqlAll("prod", "Sales_Records", SALES_FIELDS, salesWhere),
]);

const report = {
  checkedAt: new Date().toISOString(),
  counts: { sbAccounts: sbAccounts.length, prodAccounts: prodAccounts.length, sbSales: sbSales.length, prodSales: prodSales.length },
  fieldDiffs,
  accountIssues: checkAccounts(sbAccounts, prodAccounts),
  salesIssues: checkSales(sbSales, prodSales),
};
await writeFile("collision-report.json", JSON.stringify(report, null, 2));
console.log(JSON.stringify({ ...report, accountIssues: report.accountIssues.length }, null, 2));

実行は次のとおりです。トークンはファイルに書かず、環境変数で渡します。

export PROD_API_DOMAIN=https://www.zohoapis.jp
export SANDBOX_API_DOMAIN=https://sandbox.zohoapis.jp
export PROD_TOKEN=(本番の読み取り専用アクセストークン)
export SANDBOX_TOKEN=(サンドボックスの読み取り専用アクセストークン)
export START_DATE=2025-04-01
export END_DATE=2026-03-31
node collision-check.mjs

実装上の注意点を3つ挙げます。

  1. ページ送りは id で進める。 COQLは limit オフセット, 件数 でもページを送れますが、公式ドキュメントでは同じ条件で取得できる累計は100,000件までです。本スクリプトは、公式ドキュメントが案内している「id > 直前の最後のid を条件に加えて次を取る」方法にしているため、件数が多くても止まりません。
  2. 0件は204で返る。 条件に合うレコードがないと、本文なしの応答(HTTP 204 No Content)が返ります。そのまま res.json() を呼ぶと失敗するため、先に204を判定して空の結果にしています。
  3. 条件は括弧で囲む。 公式ドキュメントでは、WHEREに3つ以上の条件を書くときは括弧で正しく囲むよう案内されています。期間の条件と id > の条件を組み合わせるときに、元の条件全体を括弧で囲んでいるのはそのためです。

手順4:自動処理の差を確かめる

本番へ登録したときに動く自動処理(ワークフローや関数)も、確認の対象です。当社の事例では、取引先に「半角カナを全角カナにそろえる」処理が、作成時と編集時に動く設定になっていました。

ここで確かめたのは2点です。

  • 自動処理の設定(対象モジュール・実行の条件)が、本番とサンドボックスで同じか
  • 紐づけ候補になっている本番の取引先について、自動処理で書き換わる項目に変換対象の文字が含まれていないか

候補の取引先の対象項目を読み取ったところ、変換対象の文字は含まれていませんでした。これで、既存の取引先の値が知らないうちに書き換わる心配は小さいと判断できます。ただし、APIで読めるのは設定と動作の条件までです。本番に置かれている関数の中身がリポジトリの版と完全に同じかどうか、実際に書き込んだ後どうなるかは、この方法では確かめられません。報告ではこの点を「未検証」と明記しました。

衝突が0件なら本番に入れてよい?当社の事例で分かったこと

販売実績(大量の取引データ)については、外部IDの一致、外部IDが違って中身が同じもの、取込番号の重複、空欄や重複のいずれも0件でした。取引データだけを見れば、衝突はありませんでした。

一方、取引先では人の判断が要るものが数件ずつ残りました。

  • 本番に同じ名前の取引先が複数あり、どちらに紐づけるか決められないもの
  • 本番に同じ取引先コードの取引先があるのに、名称が違うもの(部署名の有無、締め日の付記、社名の変更など)

後者は、同じ相手の表記違いかもしれませんし、別の部署や別の締め条件として分けて管理している取引先かもしれません。コードが同じだからといって自動で統合するのは危険です。

もう1つ、取込ウィジェットの仕様から分かったことがあります。当社が作った取込ウィジェットでは、既存の取引先を選んで紐づけた場合、ウィジェットが更新するのは一意キーなどの管理用の項目だけで、取引先名は更新しません。サンドボックスで取引先名を正式名称に直していても、本番の既存取引先の名前はそのまま残ります。名称を直したいなら、別の作業として計画する必要があります。

さらに、依頼を受けていた正式名称への修正のうち、サンドボックスにまだ反映されていないものがありました。サンドボックスのデータそのものが、全期間の取込を終えた状態ではなかったためです。

これらを踏まえ、結論は「現在のサンドボックスのデータは、そのままでは本番に適用できない」としました。取引データに衝突がないことと、本番に入れてよいことは別の話です。

手順5:判断を反映して再検証し、小さく試す

衝突チェックの後は、次の順で進めます。

  1. 判断が要る取引先について、紐づける本番の取引先を選ぶか、新規作成とするかを、業務を知る方と決める(移行項目の確認の進め方は「Zoho CRMへのデータ移行で漏れを防ぐ」も参考になります)
  2. 未反映の修正や未取込の期間を、サンドボックスで仕上げる
  3. 同じスクリプトをもう一度実行し、「同名が本番に複数」「コードは同じで名称が違う」「取引データの衝突」がすべて0件になったことを確かめる
  4. 全件を入れる前に、1つの取込単位だけを本番へ登録する
  5. 登録した単位について、件数・金額・外部ID・取引先の参照・自動処理後の値を本番から読み直し、元のデータと一致することを確かめる
  6. 問題がなければ、残りの単位を順に登録する

同じスクリプトを何度でも実行できるのが、読み取り専用で組む利点です。判断を反映するたびに再実行し、数字が0件に近づいていくのを確かめながら進められます。

報告書には「確かめた範囲」を書く

衝突チェックの報告では、結果に加えて確認の区分を分けて書くことをおすすめします。

  • 読み取りで確かめたこと(認証、項目設定、全件取得、衝突の集計)
  • まだ確かめていないこと(本番での登録結果、自動処理の実際の動き)
  • 先に決める必要があること(取引先の紐づけ先、未反映の修正)

「衝突0件」という数字だけが伝わると、本番に入れてよいと受け取られがちです。何を確かめて何を確かめていないかを並べておくと、判断を仰ぐ相手も次の一手を決めやすくなります。

まとめ:本番適用前の衝突チェックリスト

  1. 環境ごとに、READ のスコープだけのトークンを用意する
  2. 項目の型と参照先を、本番とサンドボックスで比べる
  3. 両環境を全件取得する(ページ送りは id で進め、0件の204に備える)
  4. 取引データは、外部IDの一致と「外部IDは違うが中身が同じ」の両方を数える
  5. 取引先は「同名が本番に複数」「コードは同じで名称が違う」を洗い出し、自動で決めない
  6. 自動処理が既存データを書き換えないかを、対象項目を読んで確かめる
  7. 判断を反映したら再実行し、0件を確かめてから1単位だけ本番で試す

本記事のCOQLの上限値やAPIの書き方は、Zoho CRM API v8の公式ドキュメントに基づいています。仕様は更新されることがあるため、実行前に最新のドキュメントもあわせてご確認ください。

サンドボックスから本番への反映の進め方を相談したい場合は、販売管理システムとCRMの統合構築サービスのページをご覧ください。

よくある質問

Zohoのサンドボックスから本番へデータを直接コピーする機能はありますか?

サンドボックスは本番と切り離された検証用の環境で、設定の反映とデータの扱いは別です。データは、取込ツールやAPIで改めて本番に登録する前提で考え、その前に本記事のような衝突チェックを行うのが安全です。

なぜ読み取り専用のトークンにするのですか?

確認のつもりのスクリプトが、誤って本番のレコードを作成・更新するのを防ぐためです。発行時に READ のスコープだけを選べば、仮にコードに書き込み処理が紛れ込んでもAPIが拒否します。

外部IDの衝突が0件なら、本番に入れてよいですか?

取引データ側の衝突が0件でも、参照先の取引先が一意に決まらなければ、間違った取引先に紐づくおそれがあります。取引先側の確認も0件になってから進めます。

件数が多くて取得に時間がかかります。

COQLはAPI v8で1回最大2,000件まで指定できます。スクリプトの PAGE_SIZE を上げると呼び出し回数は減ります。API呼び出しの消費はご契約のエディションと残りクレジットによって異なるため、公式ドキュメントで確かめてから決めてください。