Skip to content

[17.0-rc2验收] 删除挂有明细行的 opportunity / quote 被引擎 restrict 拦截:两个 line-item 对象的父 lookup 未声明 deleteBehavior:'cascade' #727

Description

@yinlianghui

环境:hotcrm@0899b4f + @objectstack 17.0.0-rc.2(基线 main;经核对当前 main 上 opportunity_line_item.object.ts / quote_line_item.object.ts 的相关声明未被 #712/#719 触及,故本缺陷在最新 main 同样成立,非基线陈旧产物)。

现象

删除任何"挂有明细行"的 crm_opportunitycrm_quote 都会被引擎以 409 DELETE_RESTRICTED 拦截,且抛出的是面向开发者的报错文案(直接把内部字段路径和 deleteBehavior:'cascade' 的修法建议暴露给最终用户/API 调用方)。必须先手动删掉全部明细行,父记录才删得掉。

DELETE /api/v1/data/crm_opportunity/<id>
→ 409 {"error":"Cannot delete crm_opportunity (<id>): 1 dependent crm_opportunity_line_item
        record(s) reference it via crm_opportunity (crm_opportunity is required, so it cannot
        be cleared). Delete or reassign them first, or set deleteBehavior:'cascade' on
        crm_opportunity_line_item.crm_opportunity.",
       "code":"DELETE_RESTRICTED","dependentObject":"crm_opportunity_line_item",...}

crm_quotecrm_quote_line_item 完全同构:

DELETE /api/v1/data/crm_quote/<id>
→ 409 "Cannot delete crm_quote (<id>): 1 dependent crm_quote_line_item record(s) reference it
       via crm_quote (crm_quote is required...). ... set deleteBehavior:'cascade' on
       crm_quote_line_item.crm_quote."

复现步骤(可粘贴执行)

BASE=http://localhost:4097/api/v1
TOKEN=$(curl -s -X POST $BASE/auth/sign-in/email -H 'Content-Type: application/json' \
  -d '{"email":"admin@objectos.ai","password":"admin123"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
H="Authorization: Bearer $TOKEN"; CT="Content-Type: application/json"

ACC=$(curl -s -X POST $BASE/data/crm_account -H "$H" -H "$CT" -d '{"name":"Repro Acct 001"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
OPP=$(curl -s -X POST $BASE/data/crm_opportunity -H "$H" -H "$CT" \
  -d "{\"name\":\"Repro Opp\",\"crm_account\":\"$ACC\",\"amount\":1000,\"stage\":\"prospecting\",\"close_date\":\"2026-12-31\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
PRD=$(curl -s -X POST $BASE/data/crm_product -H "$H" -H "$CT" -d '{"name":"Repro Prod","list_price":100,"sku":"REPRO-1"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
curl -s -X POST $BASE/data/crm_opportunity_line_item -H "$H" -H "$CT" \
  -d "{\"crm_opportunity\":\"$OPP\",\"crm_product\":\"$PRD\",\"quantity\":1,\"unit_price\":50}" >/dev/null

# 现在删除父商机 —— 被拦截
curl -s -X DELETE $BASE/data/crm_opportunity/$OPP -H "$H"
# → 409 DELETE_RESTRICTED,商机仍在;先删明细行后再 DELETE 才 200

实测:删除被拦后 GET /data/crm_opportunity/<id> 仍 200;把那条明细行删掉后重试 DELETE 即 200。已两次复现。

期望 vs 实际

  • 期望:明细行是商机/报价的从属记录 —— opportunity_line_item.hook.ts 明说 "Keeps crm_opportunity.amount in sync with the sum of its line items … once a deal is itemised, the line sum is authoritative",即父记录的头号金额本身派生自明细行;opportunity_line_item.object.ts 也写着 "A line item has no meaning apart from its deal"。删除父记录时,这些从属行理应随父一起消失(Salesforce master-detail 语义),或者至少给出面向业务用户的拒绝文案。
  • 实际:父记录删除被 409 硬拦,并把 set deleteBehavior:'cascade' on crm_opportunity_line_item.crm_opportunity 这类开发者向建议原样抛给调用方。

落点分析

引擎的行为本身是合理默认,不是引擎 bug:packages/objectql/src/engine.ts:5523-5539(cascadeDeleteRelations)对一个 REQUIRED 且未声明 deleteBehavior 的 lookup,把默认的 set_null 升级为 restrict(NOT NULL FK 不能置空)。落点在 HotCRM 元数据:

  • src/objects/opportunity_line_item.object.tscrm_opportunity: Field.lookup('crm_opportunity', { required: true, storage:{notNull:true} }) —— 无 deleteBehavior
  • src/objects/quote_line_item.object.tscrm_quote: Field.lookup('crm_quote', { required: true, ... }) —— 同样无 deleteBehavior

这与 #696 / #711 是**同一族"没人写过 deleteBehavior 导致默认落到 restrict/set_null"**的潜伏缺陷,只是症状相反:#696/#711 是"人删不掉"(party lookup 被置空撞校验),这里是"父记录删不掉"(明细行把父钉住)。

需要区别对待的一点(供裁决,不擅自下结论):#696 的验收结论明确把 campaign_member.crm_campaign / event_attendee.crm_event 的 restrict 判为"有意保留",理由是活动的成员名单是历史记录、不应随手滑级联。但那条理由不适用于 line item —— 明细行没有独立留存价值,父金额还派生自它。因此:

  1. 若认可"明细行随父级联删除"(推荐,与 Salesforce master-detail 及本仓 amount-rollup 语义一致):在两个 line-item 对象的父 lookup 上加 deleteBehavior: 'cascade'(与 fix(campaign-member): 报名过市场活动的线索/联系人恢复可删除 —— 参与行随人级联删除 (#696) #712 给 campaign_member party lookup 的修法同形)。
  2. 若刻意保留 restrict(防止误删丢失明细/金额分解),则应把这条引擎默认文案替换成面向业务用户的拒绝说明(参照 删除被级联守卫拦下时,报错文案指向了错误的对象("删除客户"报成"Cannot delete contact") #693 对"文案指错受众"的处置),而不是把 deleteBehavior:'cascade' 的内部建议丢给用户。

证据

  • 商机:409 DELETE_RESTRICTED,dependentObject: crm_opportunity_line_item;删明细行后重删 200
  • 报价:409 DELETE_RESTRICTED,dependentObject: crm_quote_line_item
  • 对照:crm_account → crm_contact(真 master-detail,deleteBehavior:'cascade')删除父客户会正确级联删子联系人(实测 200 且子记录 GET 404),证明"级联删除"路径在本应用里是通的,唯独两个 line-item 对象没走这条路。

严重度建议 p2:有绕行(先删明细行),非数据丢失/越权;但属正常生命周期操作被非预期拦截 + 文案受众错配,建议纳入 17.0 收口裁决。

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingmetadataDeclarative metadata — schema, security posture, UI surfacespm:dispatchedDispatched to a dev agent by /pm-dispatchprio:p2Triaged backlog — no near-term schedule

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions