Skip to content

quota_attainment_by_rep sums forecast rows across every period, so month + quarter snapshots double-count #614

Description

@os-zhuang

Found while implementing #590. Pre-existing, filed unassigned rather than fixed in that PR.

The problem

The Sales dashboard's quota table binds the forecast_metrics dataset with no period filter:

// src/dashboards/sales.dashboard.ts
id: 'quota_attainment_by_rep',
filterBindings: { dateRange: false, type: false },
dataset: 'forecast_metrics', dimensions: ['owner'], values: ['quota_sum', 'closed_sum', 'attainment'],

quota_sum and closed_sum are plain sum measures over crm_forecast, and attainment is ratio(closed_sum, quota_sum). crm_forecast holds one row per owner per period — and deliberately holds more than one period at a time: the seeds ship a current-quarter row, a current-month row and a previous-quarter row.

So the table currently adds a quarter's quota to a month's quota to last quarter's quota for the same owner and calls the result "Quota". The seeded numbers make it concrete: 1,500,000 (this quarter) + 500,000 (this month) + 1,400,000 (last quarter) = a 3.4M "quota" for a rep whose real quarterly quota is 1.5M. The attainment percentage is wrong by the same construction.

The same applies to any widget or report that aggregates forecast_metrics without pinning a period — period and period_label are declared dimensions precisely because rows are not comparable across them.

Why it surfaced now

#590 made crm_forecast a live table instead of three demo rows. Today the sweep writes quarterly rows only, so it does not add a new kind of double count — but it does multiply the row count, and the moment a monthly cadence is added the error compounds per owner.

Suggested direction

Pin the widget to a single period. Either:

  1. Add filter: { period: 'quarter' } plus a period_start equality to the widget (needs a date-macro the widget filter path actually resolves — note the comment in src/views/forecast.view.ts that the list path resolves none, so check the analytics path separately before relying on one); or
  2. Add a current_quarter boolean/flag dimension the dataset can filter on; or
  3. Give forecast_metrics a defaultFilter so every consumer inherits "latest quarter" unless it opts out.

Whatever the mechanism, the acceptance test is: with the seeded data plus one sweep run, the table's Quota column for a rep equals that rep's quarterly quota, not the sum of all their snapshots.

Activity

  1. added
    bugSomething isn't working
    metadataDeclarative metadata — schema, security posture, UI surfaces
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:queueReady for the PM dispatch loop
    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 循环第 4 轮
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-614-quota-attainment-period
    Worktree:hotcrm-issue-614

    优先级从 P1 上调为 P0:#590 落地后 crm_forecast 变成活表,这张表现在会把同一个人的季度配额、月度配额、上季度配额加在一起当成"配额"——种子数据下就是 1.5M 的真实季度配额显示为 3.4M,达成率同比例失真。客户预览里销售仪表盘上一个明显算错的数字,比缺一个功能更伤信任,所以它该在预览前修掉。

    issue 给了三条路线,请先核实哪条真的可行再选:方案 1 需要一个分析路径真的能解析的日期宏——src/views/forecast.view.ts 里已有注释说明列表路径一个都解析不了,分析路径必须单独验证,不要从列表路径的结论外推。方案 3(给 forecast_metrics 加 defaultFilter)会影响所有消费方,若选它请说明还有谁在读这个数据集。

    验收按 issue 原话衡量:种子数据加一次 sweep 后,表里某个销售的 Quota 列等于他的季度配额,而不是所有快照之和。请把改前改后的实际数字都放进报告。

    同批:#622(demo-bootstrap.flow.ts / contract.hook.ts / 种子)、#598(线索相关)。共用桶文件提醒:content/docs/guides/index*.mdx 与 meta*.json,只有真新增文档页才碰。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:通过,已入队合并 — #628

    核验依据(以 GitHub 为准):9 项检查全绿;9 个文件全部在范围内(widget、数据集契约注释、四语言翻译、文档一处更正、新测试、changeset);未碰 src/data/index.ts(本轮归 #622)与共用的 content/docs/guides/ 索引。

    实际数字比我在 issue 里估的更糟:不是 3.4M,而是 7,940,000——因为 #591 把预测种子从 3 行加到了 8 行,达成率显示 90%,真实值 55%。这也说明这类"跨维度求和"的缺陷会随数据增长而恶化,越晚修越难看。

    方案选择做了实测而非推理,三条路线逐条给了结论:

    • 方案 2(current_quarter 布尔维度)不可行——该标记必须随时间变化,只能做 formula,而 formula 是引擎查完后在 JS 里算的,数据引擎看不到这一列。本仓库已经踩过同一个坑(forecast.view.ts 里 attainment_pct desc 排序失效)。这条路会产出一个"过滤不掉任何东西"的过滤器。
    • 方案 3(数据集 defaultFilter)可行但不该做——已全量 grep 确认 forecast_metrics 当前只有这一个消费方(10 个 report 全绑在别的数据集上),所以影响面小;不选的理由是长期形状:period / period_label 这两个维度存在的意义就是跨周期趋势,数据集自带"只看最新季度"会让将来任何配额趋势图静默只返回一行。我认可这个取舍标准——缺 widget 过滤器的失败模式是一个会红的测试,藏在数据集里的过滤器的失败模式是悄悄少了数据。
    • 方案 1 实测可行。我在认领里特别要求"分析路径必须单独验证、不要从列表路径外推",这一条照做了:queryDataset 在任何 strategy 执行前先把 widget 的 runtimeFilter 过一遍 resolveFilterTokens,编译出的 SQL 是 WHERE (period = $1 AND period_start = $2),宏成为绑定参数;而列表路径确实一个宏都解析不了,所以 this_quarter_forecasts 视图保持只用操作符过滤,两条路径没有互相外推。

    两个过滤条件缺一不可,这点给了对照数据:只按 period 仍把历史每个季度相加(6,500,000);只按 period_start 又会把 Q3 2026 与 Jul 2026 合并(两者 period_start 同为 2026-07-01,1,980,000);两者同时才唯一命中一行(1,500,000)。

    验收按 issue 原话跑出来了:真实服务 + 真实 SQLite,改前 quota_sum: 7940000 / attainment: 0.90,改后 quota_sum: 1500000 / closed_sum: 900000 / attainment: 0.6——1,500,000 正是该销售 Q3 2026 的季度配额。守卫测试做了变异验证(摘掉 widget 的 filter,3 个测试变红且报错文案直指问题)。

    已知代价已写进注释和 changeset 并被接受:季度切换到当晚 03:00 sweep 建出新行之间,表是空的。空表比在写着"本季度"的表头下显示上季度达成率更诚实。

    派生发现 #627(预测文档称达成率"没有专属字段",而 crm_forecast.attainment_pct 正是)已另开,未夹带。


    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

bugSomething isn't workingmetadataDeclarative metadata — schema, security posture, UI surfacespm: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