fix(quote): an absent link on an accepted quote is an absent key, not false (#714) - #1013
Merged
Merged
Conversation
… `false`
`quote_on_accepted` read every id it copies onto the drafted contract with
`(typeof input.x === 'string' && input.x) || (typeof previous?.x === 'string'
&& previous.x)`. When neither operand holds — a quote with no contact or no
opportunity, both allowed by `crm_quote` — that expression evaluates to boolean
`false`, and `false` reached the engine as the CONTENT of a lookup.
Measured on 17.0.0-rc.3, both ADR-0104 value-shape postures are wrong:
- strict (after `os migrate value-shapes --apply`): the insert is refused with
"Primary Contact has an invalid lookup value: Invalid input: expected
string, received boolean", and the refusal aborted the whole handler — so
the close-won leg after it never ran either. `async` + `onError: 'log'`
made the accepting PATCH answer 200 with no user-visible sign.
- warn-first (default): nothing is refused; `false` is STORED in a reference
column, which is a row the value-shape scan later cannot convert.
`pickId` collapses every "no id here" case to `undefined`, and only the lookups
the quote actually carries are written, so an opportunity-less quote drafts its
contract normally. The two consequences of acceptance are now independent: a
contract that will not draft no longer decides whether the deal is won, and
each failing leg is reported by name so the log says what happened.
`crm_contract.crm_contact` stays required — a contact-less quote still drafts
no contract, now refused honestly as "Primary Contact is required" and no
longer at the cost of the opportunity, exactly as content/docs/sales/quotes.mdx
already describes.
Fixes #714
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
yinlianghui
marked this pull request as ready for review
August 7, 2026 02:05
This was referenced Aug 7, 2026
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 #714
现象
接受一张没有同时挂
crm_contact和crm_opportunity的报价时,quote_on_accepted整条链断掉:合同不起草,即便报价挂着 opportunity(只缺 contact)也不会 close-won。hook 是async: true+onError: 'log',接受写本身照样 200,用户完全无感知。前提复核(fresh origin/main + rc.3):部分成立
代码缺陷依然在,但 rc.3 下症状与 issue(rc.2 时代)描述的不一样——因为 ADR-0104 的 value-shape 校验在 rc.3 变成了 warn-first。实测两种 posture:
crm_contact: false的结果os migrate value-shapes --apply)[value-shape] … accepted for now警告,false被写进 reference 列OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1)ValidationError: Primary Contact has an invalid lookup value: Invalid input: expected string, received boolean—— 与 issue 日志逐字一致也就是说:issue 描述的「合同不起草 + close-won 不执行」在 strict 下完全复现(rc.2 的默认行为,也是
os migrate value-shapes之后的归宿);在 rc.3 的 warn-first 默认下,它变形成了静默写脏数据——合同起草了,但crm_contact = false落库,正是 value-shape 扫描日后无法转换的那类行。两种结果都错,根因是同一个。顺带实测到 issue 未单独点名的一条:有 contact、缺 opportunity 的报价在 strict 下同样被拒(
Related Opportunity has an invalid lookup value),即合同也起草不出来。根因
src/objects/quote.hook.ts的 id 取值写成:两个操作数都不成立时,表达式的值不是
undefined而是 booleanfalse,并被原样当作 lookup 的值塞进合同 insert。lookup 列只接受 record id 或什么都不填,false两者都不是。第二处根因是顺序耦合:起草合同与 close-won 原本是一条直线,合同 insert 一抛,后面的 close-won 就永远到不了——成交是否推进,取决于合同能不能起草。
修法
pickId(...candidates):把「这里没有 id」的所有形态(缺键、null、空串、类型不对)统一收敛成undefined;false也不是null(实测:null对可选的crm_opportunity合法,对必填的crm_contact不合法,统一用缺键这一种写法);onError: 'log'因此能记到真实原因(could not draft the contract for quote …),而不是什么都记不到。没有动的是
crm_contract.crm_contact的required + notNull:缺 contact 的报价仍然起草不出合同,但现在是诚实的Primary Contact is required,而不是received boolean,并且不再连累 close-won。这与content/docs/sales/quotes.mdx已经写给销售的口径一致——「what the quote does not carry, acceptance cannot pass on」。验证
新增
test/quote-accepted-lookups.test.ts(13 例),分三层:crm_contact: false,这正是既有覆盖一路绿灯的原因;runHookBody下发的 body-only 源码,证明undefined过 JSON 边界会丢键、引擎 facade 的 rejection 在 VM 内可被 catch;crm_contractmetadata 起一个 ObjectQL,把上面 stub 的判决钉在内核的判决上(strict 下拒 boolean、接受本次修好的文档、缺 contact 报Primary Contact is required;并单独钉住 warn-first 下false被存下来这件事)。反向验证(先预判方向再跑):把 hook 还原成 origin/main 版本,13 例中 11 例转红,红的方向与预判一致——
保持绿的 2 例是只量引擎、不量 hook 的那两例(strict 下拒 boolean、warn-first 下收下 boolean)——平台行为没变,它们本就该两边都绿,这是正确的方向而不是漏网。
close-won 那条腿另做了一次直接测量(合同 insert 按内核的方式拒绝时):
六门自检(rc.3,未改任何
@objectstack/*版本):pnpm validate✅ ·pnpm lint✅(13 warning / 14 suggestion,全部既有)·pnpm typecheck✅ ·pnpm hygiene✅ ·pnpm build✅pnpm test✅78 files / 1835 passed, 1 skippedpnpm test:coverage✅ 分支 84.51%(阈值 78)·quote.hook.ts行覆盖 100%node scripts/check-stackblitz-lock.mjs✅留给维护者的一个问题(未在本 PR 决定)
crm_quote.crm_contact有意可选,而crm_contract.crm_contact必填,所以缺 contact 的报价被接受后合同仍然起不出来——本 PR 只让它失败得诚实且不再连累成交。但 quote schema 自己的注释写着 "Recipient is nailed down by the time a quote is presented",这个意图没有任何东西在执行:没有规则拦住一张没有 contact 的报价走到presented/accepted。是否要把它变成强制(例如crm_quote.crm_contact在presented起requiredWhen),会改变报价何时可被呈现,并影响存量数据,属于产品决策,留给维护者。Generated by Claude Code