Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/2242-forecast-title-owner.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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** 共用同一个起始日标题,在它之下作为两个周期分开列出。

## 预测是如何创建的

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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** 共用同一個起始日標題,在它之下作為兩個週期分開列出。

## 預測是如何建立的

Expand Down
30 changes: 17 additions & 13 deletions src/sales/objects/forecast.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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',
}),

Expand Down
75 changes: 75 additions & 0 deletions test/forecast-owner-title.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

let verify: VerifyStack;
let nina: Person;
let omar: Person;
let admin: string;

const stored = async (id: string): Promise<Rec> => (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<string> => {
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');
});
});
Loading