Skip to content

fix(forecast): 当季只留一个写入者,并把 crm_forecast 补进认领清单 (#702) - #717

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-702-forecast-phantom-row
Aug 5, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-702-forecast-phantom-row

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #702

前提核对(先于实现)

issue 的证据采自验收线的 pinned baseline 5a78f88,早于 PR #677。按 origin/main 59282895 逐条重核,三条促成因素在当前 main 上全部仍然成立,只有措辞过时:

issue 的说法 当前 main 实测 结论
(a) demo_bootstrap 认领清单不含 crm_forecast CLAIMED_OBJECTS 是 lead / account / contact / opportunity / case / task / quote / contract 八项,无 forecast ✅ 成立
(b) 种子 forecast 行不带 owner src/data/revenue.seed.ts 八条记录均未声明 owner_id,注释也写明「a seed cannot name a user」 ✅ 成立
(c) 快照流按 owner 键控当季查找 CURRENT_PERIOD_FILTER = owner_id: '{currentOwner.id}' + period: 'quarter' + period_start ≤ TODAY ≤ period_end ✅ 成立
「8 objects × 2 columns」 过时。#677 已把两列并成单列 owner_id,认领谓词是 {owner_id: null} ⚠️ 措辞过时,机理不变

碰撞发生在 demo_quarter_current 这一条记录上:demo_month_current 是 month 期,而 sweep 的 SNAPSHOT_PERIOD 固定为 quarter,两者不同窗。

平台侧的 upsert 语义也实测过(@objectstack/metadata-protocol 的 SeedLoaderService.writeRecord):mode: 'upsert' 命中已有行时走 engine.update({ ...record, id }) —— 部分写,只覆盖种子声明的列。所以一旦 owner_id 被盖上,热启动重播种不会把它抹掉。这条事实决定了下面方案的可行性,因此单独验证而非假设。

三个方向的取舍(选 2 + 1,理由如下)

方向 1 单用(只把 crm_forecast 加进认领清单):order-dependent,实测证明

PM 提示要核实「认领到底去重了,还是只是换了幻影的 owner」。实测答案是:看两个调度流谁先到窗口。我在 test/flow-scheduled.test.ts 里把两种顺序都跑了一遍,并把当季种子行放回去做反向验证:

  • demo_bootstrap 先跑 → sweep 认出已被认领的行并 adopt,不产生重复,方向 1 看起来「修好了」;
  • forecast_snapshot 先跑(冷启动在 03:00 前十分钟内)→ sweep 看不见还没被认领的行,另开一条;随后认领又把种子行盖上 owner,于是同一个 owner 在同一窗口有两行:
AssertionError: two rows share one snapshot identity — a quota/closed double count:
  usr_admin|quarter|2026-07-01: expected [ 'usr_admin|quarter|2026-07-01' ] to deeply equal []

而且不自愈:sweep 的 find_forecast 是 findOne,只刷新先碰到的那一条,另一条永远留着。第一个用户如果本身不是 deal owner,方向 1 还会让幻影行看起来更合法(挂在 admin 名下、带 1.5M quota),与 issue 里那条空 owner 的行并排。

方向 3(让 sweep 收养无 owner 行):不成立

flow 的 lookup 是纯 metadata filter。要表达「我的 或 无主」只能写 owner_id: { $in: ['{currentOwner.id}', null] },但语义是错的:多个 deal owner 会争抢同一条无主行,谁先跑谁收养,其余人还是各自新建。这不是「宽松一点」,是把不确定性写进契约。按 contract-first,producer 侧修。

方向 2(+ 方向 1 作为独立修复):采用

  • 种子只写已结算的季度:当季窗口交给 forecast_snapshot 独占。碰撞从源头消失,与调度顺序无关 —— 上面那条反向验证在两种顺序下都变红,就是这个性质的证据。
  • crm_forecast 补进认领清单,理由与幻影无关、是另一个真实缺陷:该对象 sharingModel: 'private',sales_rep 以 readScope: 'own' 读它,所以一条无主快照不只是 owner 轴上空白,而是对每个 rep 都不可见、对所有人(含 admin)都不可编辑。它是认领清单里唯一漏掉的 owner-scoped 已 seed 对象。

两者的依赖方向写进了源码注释:认领之所以正确,是因为种子已经退出了 sweep 的窗口;顺序无关性来自种子退让,不来自认领。

明说代价(不藏)

新装/重置的 demo org 上,Sales dashboard 的 Quota Attainment by Rep 表在 03:00 sweep 之前是空的,之后 quota 也是空的(quota 按设计没有自动写入者,是 attainment 的手工分母)。

这不是回归的净损失,因为今天这个「demo 故事」本来就是坏的:sweep 一旦跑过,归到 Dev Admin 名下的是 sweep 那行(quota 为空),种子那条 1.5M quota 挂在空 owner 上。换句话说,被删掉的不是一个能用的 quota 故事,而是一条不属于任何人的行。而「空」正是该 widget 在季度切换时已经选择过的诚实状态(见 sales.dashboard.ts 里 quota_attainment_by_rep 的既有注释)。

改动

  • src/data/revenue.seed.ts —— 删除 demo_quarter_current(八条 → 七条),连带删掉只服务于它的 thisQuarterStart / thisQuarterEnd。新增「ONE PRODUCER PER WINDOW」注释块,写清机理、为什么认领不足以修、以及上面那条代价。
  • src/flows/demo-bootstrap.flow.ts —— CLAIMED_OBJECTS 增加 ['forecasts', 'crm_forecast', 'Forecasts'],附作者论证。
  • .changeset/forecast-current-quarter-single-producer.md。

未触碰 #684 在飞的七个 record-change flow,未改 forecast-snapshot.flow.ts(方向 2 不需要动它),未改 content/docs/releases/。

测试

test/forecast-seeds.test.ts —— 新增 describe「the seeds stay out of the window forecast_snapshot owns (#702)」。禁区从 flow 自身的 lookup filter 推导(走节点树找 find_forecast,读 config.filter),所以 SNAPSHOT_PERIOD 一改守卫就跟着改,不会在陈旧前提上空转。四条:

  1. sweep 仍然以 owner 键控当季(premise 守卫:owner_id 模板、period 标量、两个窗口算子都断言到);
  2. 没有任何种子行落在该窗口内;
  3. 仍然为 sweep 不写的那个 period 保留一条当期行(说明这是定向删除,不是「一刀切掉所有当期」);
  4. sweep 所属 period 的种子行必须已结算(period_end 早于今天)—— 单独一条,因为 (2) 对「种子了一个未来季度」是放行的,那种缺陷会潜伏最多三个月。

同时把原先的 a quarterly snapshot exists for the current calendar quarter 改指到当月:#530 要保的性质是「存在一条日历真值的当期行可被等值过滤命中」,那条性质现在由 month 行承载,季度那条由 sweep 产出(flow-scheduled 里已钉死它落在同样精确的边界上)。这属于 fixture 三种处置里的「改指」,不是删覆盖。

test/flow-scheduled.test.ts —— 新增端到端 describe:用真实种子记录在同一 store 上跑 demo_bootstrap + forecast_snapshot,冷启动一轮、模拟热启动重播种再跑一轮,两种调度顺序各跑一遍,断言无无主行、(owner, period, period_start) 唯一、当季每个活跃 deal owner 恰好一行、当季数字来自 sweep 而非种子、七条种子行全部归到第一个用户。

重播种按 loader 的真实语义模拟(按 seed_key 匹配 + 部分写),并且新插入的行带 owner_id: null 而非缺键 —— 这个区别是 load-bearing 的:demo_bootstrap 的过滤器是 {owner_id: null},缺键匹配不上。第一版就是在这里红的。

最后一条用例把当季种子行放回去并按 sweep-先跑的顺序执行,断言重复行确实出现、且后续再跑也不自愈 —— 保证上面那组守卫不会因为「什么都没产出」而空转通过。

demo_bootstrap 的显式认领清单加上 crm_forecast。

test/forecast-period-scope.test.ts —— #614 的 runtime 半场原本直接从种子里取当季行做 fixture,现在改成生产形态:种子(已结算阶梯)+ sweep 新开的当季行 + 手工设的 quota(常量 CURRENT_QUARTER_QUOTA,注释说明 quota 没有自动写入者所以只能这样来)。新增一条断言:种子里不含当季行。#614 的结构守卫(不跨期求和)未动。

反向验证(方向在跑之前就定好:把删掉的种子行放回去 → 转红)

FAIL test/forecast-seeds.test.ts > ... > no seeded forecast row lands inside that window
FAIL test/forecast-seeds.test.ts > ... > every seeded row in the swept period is SETTLED
FAIL test/flow-scheduled.test.ts > ... > sweep first > leaves exactly one row per (owner, period, period_start)
FAIL test/flow-scheduled.test.ts > ... > sweep first > gives the current quarter one row per ACTIVE deal owner
FAIL test/flow-scheduled.test.ts > ... > claim first > gives the current quarter one row per ACTIVE deal owner

值得单独指出的非对称:claim first 那一栏只红了一条(「当季出现了种子行」),重复行没有出现 —— 这正是方向 1 单用会「看起来修好了」的原因;sweep first 那一栏才红出重复。这条不对称就是选方向 2 而不是方向 1 的实测依据,随后已还原。

全量

pnpm typecheck   → tsc --noEmit,无输出
pnpm validate    → ✓ Validation passed (965ms)   (5 条 warning 均为存量:approval 空审批人槽位 / campaign_member 字段组)
pnpm lint        → 13 warning(s), 14 suggestion(s)   与 main 基线逐字相同(stash 对比过)
pnpm hygiene     → ✓ source hygiene clean
pnpm build       → ✓ Build complete,Logic: 24 Flows
pnpm test        → Test Files  56 passed (56)
                   Tests  1363 passed | 1 skipped (1364)

控制字符自查:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 扫过全部改动文件(含 changeset),clean。

范围与约束

  • 平台依赖保持 @objectstack/* 17.0.0-rc.2,未做任何版本改动。
  • 无平台侧 workaround:无主种子与热启动重播种都按文档化行为对待,修在 app 的 producer 侧。
  • 未改 issue 的 assignee。

越界发现

已按归档纪律搜过 open issue(关键词 + 文件路径),无重复,另开 #716(未指派):demo_bootstrap 同样不认领 crm_campaign 与 crm_knowledge_article。这两个是 public_read,所以不是不可见,但 OWD baseline 只放开读 —— 而 marketing_user.crm_campaign 与 service_agent.crm_knowledge_article 都是 allowEdit: true, modifyAllRecords: false,种子行无主导致这两个岗位授权表上写着能编辑、实际改不动(#622 的形态,换了两个对象)。没有在本 PR 顺手改:crm_knowledge_article 的 owner 语义(作者?维护人?把全部种子文章归到第一个用户是否合适)需要维护者拍板,属于 #702 范围之外。


Generated by Claude Code

…orecast (#702)

The demo seeds shipped a current-quarter crm_forecast row. Seeded rows land
ownerless (seed writes are isSystem, so the platform's owner_id stamp never
fires), while forecast_snapshot looks up the current-quarter row BY OWNER —
so the sweep never matched the seeded row, concluded the period was missing,
and opened a second row spanning the same quarter. Every owner-grouped
consumer, quota_attainment_by_rep above all, then rendered a phantom
ownerless duplicate after each re-seeded boot.

Seeds now ship settled quarters only, plus the current month (a window no
runtime writer touches). One producer per window, order-independent.

demo_bootstrap additionally claims crm_forecast: the object is private and
sales_rep reads it with readScope 'own', so an ownerless snapshot was
invisible to every rep and editable by nobody. It was the one owner-scoped
seeded object the claim list omitted.

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

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

Request Review

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.

forecast_snapshot 与启动重播种的互动:每次 dev 重启后,当季出现一条无 owner 的幻影快照行与 owner 键控行并存

2 participants