Skip to content

fix(metadata): 明细行随父记录级联删除(opportunity / quote) - #1006

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-727-line-item-delete-cascade
Aug 7, 2026
Merged

fix(metadata): 明细行随父记录级联删除(opportunity / quote)#1006
yinlianghui merged 1 commit into
mainfrom
claude/issue-727-line-item-delete-cascade

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #727

现象

在 fresh origin/main + @objectstack/* 17.0.0-rc.3 上复验,issue 前提依然成立:只要商机/报价挂过明细行(即用产品目录配过价的正常状态),父记录就删不掉,且拒绝文案把一条调用方根本够不着的元数据键甩给了 API 使用者:

DELETE /api/v1/data/crm_opportunity/{id}
→ 409 DELETE_RESTRICTED "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."

crm_quotecrm_quote_line_item 是同一句话换名词。唯一的绕行是先手工把明细行一条条删干净。

根因

不是引擎缺陷,是一个没人写下来的默认值

crm_opportunity_line_item.crm_opportunitycrm_quote_line_item.crm_quote 都没声明 deleteBehavior。「不声明」不等于「没意见」:spec 把该字段落到默认值 set_null(rc.3 实测,解析后的字段字面量就是 "deleteBehavior":"set_null",而源码里一个字都没写),而引擎的 cascadeDeleteRelations 会把 required lookup 上的 set_null 默认升级成 restrict —— NOT NULL 列没法置空。所以两个对象的源码里没有任何一处表达过「禁止删除父记录」,这个行为完全来自那条隐式默认。

契约核验(改之前的硬前置)

在动手前先确认 rc.3 契约支持这个键,而不是写平台 workaround:

  • node_modules/@objectstack/spec/src/data/field.zod.ts:463
    deleteBehavior: z.enum(['set_null', 'cascade', 'restrict']).optional().default('set_null')
    —— 键存在,取值面含 'cascade'
  • node_modules/@objectstack/objectql/dist/core.js:6541
    behavior = fdef.type === "master_detail" ? … : fdef.deleteBehavior || "set_null",随后
    if (behavior === "set_null" && fdef.required === true) behavior = "restrict"
    behavior === "cascade" 分支逐条 this.delete(childName, …)
    —— 引擎确实消费它,且级联发生在父记录 driver.delete 之前core.js:6626)。

契约支持,故按 issue 的方案一实施。

修法

两个父 lookup 各加一行 deleteBehavior: 'cascade',并把「为什么是 cascade、为什么不是 restrict」写进对象源码。明细行在构造上就是从属记录,这一点两个对象本来就说了两遍:对象头注释写着 line item 离开它的 deal 就没有意义,而 rollup hook 把 crm_opportunity.amount 和报价的 subtotal/total 都是明细行集合派生出来的。父记录没了还留着的明细行什么都不指代,只会让一个已删除商机的收入继续活在所有行级报表里。

范围严格限定在这两个字段,并且把边界也一并钉住:

验证

新增 test/line-item-cascade.test.ts,沿用仓内已有的 *-cascade.test.ts 家族形态(结构断言 + 真引擎端到端放在同一个文件,理由见 campaign-member-cascade.test.ts 的说明):结构上钉住两个父 lookup 解析值为 cascade、两个 product lookup 仍为 set_null;行为上在 ObjectQL + InMemoryDriver 上跑真删除 —— 挂明细的商机/报价删得掉且明细随删、别的 deal 的行和同产品的报价行都不受影响、没挂明细的商机照常删、引用中的产品仍被 restrict 拦住并报出真正的阻塞方。

反向验证(先定方向再跑):预期把两行声明摘掉后 2 条结构断言 + 2 条端到端删除转红,实测 5 failed | 4 passed —— 多红的一条是跨 deal 隔离用例(它同样依赖级联真的发生),4 条绿的正是不依赖级联的边界断言。方向与预期一致,非空转通过。

六门:

pnpm validate   ✓ Validation passed (1520ms)
pnpm typecheck  ✓ tsc --noEmit 无输出
pnpm lint       ✓ 13 warning(s), 14 suggestion(s)(均为既有信息级项)
pnpm hygiene    ✓ source hygiene clean(含控制字节扫描)
pnpm build      ✓ Build complete,17 Objects 344 Fields
pnpm test       ✓ Test Files 78 passed,Tests 1831 passed | 1 skipped

changeset:.changeset/line-item-cascade-on-parent-delete.md(patch),正文按发布说明读者写清了「删商机/报价会连带删掉产品行、行级收入报表相应下降」这个用户可见后果。

未做的事

没有改 content/docs/ —— 同族的 #712(campaign_member 级联)也没有配套文档改动,本次行为变更的用户可见说明走 changeset。若维护者认为应当在 sales/opportunities.mdx / sales/quotes.mdx 三个语言版本各补一条删除语义说明,可另开单,我不在本单里顺手加。


Generated by Claude Code

…deleted

Both line-item objects hang off their parent through a REQUIRED lookup that
declared no `deleteBehavior`. That resolves to the spec default `set_null`,
and the engine escalates a `set_null` default on a required lookup to
`restrict` (a NOT NULL column cannot be cleared) — so every itemised
opportunity or quote refused to delete with a 409 DELETE_RESTRICTED whose
message told the API caller to set `deleteBehavior:'cascade'` on a metadata
key they cannot reach.

Declare `deleteBehavior: 'cascade'` on `crm_opportunity_line_item
.crm_opportunity` and `crm_quote_line_item.crm_quote`. A line item is
subordinate by construction: both objects state a line has no meaning apart
from its parent, and the rollup hooks derive the parent's amount / totals
FROM the line set.

Scope is exactly those two fields. The `crm_product` lookup on both objects
stays on the restricting default so retiring a catalog product cannot shred
priced history, and the campaign/event parent lookups (#696, #711) keep
restricting for the same historical-record reason.

`test/line-item-cascade.test.ts` pins the resolved declarations and runs the
deletes end-to-end on a real ObjectQL + InMemoryDriver.

Fixes #727
@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:43pm

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