Skip to content

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

Description

@os-zhuang

Batch-2 (business completeness) item from the 2026-08-02 review. Small scope, closes an existing loop.

Problem

crm_opportunity.win_reason / loss_reason / loss_details exist, and the loss_reason field comment even says "required when stage moves to closed_"* — but nothing enforces it (crm_case has exactly the pattern needed: resolution_required_for_closed). No report or dashboard widget consumes the fields either, so they are empty and invisible.

Scope

  1. Enforce capture: requiredWhen conditional rules (v17, server-enforced — already available on the pinned protocol) or a cross_field validation — loss_reason required on closed_lost, win_reason on closed_won.
  2. Analytics: win-rate metric (won / (won+lost)) by owner and by lead source; loss-reason breakdown widget; both feed from opportunity_metrics (add measures as needed).
  3. i18n for any new labels in all 4 locales (the reason picklist values are among the 15 keys already missing in ja/es — see i18n: 205 warnings hidden by --skip-i18n; broken option keys and untranslated navigation #494).

Acceptance

  • Closing an opportunity without the applicable reason is rejected server-side.
  • Sales dashboard shows win rate and a loss-reason breakdown with seeded data.

Activity

  1. added
    enhancementNew feature or request
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed on Aug 2, 2026
  2. self-assigned this
    on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 9 轮
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-593-win-loss-reasons
    Worktree:hotcrm-issue-593


    本轮唯一的 P0。正文的三条范围照做,但有一条必须自己量、不要照抄 issue 的措辞:

    正文说"用 requiredWhen 条件规则(v17,服务端强制)或 cross_field 校验"。这个"或"字底下藏着本仓库这几轮反复踩的那一族坑——声明了不等于被强制。本轮已经量出五种不同的机制,各自的失败方式完全不同:对象脚本校验在谓词求值失败时静默跳过只留 WARN(#634 / objectstack#4649);共享规则根本不解释执行、而是编译成下推 SQL(#621);流程条件抛错但触发它的那次写入照样成功(#633);decision 节点的单数 config.condition 引擎从不读取(#650);流程变量声明了在运行时什么也不绑定(#651)。

    所以第 1 条的验收不是"规则写上去了",而是:用真驱动 + 真写入路径复现一次——不带 loss_reason 把商机置为 closed_lost,写入必须被拒绝(不是记 WARN、不是 dry-run 说不行但写入放行——后面这个错配是已知的,见 objectstack#4633 / #4647)。两条路子哪条真的能挡住写入,就用哪条;两条都挡不住,就是又一处"声明 ≠ 强制",按发现报上来、不要硬凑。crm_case 的 resolution_required_for_closed 是正文点名的现成范式,先去读它、确认它今天真的在挡写入,再决定是不是照抄。

    第 2 条分析面注意别复制 #614 的错:那次仪表盘把 150 万的季度配额显示成 7,940,000,根因是过滤器少了一半。胜率是个比值,分母写错不会报错、只会给出一个看起来合理的数——请给出一张表证明分子分母两半都是承重的(改任一半,数字都要变)。

    第 3 条 i18n:本轮四个语言包文件归你独占,另外两件已被明确禁止触碰,你不必担心冲突。顺带确认一下 #494 里提到的那 15 个 ja/es 缺键中属于本次 picklist 的部分是不是也该一并补——如果超出范围就在报告里说明,不要默默扩。

    **文件边界(本轮三件并行,严格分工):**你拥有 src/objects/opportunity.object.ts、报表/仪表盘、src/data/sales.seed.ts、以及全部 i18n 语言包。不要动 src/objects/account.object.ts / account.hook.ts / src/flows/lead-conversion.flow.ts(#626 拥有)、不要动 src/flows/demo-bootstrap.flow.ts / src/permissions/(#640 拥有)。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    验收通过 —— #657(9/9 绿,21 个文件,已入合并队列)。

    第 1 条(捕获)按要求量了才选,没照抄 issue 的"或"字。 两种机制都实测了一遍,而且先去核了正文点名的现成范式今天是不是真的在挡写入:

    机制 关单不带原因
    字段级 requiredWhen 写入被拒(insert / update 都拒),记录停在原 stage
    script 校验(error) 写入被拒
    crm_case.resolution_required_for_closed 今天确实在挡 —— 先量了再决定抄不抄

    拦截在三条独立路径上都证明了:ObjectQL + InMemoryDriver(稀疏行)、ObjectQL + 真 SQLite(列完整行)、以及真 REST API(400 VALIDATION_FAILED,报在字段上)。每一条都同时断言了记录没有移动——这正是我要的那个标准,不是 WARN、不是"dry-run 说不行但写入放行"。

    has(record.stage) 是承重的:裸写谓词在任何缺该 key 的记录上会以 No such key 中断,而引擎对无法求值的谓词的处理是跳过——规则读起来像强制的,实际什么都不要求(#634 / objectstack#4649)。

    **第 2 条(比值)给出了扰动表,第四行是关键:**基线 8/13 = 61.5%;一笔赢改丢 → 53.8%;一笔丢改赢 → 69.2%;加 5 笔进行中商机 → 仍是 61.5%。分母不是"所有商机"——#614 那张配额表恰恰通不过这一行。而且比值两半都取自 dataset 的 measure 过滤器、不取 widget 过滤器,因为 widget 级过滤会同时收窄分子分母。非空泛性也做了变异测试:把分母从 decided_count 换成 opp_count,9 条断言同时变红。

    Playwright 那次红是新校验在起作用,不是缺陷 —— 三个 e2e fixture 写于"关单不需要理由"的年代。补理由而非放松规则,并且按建议新增了一条正面断言(opportunity-win-loss-capture,5 条),让"不带理由的关单被 400 拒绝"成为一条会长期守着的断言,而不是靠别的用例顺带炸出来。第二次推送前本地跑了 e2e(16 passed)。

    quote_accepted 这个新值我确认采纳。 quote_on_accepted 自动关单时没有人可以归因,加上 requiredWhen 后这条 CPQ 链路会被引擎直接拒掉。给自动化路径起个名字,好过给规则开例外分支——规则因此保持全函数,而分析时仍能把 CPQ 成交和销售自己的归因分开。这是本仓库已有的做法(crm_lead.duplicate_status 的机器 suspected vs 人的 confirmed)。

    越界文件我核过了,四处都属实、都被披露、且都不属于本轮其他两件:src/objects/quote.hook.ts(合同倒逼)、test/flow-condition-totality.test.ts 与 e2e/opportunity-lifecycle.spec.ts(fixture,补值而非 skip 或降级)、content/docs/ 的 6 个页面(AGENTS.md 要求,且 dashboards 页原本还写着一条不存在的 "Win Rate (last 90 days)")。禁区文件一个没碰。

    i18n 的范围判断也正确:只补了 win_reason / loss_reason 的 ja/es(这两个字段现在是必填且进了图表图例,缺翻译等于把 no_budget 这样的存储值摆到销售必须选的下拉框里),approval_status 留在账本里归 #645 扫。PENDING_SELECT_LABELS 只缩不增的规矩守住了。

    衍生发现 #656(带 filter 的 measure 对空组返回缺列而非 0,derived ratio 因此算成 null)我会归到 upstream:objectstack 并上报。特别记一笔:没有在消费端用 ?? 0 糊掉,当前行为被一条"平台修好时会变红"的断言钉住,发布的表格把 lost_count 放在 win_rate 旁边,让空白读作"0 胜 1 负"而非"没有数据"。同一个原因也决定了 decided_count 必须是独立过滤计数而不是 won+lost 的 derived sum——后者会让任何从未丢过单的销售的分母变空。


    Generated by Claude Code

  5. added
    priority:p0Critical: blocker, must ship before MVP
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looppriority:p0Critical: blocker, must ship before MVP

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions