Skip to content

fix(conversion): match accounts on a normalized company name (#626) - #654

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-626-account-name-normalized
Aug 2, 2026
Merged

fix(conversion): match accounts on a normalized company name (#626)#654
os-zhuang merged 1 commit into
mainfrom
claude/issue-626-account-name-normalized

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #626

做了什么

线索转换过去用原始 crm_account.name 去重,所以 "Acme Corp""ACME Corp" 会变成同一家公司的两个客户。现在改成归一化后精确匹配:比较发生在两个存储的、由 hook 维护的匹配键上——crm_account.name_normalizedaccount_protection 写)与 crm_lead.company_normalizedlead_duplicate_check 写),都做 lower + trim + 内部空白折叠。模糊匹配仍然不做(Acme CorpAcme Corporation 依旧是两家公司)。

两个新字段都是 readonly + hidden:没人手写它们,展示值 name / company 一点没动——转换建出来的客户名仍然是线索里原样的公司名。

为什么只能是存储列(三条前提都重新实测过)

正文里的三条测量我没有继承,全部在 17.0.0-rc.1 上重跑了一遍,并且写成了测试test/account-name-normalized-match.test.ts),这样平台升级后前提失效会红,而不是留下一段过期的注释:

  1. 流程模板无法折叠字符串。 resolveToken 只认 NOW() / TODAY() 一种函数形式;表达式回退分支在求值前会把每个裸标识符替换掉,所以字符串方法根本够不着。实测:{LOWER(x)}{TRIM(x)}{x.toLowerCase()}{(x).toLowerCase()} 全部解析成 undefined,而没包起来的 LOWER({x})静默插值成字面量 "LOWER(ACME Corp)"
  2. formula 字段不能当匹配键。 fieldHasColumn({ type: 'formula' }) 仍然是 false —— 没有物理列可以过滤。
  3. $regex 不是答案,而且比 issue 描述的更糟。driver-sql 上它根本不按正则执行,而是编译成 LIKE '%value%' 子串匹配。实测(真 SQLite):$regex: 'Acme Corp' 同时命中 'Acme Corp''Not Acme Corp Ltd',而真正的模式 '^acme\s+corp$' 命中 0 行。它也无法折叠内部空白,前导通配符还废掉索引。

结论没变,但推理链现在是被测量钉住的。

一个必须点名的范围扩展:crm_lead.company_normalized

Issue 的 scope 只说了 crm_account。但只在客户侧加归一化列修不好任何东西——流程会拿一个原始公司名去比一个折叠过的客户名,"ACME Corp" 照样匹配不上 acme corp。而由前提 1,流程也没法折叠输入侧。所以比较的两边都必须是存储列。这不是我在两种架构里挑了一个,而是验收标准在给定测量下只有这一种形状。请复核这个判断。

company 本身没有就地折叠(email 是就地折叠的):它是展示值,会被原样复制到新建客户的 name 上,折叠它就等于把 acme corp 当客户名发出去。

scope 第 4 条(是否加唯一索引)的决定:不加

name_normalized 只带普通索引。理由写在 account.object.ts 的索引块里:

  1. 客户名唯一性已经按租户声明在 name 字段上(crm_account.name uniqueness is platform-wide, not per-tenant — two orgs cannot both have an "Acme Corp" #625)。唯一的 name_normalized吞掉那条约束(同名必同归一化),等于把上一轮刚落地的决定重新打开一次——而本 issue 的验收并不需要这个保证。
  2. 这列是匹配键,不是策略。缺陷是"流程找不到已有客户",修法是让它找到;直接拒绝近似重名是另一个数据质量决定。本仓库对同类问题已经选过一次软形态(Duplicate management: soft lead dedupe instead of the hard unique email constraint #598crm_lead.email 的硬唯一去掉了)。
  3. 实测危险仍在:已同时存在两种写法的部署上 create_index失败。今天它的影响面被"本仓库只有全新安装"这个前提兜住——这个前提和结论一起写进了代码注释和 docs/MAINTENANCE.md §3.3,前提变了结论就得跟着变

代价也记下来了,没藏:流程之外的路径仍可能造出两个同归一化名的客户,而 get_record 没有 sort,届时会复用其中任意一个。复用 N 个里的一个,仍然严格好于今天的"再造第 N+1 个"。

一个实现过程中的实测发现

把过滤器指到新列之后,两个既有测试文件红了,原因很有价值:当过滤条件解析为空时,get_record 会拒绝执行,而不是静默放宽:

get_record: refusing to run — 1 filter condition(s) resolved to nothing and were
dropped from the query: `{leadRecord.company_normalized}` (at name_normalized).
An absent condition does not narrow a query, it widens it …

这意味着未回填的两个对象有两种不同的失败模式,都已钉成测试、也都写进了 runbook:

  • 未回填的客户name_normalized 为空)→ 匹配不到,静默多建一个客户。这才是让回填成为必做项的那一条。
  • 未回填的线索company_normalized 为空)→ 转换大声失败,报错里直接点名缺失的键,线索保持未转换。

