fix(conversion): match accounts on a normalized company name (#626) - #654
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-zhuang
marked this pull request as ready for review
August 2, 2026 20:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #626
做了什么
线索转换过去用原始
crm_account.name去重,所以"Acme Corp"和"ACME Corp"会变成同一家公司的两个客户。现在改成归一化后精确匹配:比较发生在两个存储的、由 hook 维护的匹配键上——crm_account.name_normalized(account_protection写)与crm_lead.company_normalized(lead_duplicate_check写),都做 lower + trim + 内部空白折叠。模糊匹配仍然不做(Acme Corp与Acme Corporation依旧是两家公司)。两个新字段都是
readonly+hidden:没人手写它们,展示值name/company一点没动——转换建出来的客户名仍然是线索里原样的公司名。为什么只能是存储列(三条前提都重新实测过)
正文里的三条测量我没有继承,全部在 17.0.0-rc.1 上重跑了一遍,并且写成了测试(
test/account-name-normalized-match.test.ts),这样平台升级后前提失效会红,而不是留下一段过期的注释:resolveToken只认NOW()/TODAY()一种函数形式;表达式回退分支在求值前会把每个裸标识符替换掉,所以字符串方法根本够不着。实测:{LOWER(x)}、{TRIM(x)}、{x.toLowerCase()}、{(x).toLowerCase()}全部解析成undefined,而没包起来的LOWER({x})会静默插值成字面量"LOWER(ACME Corp)"。fieldHasColumn({ type: 'formula' })仍然是false—— 没有物理列可以过滤。$regex不是答案,而且比 issue 描述的更糟。 在driver-sql上它根本不按正则执行,而是编译成LIKE '%value%'子串匹配。实测(真 SQLite):$regex: 'Acme Corp'同时命中'Acme Corp'和'Not Acme Corp Ltd',而真正的模式'^acme\s+corp$'命中 0 行。它也无法折叠内部空白,前导通配符还废掉索引。结论没变,但推理链现在是被测量钉住的。
一个必须点名的范围扩展:
crm_lead.company_normalizedIssue 的 scope 只说了
crm_account。但只在客户侧加归一化列修不好任何东西——流程会拿一个原始公司名去比一个折叠过的客户名,"ACME Corp"照样匹配不上acme corp。而由前提 1,流程也没法折叠输入侧。所以比较的两边都必须是存储列。这不是我在两种架构里挑了一个,而是验收标准在给定测量下只有这一种形状。请复核这个判断。company本身没有就地折叠(email是就地折叠的):它是展示值,会被原样复制到新建客户的name上,折叠它就等于把acme corp当客户名发出去。scope 第 4 条(是否加唯一索引)的决定:不加
name_normalized只带普通索引。理由写在account.object.ts的索引块里:name字段上(crm_account.name uniqueness is platform-wide, not per-tenant — two orgs cannot both have an "Acme Corp" #625)。唯一的name_normalized会吞掉那条约束(同名必同归一化),等于把上一轮刚落地的决定重新打开一次——而本 issue 的验收并不需要这个保证。crm_lead.email的硬唯一去掉了)。create_index会失败。今天它的影响面被"本仓库只有全新安装"这个前提兜住——这个前提和结论一起写进了代码注释和docs/MAINTENANCE.md§3.3,前提变了结论就得跟着变。代价也记下来了,没藏:流程之外的路径仍可能造出两个同归一化名的客户,而
get_record没有sort,届时会复用其中任意一个。复用 N 个里的一个,仍然严格好于今天的"再造第 N+1 个"。一个实现过程中的实测发现
把过滤器指到新列之后,两个既有测试文件红了,原因很有价值:当过滤条件解析为空时,
get_record会拒绝执行,而不是静默放宽:这意味着未回填的两个对象有两种不同的失败模式,都已钉成测试、也都写进了 runbook:
name_normalized为空)→ 匹配不到,静默多建一个客户。这才是让回填成为必做项的那一条。company_normalized为空)→ 转换大声失败,报错里直接点名缺失的键,线索保持未转换。也正因为平台自己就拒绝放宽查询,我没有给流程加"归一化查不到就回退查原始 name"的兜底:键缺失说明生产者没跑,容忍式的第二条匹配路径只会把它藏起来,顺带把本节点要修的 bug 原样放回来。
回填(scope 第 2 条)
docs/MAINTENANCE.md新增 §3.3:全新安装不需要任何动作(seed 写入会跑生命周期 hook —— 我按要求复现确认了,src/data/_shared.ts与billing_country的既有依赖就是活证据);就地升级需要一次性回填,做法是用行自己的值回写自己(PATCH {basePath}/data/:object/:id,name/company),hook 会派生出键,幂等、可重跑。验证
新增
test/account-name-normalized-match.test.ts(45 个用例):三条前提的实测、两列的元数据契约、两个 hook 的真实 handler 行为、在真 QuickJS 沙箱里跑两段内联折叠(归一化是照 #621 的billing_country内联写的,没有抽成模块级 helper),以及走真流程 + 真 hook 的端到端验收。需要 PM 接手的一件小事
两个新字段都是
hidden: true,不进表单和选择器,所以本轮没有动那四个语言包(按分工要求)。如果后续要把它们做成可见字段,需要补 4 个 locale 的 label。🤖 Generated with Claude Code
https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
Generated by Claude Code