Skip to content

fix(quote): an absent link on an accepted quote is an absent key, not false (#714) - #1013

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-714-quote-accepted-false-lookup
Aug 7, 2026
Merged

fix(quote): an absent link on an accepted quote is an absent key, not false (#714)#1013
yinlianghui merged 1 commit into
mainfrom
claude/issue-714-quote-accepted-false-lookup

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #714

现象

接受一张没有同时挂 crm_contactcrm_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:

deployment posture crm_contact: false 的结果
warn-first(默认,未跑 os migrate value-shapes --apply) 放行,只打 [value-shape] … accepted for now 警告,false写进 reference 列
strict(跑过该 gate,或 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 取值写成:

const contactId =
  (typeof input.crm_contact === 'string' && input.crm_contact) ||
  (typeof previous?.crm_contact === 'string' && previous.crm_contact);

两个操作数都不成立时,表达式的值不是 undefined 而是 boolean false,并被原样当作 lookup 的塞进合同 insert。lookup 列只接受 record id 或什么都不填,false 两者都不是。

第二处根因是顺序耦合:起草合同与 close-won 原本是一条直线,合同 insert 一抛,后面的 close-won 就永远到不了——成交是否推进,取决于合同能不能起草。

修法

  1. pickId(...candidates):把「这里没有 id」的所有形态(缺键、null、空串、类型不对)统一收敛成 undefined;
  2. 合同文档只写真正有的 lookup——缺失的链接是缺键,不是 false 也不是 null(实测:null 对可选的 crm_opportunity 合法,对必填的 crm_contact 不合法,统一用缺键这一种写法);
  3. 两条腿互相独立:各自 try/catch,失败按名字收集,最后一起抛出,onError: 'log' 因此能记到真实原因(could not draft the contract for quote …),而不是什么都记不到。

没有动的是 crm_contract.crm_contactrequired + 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 例),分三层:

  • 闭包层:断言的是 hook 交给引擎的文档,不是 stub 存下的行——仓内两个 harness 都会照单全收 crm_contact: false,这正是既有覆盖一路绿灯的原因;
  • QuickJS 层:跑 runHookBody 下发的 body-only 源码,证明 undefined 过 JSON 边界会丢键、引擎 facade 的 rejection 在 VM 内可被 catch;
  • 真引擎层:用 app 真实的 crm_contract metadata 起一个 ObjectQL,把上面 stub 的判决钉在内核的判决上(strict 下拒 boolean、接受本次修好的文档、缺 contact 报 Primary Contact is required;并单独钉住 warn-first 下 false 被存下来这件事)。

反向验证(先预判方向再跑):把 hook 还原成 origin/main 版本,13 例中 11 例转红,红的方向与预判一致——

× drafts a contract for a quote with NO contact — and writes no crm_contact
  → AssertionError: crm_contact must be OMITTED, not written as false/null
× every lookup it DOES write is a record id — quote with no contact
  → AssertionError: crm_contact reached the engine as false, which is not a record id
× still pushes the linked opportunity to closed_won
  → expected [Function] to throw error matching /could not draft the contract…/ but got 'Primary Contact is required'
× accepts the document the hook builds when the quote has no opportunity
  → expected 'Related Opportunity has an invalid lo…' to be null
Tests  11 failed | 2 passed (13)

保持绿的 2 例是只量引擎、不量 hook 的那两例(strict 下拒 boolean、warn-first 下收下 boolean)——平台行为没变,它们本就该两边都绿,这是正确的方向而不是漏网。

close-won 那条腿另做了一次直接测量(合同 insert 按内核的方式拒绝时):

修复前: handler threw: Primary Contact is required
        opportunity stage after accept: proposal      ← 成交没推进
        crm_opportunity update calls: 0
修复后: handler threw: quote_on_accepted: could not draft the contract for quote q1: Primary Contact is required
        opportunity stage after accept: closed_won    ← 推进了,且失败仍被如实上报
        crm_opportunity update calls: 1

六门自检(rc.3,未改任何 @objectstack/* 版本):

  • pnpm validate ✅ · pnpm lint ✅(13 warning / 14 suggestion,全部既有)· pnpm typecheck ✅ · pnpm hygiene ✅ · pnpm build
  • pnpm test78 files / 1835 passed, 1 skipped
  • pnpm 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_contactpresentedrequiredWhen),会改变报价何时可被呈现,并影响存量数据,属于产品决策,留给维护者。


Generated by Claude Code

… `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
@vercel

vercel Bot commented Aug 6, 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 6, 2026 6:53pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants