feat(opportunity): require a win/loss reason at close, and measure win rate (#593) - #657
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
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
Playwright 已修复 —— 并且这次失败本身就是最强的验收证据
失败的三条用例都是同一句报错,来自真 REST 写入路径、真内核、demo 真正启动的那个数据库: 这正是 issue 验收标准要的那句"服务端拒绝",而且它是独立于我自己写的测试得到的——三条 fixture 写于"关单不需要理由"的年代,它们不知道有这条规则,照旧提交,然后被拒。注意 同时它还顺带证明了另外两件事:其余 8 条 e2e 全绿,说明带新种子的应用照常启动、种子照常写入——即"每条已关单种子都带原因"这件事在真库上成立,不只是在种子源码里成立。 改法(按评审要求,三条捷径一条都没走)
顺带采纳了评审最后那条建议:把拒绝变成常驻断言评审说得对——"真 HTTP 路径上的拒绝证据目前只以'别的用例顺带炸了'的形式存在,那不是一条会一直守着的断言"。而且这种证据在 fixture 被修好的那一刻就消失了。所以新增
本地验证(这次推之前跑了)教训收下了: 一处不属于本 PR 的既有噪声(未处理,仅备案)e2e 启动日志里有一批 Generated by Claude Code |
Fixes #593
crm_opportunity.win_reason/loss_reason/loss_details从对象写下来那天就存在,loss_reason的注释甚至写着 "required when stage moves to closed_*" —— 但没有任何东西在要求它。两列在所有记录上都是空的(种子数据也一样),也没有任何报表或组件读它们。声明了、没强制、没人用。1. 捕获:
requiredWhen,而且是量出来的正文说"用
requiredWhen或cross_field校验",这个"或"字底下就是本仓库这几轮反复踩的那族坑。所以两条路子都先实测了一遍,再挑能真正挡住写入的那条:requiredWhenscript校验(severity: error)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中断,而引擎对无法求值的谓词的处理是跳过——规则读起来像强制的,实际什么都不要求。验收证据(真驱动 + 真写入路径,两种驱动都跑):
不是 WARN,不是"dry-run 说不行但写入放行"—— 每条拒绝都同时断言了记录没有移动。
以及一层更靠外的证据:真 REST 写入路径。 加上规则后,三条写于"关单不需要理由"年代的 e2e fixture 直接被内核拒了——它们不知道有这条规则,照旧
PATCH了一个没有理由的关单:这是独立于本 PR 自己写的测试得到的验收证据,而且落在真内核 + demo 真正启动的那个数据库上。注意
fields[].field是win_reason——错误报在字段上,这正是选requiredWhen而非记录级 script 校验的收益。fixture 已补上理由(
best_fit/no_budget/better_price,不用quote_accepted:那个值是 CPQ 自动化路径的标记,人手关单用它会弄脏这个区分)。同时新增e2e/opportunity-win-loss-capture.spec.ts,把这次拒绝变成常驻断言而不是"别的用例顺带炸了"——后者在 fixture 被修好的那一刻就消失了:negotiation(拒绝 + 写入没落地,这才区分得开 Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633 那种"报错但写入照样成功");win_reason新增一个quote_acceptedquote_on_accepted在客户接受报价时自动把商机置为closed_won,这一次写入里没有人可以归因。加上requiredWhen后这条 CPQ 链路会被引擎直接拒掉。处理方式是给这条自动化路径起个名字,而不是替销售编一个 "Better Product":这正是本仓库既有的做法(
crm_lead.duplicate_status= 机器的suspectedvs 人的confirmed)。规则因此没有例外分支——每条 closed_won 都带原因,而分析时仍能把 CPQ 成交和销售自己的归因区分开。2. 分析:比值的两半都必须承重
#614 把 150 万的季度配额显示成 7,940,000,根因是过滤器少了一半,而且不会报错——分母写错只会给出一个看起来合理的数。胜率是同一个形状。
因此:比值的两半都来自 dataset 的 measure 过滤器,绝不来自 widget 过滤器。widget 级的
stage过滤会同时收窄分子和分母,那正是比值悄悄变成"自己除自己"的方式。每个按维度拆分的胜率都是表格而不是柱状图:一根写着 67% 的柱子无法证伪,一行 "Won 2 · Lost 1 · Settled 3 · 67%" 自带算式。
证据 A —— 出厂种子跑通真 dataset executor(用的是已发布的 widget 绑定)
win_rate_by_lead_source:loss_reason_breakdown: timing 1 · no_budget 1 · competitor 1 · price 1 · features 1证据 B —— 扰动表:改任一半,数字都要动
第 2、3 行让分子承重(两个方向都验),第 4 行让分母承重——分母不是"所有商机"。#614 的配额表通不过第 4 行的等价检查。
这些断言不是摆设:把
win_rate的分母从decided_count改成opp_count,9 条断言同时变红;把任意一条已关单种子的原因删掉,种子守卫和"引擎能否放行种子"两条断言变红。3. i18n
win_reason/loss_reason现在四个语言包全覆盖 —— 此前ja-JP和es-ES完全缺失,而这两个字段现在是必填并且进了图表图例,缺翻译等于把no_budget、quote_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% 胜率。验证
新增
test/win-loss-capture.test.ts(37 条)与e2e/opportunity-win-loss-capture.spec.ts(5 条)。test/flow-condition-totality.test.ts和e2e/opportunity-lifecycle.spec.ts各有 fixture 补上了理由——那正是新规则在起作用,不是绕过。注:
pnpm test与pnpm 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