diff --git a/.changeset/2242-forecast-title-owner.md b/.changeset/2242-forecast-title-owner.md new file mode 100644 index 000000000..f66a93fc9 --- /dev/null +++ b/.changeset/2242-forecast-title-owner.md @@ -0,0 +1,12 @@ +--- +"hotcrm": minor +--- + +Clause-②: no +Metadata-protocol: none + +**A forecast is titled by its period and its owner: "Q4 2026 · Nina Reyes" (#2242).** A forecast is one row per owner and period, but its title named the period only ("Q4 2026 (2026-10-01)"), so two reps' forecasts for the same quarter read the same on the record, in Recent items and in a lookup. The title now names the owner after the period label. The name is read from the forecast's **Owner**, so a forecast handed to another owner is re-titled by that save. A forecast with no owner is titled by its period label alone ("Q4 2026"). + +The owner's name is copied into the title when the forecast is saved, so renaming a user re-titles their forecasts at each forecast's next save, not at once. + +**FROM → TO.** "Q4 2026 (2026-10-01)" → "Q4 2026 · Nina Reyes". No step is needed on upgrade: forecasts saved before the stored record titles of #2230 have no title yet, and `scripts/backfill-record-titles.ts` (described there) titles them in the new form. A development database created since #2230 keeps the old form on each forecast until it is next saved; `pnpm demo:reset` re-seeds it. diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index 27054b137..b8156cd23 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -47,7 +47,7 @@ The advice is therefore what it always was — when you write a snapshot by hand **A row stored before these rules shipped stays refused until its window is corrected.** The check reads the whole record on every save, not just the field you touched, so even an unrelated edit to such a row — a quota correction, a note — is rejected until the dates agree with the label. The correction itself is always accepted: put `period_start` and `period_end` back on the calendar period, and the row saves normally again. -So you'll never see a record labelled “2026-07-01 through ???” — only the human label. Dashboards group by `period_label` directly. The **All Forecasts** list groups on two levels, newest period first: by `period_start`, and under each start date by `period_label`, so every period gets a heading of its own (**Q4 2026**). It cannot group by the label alone, because a label sorts as text: *Sep 2026* would come before *Q4 2026*, and *Q4 2025* before *Oct 2026*. Nor can it group by the start date alone, because a quarter and the month that opens it start on the same day: **Q4 2026** and **Oct 2026** share one start-date heading and sit under it as two periods. +So you'll never see a record labelled “2026-07-01 through ???” — only the human label. A forecast's own title adds its owner after that label — **Q4 2026 · Nina Reyes** — so two reps' forecasts for the same quarter read apart on the record, in Recent items and in a lookup. A forecast with no owner is titled by its label alone. The name is taken from **Owner** when the forecast is saved: reassign it and the title follows; rename the user and the title changes at the forecast's next save. Dashboards group by `period_label` directly. The **All Forecasts** list groups on two levels, newest period first: by `period_start`, and under each start date by `period_label`, so every period gets a heading of its own (**Q4 2026**). It cannot group by the label alone, because a label sorts as text: *Sep 2026* would come before *Q4 2026*, and *Q4 2025* before *Oct 2026*. Nor can it group by the start date alone, because a quarter and the month that opens it start on the same day: **Q4 2026** and **Oct 2026** share one start-date heading and sit under it as two periods. ## How forecasts get created diff --git a/content/docs/sales/forecasting.zh-Hans.mdx b/content/docs/sales/forecasting.zh-Hans.mdx index 34c2484db..fecb8a0d8 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -47,7 +47,7 @@ description: 按负责人和周期的销售管道快照——营收预测的基 **在这些规则上线之前就已经存错的行,会一直被拒收,直到它的窗口被改正。** 这项校验每次保存时读的是整条记录,而不只是你改动的那个字段,所以哪怕是一处无关的编辑——改个配额、加条备注——也会被拒绝,直到日期和标签对得上。改正本身则永远是被允许的:把 `period_start` 和 `period_end` 拨回到对应的日历周期,这一行就又能正常保存了。 -因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。仪表盘会直接按 `period_label` 分组。**全部预测**列表则分两层分组,最新的周期排在最前:先按 `period_start`,再在每个起始日之下按 `period_label`,所以每个周期都有自己的一行标题(**Q4 2026**)。它不能只按标签分组,因为标签是按文本排序的:*Sep 2026* 会排在 *Q4 2026* 前面,*Q4 2025* 会排在 *Oct 2026* 前面。它也不能只按起始日分组,因为一个季度和开启它的那个月是同一天开始的:**Q4 2026** 和 **Oct 2026** 共用同一个起始日标题,在它之下作为两个周期分开列出。 +因此你绝不会看到一条标记为"2026-07-01 至 ???"的记录——只有人类可读的标签。预测记录本身的标题在这个标签后面加上负责人——**Q4 2026 · Nina Reyes**——所以两位销售同一季度的预测,在记录页、最近访问和查找框里都能区分开。没有负责人的预测,标题就只是标签本身。名字在保存预测时从**负责人**读取:把预测改派给别人,标题随之改变;用户改名后,标题要等这条预测下一次保存时才更新。仪表盘会直接按 `period_label` 分组。**全部预测**列表则分两层分组,最新的周期排在最前:先按 `period_start`,再在每个起始日之下按 `period_label`,所以每个周期都有自己的一行标题(**Q4 2026**)。它不能只按标签分组,因为标签是按文本排序的:*Sep 2026* 会排在 *Q4 2026* 前面,*Q4 2025* 会排在 *Oct 2026* 前面。它也不能只按起始日分组,因为一个季度和开启它的那个月是同一天开始的:**Q4 2026** 和 **Oct 2026** 共用同一个起始日标题,在它之下作为两个周期分开列出。 ## 预测是如何创建的 diff --git a/content/docs/sales/forecasting.zh-Hant.mdx b/content/docs/sales/forecasting.zh-Hant.mdx index b7ed756cb..d406bdd40 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -49,7 +49,7 @@ description: 按負責人和週期的銷售管道快照——營收預測的基 **在這些規則上線之前就已經存錯的資料列,會一直被拒收,直到它的區間被改正。** 這項檢核每次儲存時讀的是整筆記錄,而不只是你改動的那個欄位,所以哪怕是一處無關的編輯——改個配額、加條備註——也會被拒絕,直到日期和標籤對得上。改正本身則永遠是被允許的:把 `period_start` 和 `period_end` 撥回到對應的日曆週期,這筆資料列就又能正常儲存了。 -因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。儀表板會直接按 `period_label` 分組。**全部預測**清單則分兩層分組,最新的週期排在最前:先按 `period_start`,再在每個起始日之下按 `period_label`,所以每個週期都有自己的一列標題(**Q4 2026**)。它不能只按標籤分組,因為標籤是按文字排序的:*Sep 2026* 會排在 *Q4 2026* 前面,*Q4 2025* 會排在 *Oct 2026* 前面。它也不能只按起始日分組,因為一個季度和開啟它的那個月是同一天開始的:**Q4 2026** 和 **Oct 2026** 共用同一個起始日標題,在它之下作為兩個週期分開列出。 +因此你絕不會看到一筆標記為「2026-07-01 至 ???」的記錄——只有人類可讀的標籤。預測記錄本身的標題在這個標籤後面加上負責人——**Q4 2026 · Nina Reyes**——所以兩位業務同一季度的預測,在記錄頁、最近存取和查找框裡都能區分開。沒有負責人的預測,標題就只是標籤本身。名字在儲存預測時從**負責人**讀取:把預測改派給別人,標題隨之改變;使用者改名後,標題要等這筆預測下一次儲存時才更新。儀表板會直接按 `period_label` 分組。**全部預測**清單則分兩層分組,最新的週期排在最前:先按 `period_start`,再在每個起始日之下按 `period_label`,所以每個週期都有自己的一列標題(**Q4 2026**)。它不能只按標籤分組,因為標籤是按文字排序的:*Sep 2026* 會排在 *Q4 2026* 前面,*Q4 2025* 會排在 *Oct 2026* 前面。它也不能只按起始日分組,因為一個季度和開啟它的那個月是同一天開始的:**Q4 2026** 和 **Oct 2026** 共用同一個起始日標題,在它之下作為兩個週期分開列出。 ## 預測是如何建立的 diff --git a/src/sales/objects/forecast.object.ts b/src/sales/objects/forecast.object.ts index 26b401caa..6bdf350ef 100644 --- a/src/sales/objects/forecast.object.ts +++ b/src/sales/objects/forecast.object.ts @@ -42,8 +42,8 @@ export const Forecast = ObjectSchema.create({ // OWD is an authored decision (ADR-0090 D1/D7). Forecasts are personal to the // owner. sharingModel: 'private', - // ADR-0079 record title. ⚠️ An expression cannot dot-walk a lookup (ADR-0072), - // so `display_title` composes local fields only: period_label + (period_start). + // ADR-0079 record title: the period label and the owner's name, read one + // hop through the `owner_id` lookup (#2242). See `display_title` below. nameField: 'display_title', // Explicit search targets (ADR-0061): the real columns the lookup picker // and global search match. Until #2230 they were required, because the @@ -125,17 +125,21 @@ export const Forecast = ObjectSchema.create({ group: 'basic', }), - // ADR-0079 record title. ⚠️ An expression cannot dot-walk a lookup (ADR-0072), - // so this composes local fields only; `period_start` (a date) is - // string()-coerced for concat. + // ADR-0079 record title: "Q4 2026 · Nina Reyes" (#2242, the #2194 T1-U08 + // ruling). A forecast is one row per owner × period, so the period alone + // left two reps' Q4 rows reading the same in Recent items and in a lookup. + // The owner's name is read one hop through the `owner_id` lookup, never a + // copied text column; measured on 18.1.0 and 18.2.0, the engine resolves + // `record.owner_id.name` when it computes the stored value. With no owner + // the title is the period label alone: the `?.` / `orValue("")` guard + // keeps an ownerless row from reading "Q4 2026 · ". + // + // The name is copied in when the row is written, so renaming the user + // re-titles a forecast only at its next write (measured on 18.1.0 and 18.2.0). + // The seed-ownership claim on a fresh boot is such a write: since + // objectstack#25668 (in 18.2.0) it re-owns seeded rows by id, so each + // seeded forecast is titled with the admin it was handed to. // - // ⚠️ The coercion is `string()`, NOT `text()`. `text` is not a registered - // CEL function — it is absent from `CEL_STDLIB_FUNCTIONS` — so an expression - // using it faults at runtime and leaves the record's `nameField` null. The - // author-time `expression-invalid` rule now REPORTS it ("found no matching - // overload for 'text(dyn)'") instead of letting a broken expression through to - // null silently. `string` is the catalog's coercion, alongside - // `int`/`bool`/`double`/`timestamp`. // Stored, not a formula (#2230, objectstack#24981 ruling A): the engine // computes the expression on every insert and every update by id and // stores the result, so an import matches a lookup cell against this title @@ -145,7 +149,7 @@ export const Forecast = ObjectSchema.create({ display_title: Field.text({ readonly: true, label: 'Display Title', - expression: F`record.period_label + " (" + string(record.period_start) + ")"`, + expression: F`record.?owner_id.?name.orValue("") == "" ? record.period_label : record.period_label + " · " + record.?owner_id.?name.orValue("")`, group: 'basic', }), diff --git a/test/forecast-owner-title.test.ts b/test/forecast-owner-title.test.ts new file mode 100644 index 000000000..f4fdba2c1 --- /dev/null +++ b/test/forecast-owner-title.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +/** + * A forecast is titled by its period and its owner: "Q4 2026 · Nina Reyes" + * (#2242, the #2194 T1-U08 ruling). + * + * A forecast is one row per owner × period. Titled by the period alone, two + * reps' Q4 rows read the same in Recent items and in a lookup. The title is + * the stored `display_title` (#2230), and the owner's name in it is read one + * hop through the `owner_id` lookup, never a copied text column, so a row + * handed to another owner is re-titled by that write. + * + * Measured against the booted app, by insert and by update by id. + */ + +type Rec = Record; + +let verify: VerifyStack; +let nina: Person; +let omar: Person; +let admin: string; + +const stored = async (id: string): Promise => (await verify.rows('crm_forecast', { id }))[0]!; + +/** A hand-entered Q4 2026 forecast, so no nightly sweep owns the row. */ +const q4 = async (over: Rec = {}): Promise => { + const [row] = await verify.seed('crm_forecast', [{ + period: 'quarter', + period_start: '2026-10-01', + period_end: '2026-12-31', + period_label: 'Q4 2026', + source: 'manual', + commit_amount: 10_000, + ...over, + }]); + return String(row!.id); +}; + +beforeAll(async () => { + verify = await hotcrmStack(); + nina = await signUpPerson(verify, 'nina@forecast-owner-title.test', { + name: 'Nina Reyes', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + omar = await signUpPerson(verify, 'omar@forecast-owner-title.test', { + name: 'Omar Diaz', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + admin = await verify.signIn(); +}, 180_000); + +describe('the forecast title names the period and the owner', () => { + it("two owners' forecasts for the same quarter have different titles", async () => { + const ninas = await stored(await q4({ owner_id: nina.id })); + const omars = await stored(await q4({ owner_id: omar.id })); + expect(ninas.display_title).toBe('Q4 2026 · Nina Reyes'); + expect(omars.display_title).toBe('Q4 2026 · Omar Diaz'); + expect(ninas.display_title).not.toBe(omars.display_title); + }); + + it('a forecast with no owner is titled by its period label alone', async () => { + const row = await stored(await q4()); + expect(row.owner_id ?? null).toBeNull(); + expect(row.display_title).toBe('Q4 2026'); + }); + + it('the name comes from the lookup: handing the row to another owner re-titles it', async () => { + const id = await q4({ owner_id: nina.id }); + const res = await verify.apiAs(admin, 'PATCH', `/data/crm_forecast/${id}`, { owner_id: omar.id }); + expect(res.status, await res.text()).toBe(200); + expect((await stored(id)).display_title).toBe('Q4 2026 · Omar Diaz'); + }); +});