现象
接受(accept)一张没有同时挂 crm_contact 和 crm_opportunity 的报价时,quote_on_accepted hook 的自动起草合同 insert 抛 ValidationError,整个 handler 中止:
- 合同不会起草;
- 即便报价挂了 opportunity(只缺 contact),第 130 行的 close-won 分支也执行不到 —— 成交不推进;
- hook 是
async: true + onError: 'log',接受写本身照样 200,用户完全无感知。
服务器日志(两次独立复现均出现):
ERROR [hook] handler failed (onError=log; suppressing) {"hook":"quote_on_accepted","object":"crm_quote",
"event":"afterUpdate","error":"hook 'quote_on_accepted' threw: ValidationError: Primary Contact has an
invalid lookup value: Invalid input: expected string, received boolean; Related Opportunity has an
invalid lookup value: Invalid input: expected string, received boolean"}
复现步骤(×2 均复现)
# 只挂 account 的报价,合法状态链推进到 accepted
POST /api/v1/data/crm_quote {"name":"Q","crm_account":"<acct>","status":"draft","expiration_date":"2026-12-31","total_price":1234}
PATCH .../crm_quote/<id> {"status":"in_review"} # 200
PATCH .../crm_quote/<id> {"status":"presented"} # 200
PATCH .../crm_quote/<id> {"status":"accepted"} # 200(写不被阻断)
# 等异步 hook 后:
GET /api/v1/data/crm_contract?crm_account=<acct> # → 0 行(两次运行均 0)
对照组:同一环境下 quote 同时挂 contact + opportunity(proposal 阶段),同链路接受 → 合同 1 份自动起草、opportunity closed_won、win_reason=quote_accepted,全部正常 ✅ —— 说明失败特异于「链接缺失」。
期望 vs 实际
- 期望:CPQ 主链「接受报价 → 起草合同(+ 有关联时 close-won)」对
crm_contact 缺失是健壮的 —— crm_quote.crm_contact 本来就是有意 optional 的(schema 注释:"Recipient is nailed down by the time a quote is presented, not when it is drafted");
- 实际:任一链接缺失即整链静默失败。
落点分析(hotcrm 侧,hook 代码)
src/objects/quote.hook.ts:98-103:
const contactId =
(typeof input.crm_contact === 'string' && input.crm_contact) ||
(typeof previous?.crm_contact === 'string' && previous.crm_contact);
两个操作数都不成立时表达式求值为 false(boolean) 而非 undefined;113-116 行把它原样传进 api.object('crm_contract').insert({ crm_contact: contactId, crm_opportunity: opportunityId, ... }),引擎 lookup 校验拒绝 boolean → insert 抛错 → handler 中止,close-won 分支(130 行起)永远到不了。同文件 94-97 行 accountId 同 idiom,只是被 required 掩盖。
另注(修复时需一并决策):即便把 false 改成 undefined,contact-less 报价起草合同仍会撞上 crm_contract.crm_contact 的 required + notNull —— quote 有意允许无 contact,而合同不允许,二者的设计冲突是这条链对该类报价从未成功过的另一半原因。
非 #684(那是 record-change flows 缺 runAs:'system' 的系统驱动写被拒);本条是 hook 自身的 JS 表达式缺陷,报错形态(ValidationError: received boolean)与 #684 完全不同,两仓查重 quote_on_accepted / "received boolean" / "Auto-drafted" 均无既有单。
环境
hotcrm@0899b4f + @objectstack 17.0.0-rc.2(REST 验收,admin token,独立 file:SQLite 实例)
现象
接受(accept)一张没有同时挂
crm_contact和crm_opportunity的报价时,quote_on_acceptedhook 的自动起草合同 insert 抛 ValidationError,整个 handler 中止:async: true+onError: 'log',接受写本身照样 200,用户完全无感知。服务器日志(两次独立复现均出现):
复现步骤(×2 均复现)
对照组:同一环境下 quote 同时挂 contact + opportunity(proposal 阶段),同链路接受 → 合同 1 份自动起草、opportunity
closed_won、win_reason=quote_accepted,全部正常 ✅ —— 说明失败特异于「链接缺失」。期望 vs 实际
crm_contact缺失是健壮的 ——crm_quote.crm_contact本来就是有意 optional 的(schema 注释:"Recipient is nailed down by the time a quote is presented, not when it is drafted");落点分析(hotcrm 侧,hook 代码)
src/objects/quote.hook.ts:98-103:两个操作数都不成立时表达式求值为
false(boolean) 而非undefined;113-116 行把它原样传进api.object('crm_contract').insert({ crm_contact: contactId, crm_opportunity: opportunityId, ... }),引擎 lookup 校验拒绝 boolean → insert 抛错 → handler 中止,close-won 分支(130 行起)永远到不了。同文件 94-97 行accountId同 idiom,只是被 required 掩盖。另注(修复时需一并决策):即便把
false改成undefined,contact-less 报价起草合同仍会撞上crm_contract.crm_contact的required + notNull—— quote 有意允许无 contact,而合同不允许,二者的设计冲突是这条链对该类报价从未成功过的另一半原因。非 #684(那是 record-change flows 缺 runAs:'system' 的系统驱动写被拒);本条是 hook 自身的 JS 表达式缺陷,报错形态(ValidationError: received boolean)与 #684 完全不同,两仓查重
quote_on_accepted/ "received boolean" / "Auto-drafted" 均无既有单。环境
hotcrm@0899b4f + @objectstack 17.0.0-rc.2(REST 验收,admin token,独立 file:SQLite 实例)