Repository navigation
Require win/loss reasons on close and surface win/loss analytics #593
Description
Activity
- addedenhancementNew feature or requestNew feature or requestpm:queueReady for the PM dispatch loopReady for the PM dispatch looppm:dispatchedDispatched to a dev agent by /pm-dispatchDispatched to a dev agent by /pm-dispatchand removed
on Aug 2, 2026 认领: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
- added a commit that references this issue
on Aug 2, 2026 验收通过 —— #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的机器suspectedvs 人的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
- addedpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVPand removed
on Sep 9, 2026
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_detailsexist, and theloss_reasonfield comment even says "required when stage moves to closed_"* — but nothing enforces it (crm_casehas exactly the pattern needed:resolution_required_for_closed). No report or dashboard widget consumes the fields either, so they are empty and invisible.Scope
requiredWhenconditional rules (v17, server-enforced — already available on the pinned protocol) or across_fieldvalidation —loss_reasonrequired onclosed_lost,win_reasononclosed_won.opportunity_metrics(add measures as needed).Acceptance