也正因为平台自己就拒绝放宽查询,我没有给流程加"归一化查不到就回退查原始 name"的兜底:键缺失说明生产者没跑,容忍式的第二条匹配路径只会把它藏起来,顺带把本节点要修的 bug 原样放回来。

回填(scope 第 2 条)

docs/MAINTENANCE.md 新增 §3.3:全新安装不需要任何动作(seed 写入会跑生命周期 hook —— 我按要求复现确认了,src/data/_shared.tsbilling_country 的既有依赖就是活证据);就地升级需要一次性回填,做法是用行自己的值回写自己PATCH {basePath}/data/:object/:idname / company),hook 会派生出键,幂等、可重跑。

验证

pnpm validate   ✓ Validation passed (846ms) — 15 Objects 316 Fields
pnpm typecheck  ✓ tsc --noEmit clean
pnpm lint       1 warning(crm_campaign_member field-group-shadowed,与本次改动无关,改动前就在)
pnpm hygiene    ✓ source hygiene clean
pnpm build      ✓ Build complete — Skipping legacy runtime bundle (all 25 callables are body-only)
pnpm test       ✓ Test Files 46 passed (46) · Tests 1120 passed | 1 skipped (1121)

新增 test/account-name-normalized-match.test.ts(45 个用例):三条前提的实测、两列的元数据契约、两个 hook 的真实 handler 行为、在真 QuickJS 沙箱里跑两段内联折叠(归一化是照 #621billing_country 内联写的,没有抽成模块级 helper),以及走真流程 + 真 hook 的端到端验收。

需要 PM 接手的一件小事

两个新字段都是 hidden: true,不进表单和选择器,所以本轮没有动那四个语言包(按分工要求)。如果后续要把它们做成可见字段,需要补 4 个 locale 的 label。


🤖 Generated with Claude Code

https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf


Generated by Claude Code

Lead conversion deduped accounts on the raw `crm_account.name`, so
"Acme Corp" and "ACME  Corp" produced two accounts for one company.

Matching is now normalize-then-exact over two stored, hook-maintained
match keys — `crm_account.name_normalized` (account_protection) and
`crm_lead.company_normalized` (lead_duplicate_check) — both lower-cased,
trimmed and internal-whitespace-collapsed. Fuzzy matching stays out.

Stored columns are forced, not preferred, and each alternative was
re-measured against 17.0.0-rc.1 and pinned in the new test file:
a flow template cannot fold a string (resolveToken knows only
NOW()/TODAY(); {LOWER(x)}, {TRIM(x)}, {x.toLowerCase()} all resolve to
undefined), a formula field has no physical column to filter on, and
`$regex` compiles to a substring LIKE on driver-sql — it matches
"Not Acme Corp Ltd" and cannot collapse whitespace.

`name_normalized` carries a plain index, not a unique one: account-name
uniqueness already lives per-tenant on `name` (#625), a unique normalized
column would subsume it, and `create_index` fails on a deployment already
holding both spellings. The reasoning and its fresh-installs-only premise
are recorded in the object and in docs/MAINTENANCE.md §3.3, which also
documents the one-time backfill and its two distinct failure modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 2, 2026 7:48pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Aug 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 20:01
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit c339c8d Aug 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Normalize account matching in lead conversion (split out of #598 scope 2)

2 participants