CRM 會碰到哪些資料那篇的結論是一張表:身分、交易、互動、同意,每一類都要先講清楚誰說了算。

道理講完了,這篇動手做一次。我在本機跑了一套 EspoCRM,寫一支同步程式,把「自己系統」的會員和訂單送進 CRM。資料全部是假的,但撞到的問題都是真的。

實驗環境

  • CRM:EspoCRM 10.0.9,用官方 image 加 docker compose 在本機啟動,三個容器合計大約 200 MB 記憶體。
  • 自己的系統:用兩個 JSON 檔代替,5 個會員、6 張訂單。
  • 同步程式:一支 Node.js 腳本,透過 EspoCRM 的 REST API 寫入。

同步的規則照那張表來:

  • 身分:用自己系統的會員編號對應,不用名字或 email。
  • 交易:不搬訂單明細,只送兩個彙總:近 90 天消費金額、最後下單時間。
  • 同意:會員是否願意收行銷信,送進 CRM 的退訂欄位。

第一步:不要用管理員帳號串接

串接用的帳號,權限要照它要做的事來給。我建了一個角色「Member Sync」:聯絡人可以建立、讀取、修改,不能刪除;公司資料完全不能讀。再建一個 API 帳號,用 API Key 登入。

實測結果:

  • 讀聯絡人:200
  • 讀公司:403
  • 建立聯絡人:成功
  • 刪除聯絡人:403

同步程式出錯時,最多只能把聯絡人改壞,不能刪掉,也碰不到其他資料。這跟 那篇講 MCP 時的原則一樣:權限照角色縮,不拿管理員帳號給程式或 AI。

第二步:用會員編號當對應的鑰匙

CRM 的聯絡人沒有「會員編號」這個欄位,要自己加。我加了三個自訂欄位:會員編號、近 90 天消費金額、最後下單時間。

第一個小發現:EspoCRM 會自動在自訂欄位前面加 c。我建的 memberId,API 上的名字是 cMemberId。串接的程式要用加了前綴的名字。

同步程式的核心是 upsert:先用會員編號查,查到就更新,查不到就建立。

const found = await findByMemberId(m.memberId);   // 用 cMemberId 查
const res = found
  ? await api("PATCH", `/Contact/${found.id}`, contact)
  : await api("POST", "/Contact", contact);

查到兩筆以上,程式直接停下來報錯,不猜要更新哪一筆。

撞到的第一件事:電話格式被拒

第一次執行,5 筆全部失敗,回 400。看 EspoCRM 的日誌,原因是電話欄位驗證失敗。

EspoCRM 要國際格式的電話號碼:+886912000001。自己系統存的是 0912000001,直接送就被擋下來。

修法是在同步程式裡轉換格式。這件事很小,但它說明一件事:兩個系統對同一個欄位的格式要求不一樣,轉換要在同步的時候做,而且要有地方看得到失敗(見怎麼觀察資料有沒有正常進來)。如果同步程式只寫「失敗 5 筆」而沒有原因,就要去翻 CRM 的日誌才知道。

轉換之後重跑:

  • 第一次:建立 5 筆
  • 第二次:更新 5 筆,沒有多建任何一筆

跑幾次結果都一樣,這是同步程式最基本的要求。

交易的彙總也對:有一個會員的訂單在 6 月,超過 90 天,所以近 90 天消費是 0;另一個會員有一張已退款的訂單,沒有算進去。

撞到的第二件事:同一個人被建兩次

接著模擬一個常見情境:業務在 CRM 裡手動新增了一個客戶,名字和 email 跟某個會員一樣,但沒有填會員編號。

EspoCRM 有重複檢查。用同一個 email 建立聯絡人,API 回 409,並且把已經存在的那筆資料回傳。在畫面上,業務會看到「發現重複的資料」。

但重複檢查可以略過。API 帶上 X-Skip-Duplicate-Check: true,就建立成功了。在畫面上,就是業務按了「仍要建立」。

