Skip to content

docs(contracts): 按视图、选项集、hook 与 profile 写实合同页其余四处 (#832) - #874

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-832-contracts-residual-four
Aug 6, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-832-contracts-residual-four

Conversation

@yinlianghui

Copy link
Copy Markdown
Contributor

Fixes #832

按 issue 列出的四处,以 src/ 为事实来源在最新 main 上逐条重定位、重核、改写,三语同步。仅动文档,src/** 一行未改。

前提复核(基线漂移)

issue 基线 4c127919,本 PR 基线 ed6885e2,其间 #831(同页三处)已合并。四处在最新 main 上全部仍在,只是行号整体下移;#831 改的是到期扫描、续约提醒、记录字段表,与这四处无重叠。

# 本条 issue 给的行号 最新 main 行号(三语一致) 前提
1 标准列表视图 :115-121 :139-145 成立
2 幽灵选项 :44-62 :56-74 成立
3 内置规则 :99-103 :123-127 成立;其中一句措辞需按实测修正
4 给销售代表的提示 :129-132 :153-156 部分成立;商机链接的来源与 issue 所述不同

两处与 issue 正文/派发口径不一致,按实测写,不按模板写:

  • 第 3 条:状态机不拦保存。 contract_status_progression 是 severity: 'warning'(src/objects/contract.object.ts:260)。实测引擎侧 @objectstack/objectql 的 evaluateValidationRules:只有 severity === 'error' 才进 errors 并抛 ValidationError,其余级别只 logger.warn 后放行(node_modules/@objectstack/objectql/dist/core.mjs:2434-2441)。所以「draft 不能直跳 activated / expired 终态」不是一道闸,是一条日志;页面按「建议不是闸门」写,而不是再造一个假护栏——本条要修的正是假护栏。另:该规则没有 initialStates,insert 分支直接返回 null(同文件 :2475-2485),所以新建时就是 activated 的记录根本不受检查。
  • 第 4 条:商机链接确实有来源,只是不在 issue 查过的目录里。 issue 的 grep 只覆盖 src/actions/、src/flows/;但 src/objects/quote.hook.ts:95-152 的 quote_on_accepted 在报价转 accepted 时直接 insert 一份合同,并把报价上的 crm_opportunity、crm_account、crm_contact、owner_id、total_price 一起抄过去。所以「链接被预填」的说法在人工建合同这条路上不成立(crm_opportunity 只有 dependsOn: ['crm_account'],无 defaultValue),但整段前提并非全不成立:链接是从报价抄来的。改文按这个真实机制写。sales-rep 权限那半边完全成立(见下)。

逐条证据与改文

1. 标准列表视图 — 五条幽灵 → 一份数据 + 四种可视化

src/views/contract.view.ts 只存了一份列表 all_contracts(:14-43,无任何过滤),另三条在 listViews(:45-90),四者靠 appearance.allowedVisualizations(:34-36)与 tabs[](:37-42)拼成同一份数据的四种画法。按 #792 在 sales 列表视图上的先例改成一张「标签 / 它怎么画同一批合同」表,并写明四个标签都不过滤——读者要的「我的活跃合同」得自己在网格上排序过滤,替他盯日期的是每日续约提醒而不是某个视图。五个幽灵名字点名说明「不存在」,不静默删掉。zh 视图名按语言包(src/translations/zh-CN.ts:561-566):全部合同 / 续约日历 / 合同条款 / 合同时间线。

2. 幽灵选项 — Custom / Net 45 / Prepaid

  • 计费周期:src/objects/contract.object.ts:131-140 只有 monthly(default) / quarterly / annually / one_time,无 Custom。
  • 付款条款:src/objects/_picklists.ts:83-89 的 PAYMENT_TERMS_OPTIONS 只有 net_15 / net_30(default) / net_60 / net_90 / due_on_receipt,无 Net 45、无 Prepaid。

两处按包写实,并明说这三个值不存在、对应诉求写进「特殊条款」。zh 选项名按语言包(zh-CN.ts:535-545):按月 / 按季度 / 按年 / 一次性;15 天账期 / 30 天账期 / 60 天账期 / 90 天账期 / 货到付款。

3. 内置规则 — 删假护栏,补真会拦保存的规则

  • 第 1 条保留:end_after_start(contract.object.ts:239-246,severity: 'error'),对象上唯一一条会把合同挡回去的校验规则。
  • 第 2 条归位:合同金额非负是 contract_value 的 min: 0 字段约束(:122-129),不是校验规则;引擎侧数值字段的 min 在字段校验层拒绝(core.mjs:2081-2083)。顺带写上 contract_term_months 的 min: 1。
  • 第 3 条删除:crm_contract 没有 beforeDelete,全仓 beforeDelete 只在 contact.hook.ts / product.hook.ts / account.hook.ts 上;也没有任何按 status 拦删除的校验。删除权限完全由 profile 决定且与状态无关:system-admin.profile.ts:28 allowDelete: true(已激活的也能删)、sales-manager.profile.ts:24 与 sales-rep.profile.ts:52 都是 allowDelete: false(草稿也删不掉)。页面改为直说这件事,并把「要拦得管理员自己配」讲明。
  • 补上真会拦保存的两条 hook 规则:contract.hook.ts:54-61(end_date 与 start_date + contract_term_months 相差超 1 个月抛错)、:63-73(激活后把 end_date 往前改抛错)。状态机按上面的实测写成「建议不是闸门」。

4. 给销售代表的提示 — 按 profile 与 hook 实况改写

  • sales-rep.profile.ts:52:crm_contract 是 allowCreate: false, allowEdit: false, allowDelete: false, readScope: 'own' —— 销售代表既建不了也改不了合同,只读自己的。能建的是 sales-manager.profile.ts:24 与 system-admin.profile.ts:28。
  • 合同怎么来:quote.hook.ts:95-152 的 quote_on_accepted,在报价被接受时起草一份 draft 合同(12 个月、今天起算、contract_type: 'subscription'、金额取报价总价),商机链接是从报价抄的;owner_id 也从报价抄,所以这份合同挂在这位代表名下,他读得到但改不了。
  • 人工那条路不预填:contract.object.ts:74-79 的 crm_opportunity 只有 dependsOn: ['crm_account'](按已选客户过滤选择器),没有 defaultValue。
  • 「它驱动售后报表」也不成立:src/reports/、src/datasets/、src/dashboards/ 里 crm_contract 零命中,本仓没有任何合同报表或仪表盘。

段名「给销售代表的提示」保留,三条提示改写为:合同不是你填出来的(接受报价才是起草动作)、商机链接是抄来的不是预填的、自己的合同你读别的事去找经理/管理员。

quotes 页核查(只核不改)

PAYMENT_TERMS_OPTIONS 与 Quote 共用(quote.object.ts:178-183),所以顺带核了报价页:content/docs/sales/quotes.mdx:37 写的是 Net 15 · Net 30 (default) · Net 60 · Net 90 · Due on Receipt,与包完全一致,zh-Hans / zh-Hant 同(:37)——同批失真没有落在 quotes 页,本 PR 不改它。

核查过程中另发现两处与本单无因果的残留,已按去重纪律处理,未在本 PR 内改动:报价页 :71 的「接受即提升客户 / 60 天前排续约任务」两句;以及本页「手动终止」小节的两句(所有者能改状态、已终止不能复活)——后者与本 PR 第 3 条的实测结论直接冲突,留待单独处理。已分别立单:#871(报价页接受链路多写两步)、#872(本页「手动终止」两句)、#873(观察:共用词表的理由未被实现)。已知相关未修缺陷:#714(缺 contact 或 opportunity 的报价被接受时,同一个 hook 静默失败、合同不起草)。

验证(共享锁 + 4G 堆,全部在本工作树)

步骤 退出码 关键行
pnpm validate 0 Data: 17 Objects 344 Fields / UI: 14 Views(5 条既有 author-time 警告,与本 PR 无关)
pnpm typecheck 0 tsc --noEmit 无输出
pnpm lint 0 13 warning(s), 14 suggestion(s)(全部既有)
pnpm hygiene 0 ✓ no raw control bytes in first-party files / ✓ source hygiene clean
pnpm build 0 ✓ Build complete / Artifact: dist/objectstack.json (1921.3 KB)
pnpm test -- --maxWorkers=2 0 Test Files 66 passed (66) / Tests 1587 passed · 1 skipped (1588)

test 中 source-hygiene 元测试打印的「✗ ...」是它自己的预期 stderr。改动文件另做控制字节自扫(grep -naP 扫 0x00-0x08、0x0b、0x0c、0x0e-0x1f 这些区间),零命中。

散文不加钉子测试(issue 明确要求),也没有升级任何 @objectstack/*。


Generated by Claude Code

…iews, picklists, hook and profiles (#832)

The Contracts page still described five saved list views that do not exist,
three picklist values that cannot be picked, a delete guard nobody built, and a
tip addressed to the one persona the object refuses.

- Standard list views: `src/views/contract.view.ts` saves one unfiltered list
  (`all_contracts`) offered in four visualisations via `allowedVisualizations`
  and `tabs[]`. Rebuilt as one dataset drawn four ways, per the #792 precedent,
  and the five ghost views are named as absent.
- Billing frequency / payment terms: no `Custom`, no `Net 45`, no `Prepaid`.
  Both lists now match `contract.object.ts` and `_picklists.ts`, with the zh
  option labels taken from the locale pack.
- Built-in rules: "Activated contracts cannot be deleted" has no implementation
  (no beforeDelete, no status check; deletion is profile-only and status-blind).
  Replaced with the rules that really reject a save — `end_after_start` and the
  two throws in `contract.hook.ts` — plus the two things that are not rules:
  the `min: 0` field constraint, and the state machine, which is declared at
  warning severity and therefore logs without blocking.
- Tips for sales reps: `sales-rep.profile.ts` grants crm_contract read-only, and
  nothing pre-fills the opportunity link. The real path is `quote_on_accepted`
  drafting the contract from the accepted quote.

Documentation only, all three languages. No metadata or behaviour changes.
@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 1:14am

Request Review

@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 01:18
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 6cd53d2 Aug 6, 2026
9 checks passed
This was referenced Aug 6, 2026
yinlianghui added a commit to yinlianghui/hotcrm that referenced this pull request Aug 10, 2026
…files and the status machine (objectstack-ai#872) (objectstack-ai#878)

Three sentences on the Contracts page contradicted what PR objectstack-ai#874 measured on the
same page, in all three languages.

- "The owner can change status to Terminated" is false for the most common
  owner: edit on crm_contract is object-level and ownership does not extend it.
  sales-rep.profile.ts grants allowEdit: false, and quote_on_accepted copies the
  quote's owner_id onto the contract it drafts, so the rep owns a record they can
  read and cannot touch. Sales Manager and System Administrator hold edit.
- "Terminated contracts can't be reactivated" is a gate nobody built:
  contract_status_progression declares terminated a dead end at warning severity,
  and a non-error verdict is logged while the save proceeds. The one-way advice
  is kept, but the page now says nothing enforces it.
- "The contract owner reads and edits their own contracts" conflated the two
  access layers: record-level sharing decides which records you reach, the
  profile's object-level CRUD decides whether you hold edit at all.

Documentation only. No metadata, profile, permission or validation-rule changes.

Co-authored-by: Claude <noreply@anthropic.com>
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