Repository navigation
fix(forecast): 当季只留一个写入者,并把 crm_forecast 补进认领清单 (#702) - #717
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
yinlianghui
marked this pull request as ready for review
August 5, 2026 14:54
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #702
前提核对(先于实现)
issue 的证据采自验收线的 pinned baseline
5a78f88,早于 PR #677。按 origin/main59282895逐条重核,三条促成因素在当前 main 上全部仍然成立,只有措辞过时:demo_bootstrap认领清单不含crm_forecastCLAIMED_OBJECTS是 lead / account / contact / opportunity / case / task / quote / contract 八项,无 forecastsrc/data/revenue.seed.ts八条记录均未声明owner_id,注释也写明「a seed cannot name a user」CURRENT_PERIOD_FILTER=owner_id: '{currentOwner.id}'+period: 'quarter'+ period_start ≤ TODAY ≤ period_endowner_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 在同一窗口有两行:而且不自愈: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一改守卫就跟着改,不会在陈旧前提上空转。四条: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 的结构守卫(不跨期求和)未动。反向验证(方向在跑之前就定好:把删掉的种子行放回去 → 转红)
值得单独指出的非对称:
claim first那一栏只红了一条(「当季出现了种子行」),重复行没有出现 —— 这正是方向 1 单用会「看起来修好了」的原因;sweep first那一栏才红出重复。这条不对称就是选方向 2 而不是方向 1 的实测依据,随后已还原。全量
控制字符自查:
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'扫过全部改动文件(含 changeset),clean。范围与约束
@objectstack/* 17.0.0-rc.2,未做任何版本改动。越界发现
已按归档纪律搜过 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