結果是同一個人有兩筆聯絡人:一筆有會員編號,是同步程式建的;一筆沒有,是業務建的。同步程式只認會員編號,所以它永遠只會更新第一筆,第二筆就一直留在那裡。

這就是那篇講的「身分最常吵架」。CRM 的重複檢查能擋住大部分,但擋不住有人刻意略過。要做的事:

  1. 定期找出沒有會員編號、但 email 或電話跟會員相同的聯絡人,交給人合併。
  2. 合併時以有會員編號的那筆為主,因為身分以自己的系統說了算。

撞到的第三件事:退訂被同步蓋掉

這是最嚴重的一件。

模擬情境:一位會員回信說不想再收行銷信,行銷人員在 CRM 把他標成退訂。到這裡都沒問題。

然後同步程式照排程又跑了一次。自己系統裡,這位會員的資料還是「願意收行銷信」,同步程式就把這個值寫進 CRM。退訂被蓋回去了,下一次寄 EDM 他又會收到。

這正是那篇裡講的情況:退訂這件事有兩個地方在記。每一步單獨看都沒錯,合起來就違反個資法第 20 條:當事人拒絕行銷後,應立即停止。

順著查下去,有一個有意思的發現:EspoCRM 的退訂不是記在聯絡人上,而是記在 email 地址上。 系統裡 ming@example.com 只有一筆 email 地址紀錄,上一節那兩筆重複的聯絡人共用它。我在其中一筆標退訂,另一筆也跟著變成退訂。

所以 EspoCRM 自己的設計是對的:同一個 email 的同意只有一份。出問題的是我的同步程式,它把來源系統的舊值寫回去。

修法是一條規則:退訂優先。

// 同步只能把「可以寄」改成「退訂」,不能反過來
if (found?.emailAddressIsOptedOut && !contact.emailAddressIsOptedOut) {
  delete contact.emailAddressIsOptedOut;   // 不覆蓋 CRM 的退訂
  writeBack.push(m.memberId);              // 列出來,回寫給自己的系統
}

修正後再跑兩次,這位會員都維持退訂,而且每次都列出「要回寫給來源系統」。

這裡要再想清楚一件事:到底哪一邊是同意的唯一來源。 退訂優先只是防止最壞的情況。真正的解法是二選一:

  • 自己的系統是來源:CRM 和 EDM 收到的退訂,都要即時回寫到自己的系統,CRM 只保留副本。
  • CRM 是來源:同步程式完全不寫退訂欄位,自己的系統要寄信前,回頭查 CRM。

不管選哪一邊,「重新同意」都只能在來源那邊發生,而且要有會員本人的動作,不能由同步程式改回來。

撞到的第四件事:名字姓名顛倒

最後一件比較小。資料送進去之後,名字顯示成「小明 王」。EspoCRM 預設是英文的名字順序,名在前、姓在後。

把系統設定的名字格式改成姓在前,就變成「王 小明」,中間多一個空格。

這件事不影響資料正確,但影響業務每天看的畫面。導入國外的系統時,這類在地化的細節要在上線前一起檢查:名字順序、電話格式、地址格式、日期格式。

收:同步程式的檢查清單

項目這次實驗的做法
串接帳號專用的 API 帳號,權限只到需要的資料,不能刪除
對應的鑰匙自己系統的會員編號,查到兩筆以上就停
格式轉換在同步程式裡轉,失敗要記下原因
重跑跑幾次結果都一樣
重複資料定期找出沒有會員編號的重複聯絡人,交給人合併
退訂退訂優先,不能被同步蓋回去;指定唯一的來源
在地化名字順序、電話、地址、日期格式在上線前檢查

這七項,每一項都對應到那篇的四類資料。表格上的規則看起來很直覺,真正動手寫同步程式時才會發現,每一條都有一個地方很容易做錯。

相關主題