Skip to content

feat(opportunity): require a win/loss reason at close, and measure win rate (#593) - #657

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-593-win-loss-reasons
Aug 2, 2026
Merged

feat(opportunity): require a win/loss reason at close, and measure win rate (#593)#657
os-zhuang merged 2 commits into
mainfrom
claude/issue-593-win-loss-reasons

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #593

crm_opportunity.win_reason / loss_reason / loss_details 从对象写下来那天就存在,loss_reason 的注释甚至写着 "required when stage moves to closed_*" —— 但没有任何东西在要求它。两列在所有记录上都是空的(种子数据也一样),也没有任何报表或组件读它们。声明了、没强制、没人用。


1. 捕获:requiredWhen,而且是量出来的

正文说"用 requiredWhencross_field 校验",这个"或"字底下就是本仓库这几轮反复踩的那族坑。所以两条路子都先实测了一遍,再挑能真正挡住写入的那条:

机制 关单不带原因时
字段级 requiredWhen 写入被拒(insert 与 update 都拒),记录停在原 stage
script 校验(severity: error) 写入被拒,记录停在原 stage
crm_case.resolution_required_for_closed(正文点名的现成范式) 今天确实在挡写入 —— 先量了再决定要不要照抄

两条都能挡,选 requiredWhen,理由和 crm_lead.duplicate_of_lead 用它的理由一样(ADR-0113):"这条记录长成 X 样时这个字段必须有值"本来就是条件写入契约;引擎在 insert 和 update 都会在 evaluateValidationRules 里求值;而且它报在字段上,表单会直接标红那个空下拉框,而不是弹一条记录级横幅。

它还是唯一和现有冻结逻辑能配合的形状:opportunity.hook.ts 在商机关单后会拒绝除叙述性字段外的一切用户编辑,所以关单那一次写入没记下的原因,之后永远补不上。关单时是唯一的机会。

has(record.stage) 是承重的,不是装饰:裸写 record.stage == "closed_lost" 在任何 merged 记录缺这个 key 时会以 No such key 中断,而引擎对无法求值的谓词的处理是跳过——规则读起来像强制的,实际什么都不要求。

验收证据(真驱动 + 真写入路径,两种驱动都跑):

✓ refuses to close a deal as LOST with no loss_reason
    → ValidationError: Loss Reason is required   |  stage after attempt: negotiation
✓ refuses to close a deal as WON with no win_reason
✓ refuses an INSERT that lands directly in a closed stage
✓ refuses to blank the reason out of a deal that is already closed
✓ leaves writes that do not close anything alone
✓ the write is REJECTED on a real SQLite database too
✓ crm_case.resolution_required_for_closed is still live

不是 WARN,不是"dry-run 说不行但写入放行"—— 每条拒绝都同时断言了记录没有移动

以及一层更靠外的证据:真 REST 写入路径。 加上规则后,三条写于"关单不需要理由"年代的 e2e fixture 直接被内核拒了——它们不知道有这条规则,照旧 PATCH 了一个没有理由的关单:

PATCH /api/v1/data/crm_opportunity/:id  { "stage": "closed_won" }
→ 400 {"error":"Win Reason is required","code":"VALIDATION_FAILED",
       "fields":[{"field":"win_reason","code":"required","label":"Win Reason"}],
       "object":"crm_opportunity"}

这是独立于本 PR 自己写的测试得到的验收证据,而且落在真内核 + demo 真正启动的那个数据库上。注意 fields[].fieldwin_reason——错误报在字段上,这正是选 requiredWhen 而非记录级 script 校验的收益。

fixture 已补上理由(best_fit / no_budget / better_price,不用 quote_accepted:那个值是 CPQ 自动化路径的标记,人手关单用它会弄脏这个区分)。同时新增 e2e/opportunity-win-loss-capture.spec.ts,把这次拒绝变成常驻断言而不是"别的用例顺带炸了"——后者在 fixture 被修好的那一刻就消失了:

  • PATCH 到两个关单阶段各自缺理由 → 400,且记录仍停在 negotiation(拒绝 + 写入没落地,这才区分得开 Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633 那种"报错但写入照样成功");
  • POST 直接落在已关单阶段 → 400(导入 / API 集成走的分支);
  • 补上理由后同一次关单成功(反向守卫:否则"API 干脆不接受 stage 变更"也能让上面全绿);
  • 服务器实际加载的每条已关单记录都带理由——从启动后的库里读回来,这是唯一能看到"新规则下种子跑完是什么结果"的方式。

win_reason 新增一个 quote_accepted

quote_on_accepted 在客户接受报价时自动把商机置为 closed_won,这一次写入里没有人可以归因。加上 requiredWhen 后这条 CPQ 链路会被引擎直接拒掉。

处理方式是给这条自动化路径起个名字,而不是替销售编一个 "Better Product":这正是本仓库既有的做法(crm_lead.duplicate_status = 机器的 suspected vs 人的 confirmed)。规则因此没有例外分支——每条 closed_won 都带原因,而分析时仍能把 CPQ 成交和销售自己的归因区分开。


2. 分析:比值的两半都必须承重

#614 把 150 万的季度配额显示成 7,940,000,根因是过滤器少了一半,而且不会报错——分母写错只会给出一个看起来合理的数。胜率是同一个形状。

因此:比值的两半都来自 dataset 的 measure 过滤器,绝不来自 widget 过滤器。widget 级的 stage 过滤会同时收窄分子和分母,那正是比值悄悄变成"自己除自己"的方式。

{ name: 'won_count',     aggregate: 'count', filter: { stage: 'closed_won' } },
{ name: 'lost_count',    aggregate: 'count', filter: { stage: 'closed_lost' } },
{ name: 'decided_count', aggregate: 'count', filter: { stage: { $in: [ 'closed_won', 'closed_lost' ] } } },
{ name: 'win_rate',      derived: { op: 'ratio', of: [ 'won_count', 'decided_count' ] }, format: '0%' },

每个按维度拆分的胜率都是表格而不是柱状图:一根写着 67% 的柱子无法证伪,一行 "Won 2 · Lost 1 · Settled 3 · 67%" 自带算式。

证据 A —— 出厂种子跑通真 dataset executor(用的是已发布的 widget 绑定)

win_rate_by_lead_source:

lead_source won_count lost_count decided_count win_rate
content 2 1 3 66.7%
web 2 1 3 66.7%
referral 1 1 2 50.0%
partner 1 1 100%
event 1 1 100%
email_campaign 1 1 100%
cold_call 1 1
advertisement 1 1

loss_reason_breakdown: timing 1 · no_budget 1 · competitor 1 · price 1 · features 1

证据 B —— 扰动表:改任一半,数字都要动

扰动 won_count decided_count win_rate
(基线,出厂种子) 8 13 61.5%
把一笔赢单改成丢单 7 13 53.8%
把一笔丢单改成赢单 9 13 69.2%
新增 5 笔进行中商机(每笔 999,999) 8 13 61.5%

第 2、3 行让分子承重(两个方向都验),第 4 行让分母承重——分母不是"所有商机"。#614 的配额表通不过第 4 行的等价检查。

这些断言不是摆设:把 win_rate 的分母从 decided_count 改成 opp_count,9 条断言同时变红;把任意一条已关单种子的原因删掉,种子守卫和"引擎能否放行种子"两条断言变红。


3. i18n

win_reason / loss_reason 现在四个语言包全覆盖 —— 此前 ja-JPes-ES 完全缺失,而这两个字段现在是必填并且进了图表图例,缺翻译等于把 no_budgetquote_accepted 这样的存储值直接摆到销售必须选的下拉框和图例里。test/metadata-references.test.ts 的待办账本里这两条随之删除(账本只许缩短)。

crm_opportunity.approval_status 的 ja/es 缺口保留在账本里:它和本次改动无关,属于 #645 的整体清扫,不在本 PR 范围内。


顺带量到的平台缺口(已单独立 issue,未在本 PR 绕过)

#656 —— 带 filter 的 measure 对"过滤后一行都不剩"的分组返回的是缺列而不是 0,derived ratio 遇到缺列输入直接算成 null。所以上表里 cold_call(0 赢 1 丢)显示空白而不是 0%。

没有在消费端用 ?? 0 糊掉——那正是本仓库反复付学费的"宽容消费者"习惯,修复属于 executor。当前行为被 test/win-loss-capture.test.ts 钉住,将来平台修好时会以该断言变红的方式暴露出来。发布的表格把 lost_count 放在 win_rate 旁边,就是为了让空白读作"0 胜 1 负"而不是"没有数据"。

同样出于这个原因,decided_count 是独立的过滤计数,而不是 derived: { op: 'sum', of: [ 'won_count', 'lost_count' ] }:后者会让任何一个从未丢过单的销售(100% 胜率)的分母变空。


种子

每笔已关单的种子都带原因——引擎现在会因为缺原因而拒绝种子写入,所以这不再是"demo 数据不好看",而是启动被挡。新增 3 笔丢单,使原因分布覆盖 5 个不同值,并且 web / referral / content 三个来源同时有赢有丢(否则按来源的胜率表全是 100% 和空白,分母错了也没人看得出来)。出厂即 8 赢 5 丢、61.5% 胜率。

验证

pnpm verify   →  validate ✓ · typecheck ✓ · lint 1 warning / 13 suggestions(与 main 基线一致)
                 hygiene ✓ · build ✓ · Test Files 46 passed · Tests 1112 passed | 1 skipped

pnpm test:e2e →  16 passed (52.3s)
                 ✓ opportunity-lifecycle.spec.ts         5
                 ✓ opportunity-win-loss-capture.spec.ts  5  ← 新增
                 ✓ smoke.spec.ts                         6

新增 test/win-loss-capture.test.ts(37 条)与 e2e/opportunity-win-loss-capture.spec.ts(5 条)。test/flow-condition-totality.test.tse2e/opportunity-lifecycle.spec.ts 各有 fixture 补上了理由——那正是新规则在起作用,不是绕过。

注:pnpm testpnpm verify 都不含 e2e,所以改写入契约时必须单独跑 pnpm test:e2e

未做人工浏览器巡检:三个新组件用的都是 metric / table / donut,都是仓库里已在跑的组件类型;数字这一面用真 executor 跑出来的表格比截图更强,渲染这一面由上面的 e2e 覆盖启动与数据可读。

一处不属于本 PR 的既有噪声(未处理,仅备案)

e2e 启动日志里有一批 [runAs] refusing a data operation ... no trigger user could be resolved 的 ERROR,来自 case_escalation / opportunity_approval 等流程的触发运行,任何商机/工单写入都会触发,与本次改动无关(main 上同样存在),属于 #633 那一族。没有在本 PR 里动它。

🤖 Generated with Claude Code

https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf


Generated by Claude Code


Generated by Claude Code

…n rate (#593)

`crm_opportunity.win_reason` / `loss_reason` / `loss_details` existed since the
object was written, and `loss_reason` even carried the comment "required when
stage moves to closed_*" — while nothing required anything. Both columns were
empty on every record, seeded ones included, and no report or widget read them.

Capture: both fields carry a `requiredWhen` predicate (`loss_reason` on
closed_lost, `win_reason` on closed_won), evaluated by the engine on insert AND
update and reported against the field. Measured against a real ObjectQL over
both `InMemoryDriver` (sparse stored rows) and a real SQLite database: the write
is REJECTED and the record stays in its previous stage — not a WARN, and not a
dry-run refusal with the write landing anyway. `crm_case`'s
`resolution_required_for_closed`, the pattern the issue names, was re-measured
the same way before being copied and is pinned so it cannot rot silently.

`win_reason` gains a `quote_accepted` option: `quote_on_accepted` closes the
deal with no human in the write, so it names the automated path rather than
stamping a fabricated rep answer, and the rule stays exception-free.

Analytics: `opportunity_metrics` gains `won_count` / `lost_count` /
`decided_count` / `won_amount` / `lost_amount` (each with its own measure
filter), `win_rate` as a derived ratio, and `win_reason` / `loss_reason`
dimensions. The Sales dashboard gains a Win Rate (12M) tile flanked by its two
inputs, Win/Loss by Rep and by Lead Source tables, and a Why We Lose breakdown.
Both halves of the ratio come from the measures, never from a widget filter —
a widget-level stage filter narrows numerator and denominator alike, which is
how #614 shipped. Tests perturb one deal at a time and assert the rate falls,
rises, and does not move for open pipeline.

Seeds: every settled deal carries its reason (the engine now rejects the boot
otherwise), plus three more lost deals so the breakdown has five distinct
reasons and three lead sources carry both a win and a loss.

i18n: win_reason / loss_reason translated in all four locales — they were
missing from ja-JP and es-ES, which would have put raw stored values into a
required picklist and a chart legend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
@vercel

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

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Aug 2, 2026
The three `opportunity-lifecycle` fixtures that close a deal date from before
closing required a reason: they PATCH an opportunity to closed_won/closed_lost
with no reason, and the kernel now answers
`400 {"code":"VALIDATION_FAILED","fields":[{"field":"win_reason",...}]}`.

That is the rule working — and it is stronger evidence than any unit test in
this PR, because it lands on the real REST write path against the real database
the demo boots with, not in a harness. But it only existed as collateral damage
to neighbouring tests, which disappears the moment the fixtures are fixed. So:

  - the three fixtures supply a reason (`best_fit` / `no_budget` /
    `better_price`, never `quote_accepted` — that value marks the CPQ
    automation path and a hand-closed fixture using it would blur exactly the
    distinction it exists to draw), keeping them about the lifecycle hook;
  - a new `opportunity-win-loss-capture.spec.ts` makes the rejection a STANDING
    assertion: PATCH to each closed stage without a reason is 400 AND the deal
    stays at `negotiation`, POST straight into a closed stage is 400 too, the
    same close succeeds once a reason is supplied (so the suite cannot pass by
    the API refusing stage changes outright), and every settled record the
    server actually loaded carries its reason — read back from the booted
    database, which is the only way to see the result of seeding under the new
    rule.

Also names #656 in the two comments that previously said "filed upstream".

Verified locally: `pnpm test:e2e` → 16 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf

Copy link
Copy Markdown
Contributor Author

Playwright 已修复 —— 并且这次失败本身就是最强的验收证据

f847adff。先说 PM 评审里那句判断是对的:这不是缺陷,是规则在起作用,而且它落在比本 PR 任何单元测试都更靠外的一层。

失败的三条用例都是同一句报错,来自真 REST 写入路径、真内核、demo 真正启动的那个数据库:

PATCH /api/v1/data/crm_opportunity/:id  { "stage": "closed_won" }
→ 400 {"error":"Win Reason is required","code":"VALIDATION_FAILED",
       "fields":[{"field":"win_reason","code":"required",
                  "message":"Win Reason is required","label":"Win Reason"}],
       "object":"crm_opportunity"}

这正是 issue 验收标准要的那句"服务端拒绝",而且它是独立于我自己写的测试得到的——三条 fixture 写于"关单不需要理由"的年代,它们不知道有这条规则,照旧提交,然后被拒。注意 fields[].fieldwin_reason:错误报在字段上,这也正是选 requiredWhen 而不是记录级 script 校验的原因(表单能直接标红那个空下拉框)。

同时它还顺带证明了另外两件事:其余 8 条 e2e 全绿,说明带新种子的应用照常启动、种子照常写入——即"每条已关单种子都带原因"这件事在真库上成立,不只是在种子源码里成立。

改法(按评审要求,三条捷径一条都没走)

  • 三条 fixture 补上理由:best_fit / no_budget / better_price没有用 quote_accepted —— 那个值是留给 quote_on_accepted 这条 CPQ 自动化路径的标记,人手关单的 fixture 用它会把这个区分弄脏,而这个区分正是正文里论证过的东西。第 152 行那条只在"关单那一次写入"补理由,后面验证冻结/叙述性字段的部分一个字没动。
  • 没有requiredWhen 加任何环境例外或绕过分支;没有 skip / fixme 任何用例;没有把规则降级成 warning。

顺带采纳了评审最后那条建议:把拒绝变成常驻断言

评审说得对——"真 HTTP 路径上的拒绝证据目前只以'别的用例顺带炸了'的形式存在,那不是一条会一直守着的断言"。而且这种证据在 fixture 被修好的那一刻就消失了。所以新增 e2e/opportunity-win-loss-capture.spec.ts,5 条:

断言 为什么需要
PATCH → closed_lostloss_reason400,且记录仍停在 negotiation 拒绝 + 写入没落地。只断言 400 分不出 #633 那种"报错了但写入照样成功"
PATCH → closed_wonwin_reason400,记录不动 同上,另一个方向
POST 直接落在已关单阶段 → 400 导入 / API 集成走的路径;insert 在引擎里是另一条分支(缺字段会被填成 null)
补上理由后同一次关单成功 反向守卫:否则"API 干脆不接受 stage 变更了"也能让上面三条全绿
服务器实际加载的每条已关单记录都带理由 从启动后的库里读回来,这是唯一能看到"新规则下种子跑完是什么结果"的方式

本地验证(这次推之前跑了)

pnpm test:e2e  →  16 passed (52.3s)
   ✓ opportunity-lifecycle.spec.ts        5 passed
   ✓ opportunity-win-loss-capture.spec.ts 5 passed   ← 新增
   ✓ smoke.spec.ts                        6 passed

pnpm verify    →  validate ✓ · typecheck ✓ · lint 1 warning/13 suggestions(与 main 基线一致)
                  hygiene ✓ · build ✓ · Test Files 46 passed · Tests 1112 passed | 1 skipped

教训收下了:pnpm test 不含 e2e,pnpm verify 也不含,所以改写入契约时必须单独跑一遍 pnpm test:e2e

一处不属于本 PR 的既有噪声(未处理,仅备案)

e2e 启动日志里有一批 [runAs] refusing a data operation ... this run's effective runAs is 'user' but no trigger user could be resolved 的 ERROR,来自 case_escalation / opportunity_approval 等流程的触发运行。任何一次商机或工单写入都会触发,与本次改动无关(main 上同样存在),而且属于"流程运行记为 failed、触发它的写入照样成功"那一族(#633)。没有在本 PR 里动它——如需处理请单独派发。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 19:59
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 7c102d4 Aug 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Require win/loss reasons on close and surface win/loss analytics

2 participants