来源:#748 (文档措辞单)实施过程中,为核实「手填 period_start 的真实行为」而实测出来的行为面 问题。#748 只改文档措辞、不动契约(其 option 3 明确留给维护者拍板),故行为疑点单独记录,不在那条 PR 里顺手修。
现象(实测)
src/objects/forecast.hook.ts 在 period_start 由调用方给出时原样保留它(这是 #748 已确认、且现在文档已按实描述的部分),但接着用 endOfQuarter(start) = Date.UTC(y, m + 3, 0) 推 period_end —— 这是「起点往后数 3 个月」的滚动窗口,不是「该起点所属日历季度的最后一天」。于是同一个 period_label 下窗口长度对,位置却随手填值漂移:
手填 period_start
推出的 period_end
推出的 period_label
2026-07-15
2026-09-30
Q3 2026
2026-08-15
2026-10-31
Q3 2026
2026-09-20
2026-11-30
Q3 2026
后两行标着 Q3,窗口却伸进了 Q4。period = month 时同类:起点 2026-08-17 推出 period_end = 2026-08-31,窗口只剩半个月。
实测方式:把上表三个值送进 forecast_derive_period 的真实 handler(test/helpers/hook-harness.ts 的 makeCtx,beforeInsert),读回 input。
为什么标 finding 而不是缺陷
和 #748 一样,今天没有用户会踩到 :src/ 里没有任何写入方送手填的 period_start(forecast-snapshot.flow.ts 的 create_forecast 只送 period;src/data/revenue.seed.ts 全部用 Date.UTC(y, m, 1) 算日历真值)。属于「已声明但未被行使」的路径。
不过有两点让它比 #748 的纯措辞面更值得看一眼:
入口是敞开的。 period_start 是 required + notNull,并且出现在记录表单的 Snapshot 区块(src/views/forecast.view.ts:127)。source: 'manual'(经理手工录入)正是文档写明的三种来源之一,走的就是这个表单。
二级后果(未实测,只是从元数据推出的): 夜间 sweep 用 period_start <= today <= period_end 选「当前季度那一行」(src/flows/forecast-snapshot.flow.ts 的 CURRENT_PERIOD_FILTER)。一条手工建的、窗口恰好罩住今天的行会满足这个条件,于是 sweep 可能把它当成本季度行去 write_snapshot(覆盖四个金额、把 source 改成 scheduled),并因此认为本季度行已存在而不再开日历真值的那一行。
可选修法(供 triage 参考,不建议由文档单顺手带)
period_end 改为按 period_label 所属的日历周期算(即先 startOfPeriod(period, start) 再 endOfQuarter)—— 窗口回到日历真值,但 period_start 仍是手填值,行内自相矛盾只是换了个位置;
手填的 period_start 一并吸附到日历边界(forecasting.mdx 的「boundary is always computed, never typed」措辞,对手填 period_start 的情形是过度承诺 #748 的 option 3)—— 改契约 ,会让「给某个特定季度拍快照」以外的用法失去表达能力;
拒绝而不是接受:period_start 不是所属周期第一天时报校验错。对 AI 写元数据/写记录的场景,这是「声明即强制」方向上最不容易出错的一种,但同样是契约变更。
三者都动 crm_forecast 的写入契约,需维护者拍板。严重度请 PM 在 triage 轮判定。
Refs #748 #530 #590
来源:#748(文档措辞单)实施过程中,为核实「手填 period_start 的真实行为」而实测出来的行为面问题。#748 只改文档措辞、不动契约(其 option 3 明确留给维护者拍板),故行为疑点单独记录,不在那条 PR 里顺手修。
现象(实测)
src/objects/forecast.hook.ts在period_start由调用方给出时原样保留它(这是 #748 已确认、且现在文档已按实描述的部分),但接着用endOfQuarter(start) = Date.UTC(y, m + 3, 0)推period_end—— 这是「起点往后数 3 个月」的滚动窗口,不是「该起点所属日历季度的最后一天」。于是同一个period_label下窗口长度对,位置却随手填值漂移:后两行标着 Q3,窗口却伸进了 Q4。
period=month时同类:起点 2026-08-17 推出period_end= 2026-08-31,窗口只剩半个月。实测方式:把上表三个值送进
forecast_derive_period的真实 handler(test/helpers/hook-harness.ts的makeCtx,beforeInsert),读回input。为什么标
finding而不是缺陷和 #748 一样,今天没有用户会踩到:
src/里没有任何写入方送手填的period_start(forecast-snapshot.flow.ts的create_forecast只送period;src/data/revenue.seed.ts全部用Date.UTC(y, m, 1)算日历真值)。属于「已声明但未被行使」的路径。不过有两点让它比 #748 的纯措辞面更值得看一眼:
period_start是required+notNull,并且出现在记录表单的 Snapshot 区块(src/views/forecast.view.ts:127)。source: 'manual'(经理手工录入)正是文档写明的三种来源之一,走的就是这个表单。period_start <= today <= period_end选「当前季度那一行」(src/flows/forecast-snapshot.flow.ts的CURRENT_PERIOD_FILTER)。一条手工建的、窗口恰好罩住今天的行会满足这个条件,于是 sweep 可能把它当成本季度行去write_snapshot(覆盖四个金额、把source改成scheduled),并因此认为本季度行已存在而不再开日历真值的那一行。可选修法(供 triage 参考,不建议由文档单顺手带)
period_end改为按period_label所属的日历周期算(即先startOfPeriod(period, start)再endOfQuarter)—— 窗口回到日历真值,但period_start仍是手填值,行内自相矛盾只是换了个位置;period_start一并吸附到日历边界(forecasting.mdx 的「boundary is always computed, never typed」措辞,对手填 period_start 的情形是过度承诺 #748 的 option 3)—— 改契约,会让「给某个特定季度拍快照」以外的用法失去表达能力;period_start不是所属周期第一天时报校验错。对 AI 写元数据/写记录的场景,这是「声明即强制」方向上最不容易出错的一种,但同样是契约变更。三者都动
crm_forecast的写入契约,需维护者拍板。严重度请 PM 在 triage 轮判定。Refs #748 #530 #590