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
10 changes: 10 additions & 0 deletions .changeset/2060-items-7b-7c-line-price-and-period-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"hotcrm": patch
---

**A quote line no longer asks for a Sales Price, and a mid-month forecast start gets one clear message.**

- **Quote line item.** Leave **Sales Price** blank and the line takes the product's **List Price**, which you can still change. Before, the New Quote Line Item form refused to save with "Please check the highlighted fields: Sales Price", although a quote line added any other way already got the list price. Opportunity line items have worked this way since the previous release, and the two now behave the same.
- **Forecast.** A **Period Start** that is not the first day of its period is still refused, and the message now names **Period Start** only. Before, a start such as 2026-10-15 also drew "Period End must be the last day of the period", even with **Period End** left blank, or when the end you typed was the month's real last day. Once **Period Start** is on the 1st (on a quarter boundary for a quarterly forecast), a **Period End** you type is checked as before.

Nothing else changes. A price you type is kept, and clearing a quote line's Sales Price is still refused; as on opportunity lines, that message names the field `unit_price` rather than "Sales Price". An import's dry-run preview no longer reports a blank Sales Price on a quote line as an error. The forecasting page now says that a start off its boundary is the only thing reported.
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Because that derivation lives in one place instead of in every writer, snapshots
**What you do supply is checked, not merely kept.** The derivation fills in the blanks, and it still never rewrites a value that arrived with the record — but a value that disagrees with the period it is labelled with is now refused outright rather than stored. The window is pinned at both ends:

- **`period_start` must be the period's own first day.** A monthly snapshot starts on the 1st; a quarterly one must additionally start on a quarter boundary — January 1, April 1, July 1 or October 1. So a quarterly snapshot sent with `period_start` 2026-07-15 no longer lands mid-quarter under a **Q3 2026** label: the save is refused, with *Period Start must be the first day of the period — e.g. 2026-08-01 for Aug 2026*.
- **`period_end` must close that same period — or be left blank.** Leave it out and the derivation fills it in. Type it by hand and it has to be the last day of the period `period_start` opens: 2026-09-30 for a quarter starting 2026-07-01, or 2026-08-31 for Aug 2026. A ten-month window cannot be filed under a one-quarter label.
- **`period_end` must close that same period — or be left blank.** Leave it out and the derivation fills it in. Type it by hand and it has to be the last day of the period `period_start` opens: 2026-09-30 for a quarter starting 2026-07-01, or 2026-08-31 for Aug 2026. A ten-month window cannot be filed under a one-quarter label. While `period_start` is itself off its boundary, the refusal names `period_start` alone: the end is judged once the start opens a real period.

The advice is therefore what it always was — when you write a snapshot by hand or through the API, send the period's first day, or send no `period_start` at all and let the derivation place it. The reason is not what it used to be. It is no longer that a mid-period row is merely hard to find; it is that the row cannot be saved at all. Being hard to find is *why* the rules exist: the forecast list's *This Quarter* tab and the Sales dashboard's *Quota Attainment by Rep* table both match `period_start` against the quarter's real first day, and the nightly sweep picks a period's current row by the window that contains today.

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 @@ -41,7 +41,7 @@ description: 按负责人和周期的销售管道快照——营收预测的基
**你自己填进来的值会被校验,而不只是被原样保留。** 这套派生只补空缺,也依然不会改写随记录一起送进来的值——但一个与它所标记的周期对不上的值,现在会被直接拒收,而不是存下来。这个窗口的两端都被钉住了:

- **`period_start` 必须是所在周期真正的第一天。** 月度快照落在 1 号;季度快照还必须落在季度边界上——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。所以给一条季度快照送 `period_start` = 2026-07-15,它不会再以 **Q3 2026** 的标签落在季度中间:这次保存会被拒绝,报错是 *Period Start must be the first day of the period — e.g. 2026-08-01 for Aug 2026*。
- **`period_end` 必须收在同一个周期的末尾——或者干脆留空。** 留空的话,派生会替你补上;手工填写的话,它必须是 `period_start` 所打开的那个周期的最后一天:起始为 2026-07-01 的季度对应 2026-09-30,2026 年 8 月对应 2026-08-31。一个长达十个月的窗口,挂不到一个季度的标签底下。
- **`period_end` 必须收在同一个周期的末尾——或者干脆留空。** 留空的话,派生会替你补上;手工填写的话,它必须是 `period_start` 所打开的那个周期的最后一天:起始为 2026-07-01 的季度对应 2026-09-30,2026 年 8 月对应 2026-08-31。一个长达十个月的窗口,挂不到一个季度的标签底下。只要 `period_start` 本身不在边界上,拒收就只指出 `period_start`:等起始日真正打开一个周期,才会再校验结束日。

所以建议本身还是老样子——当你手工或通过 API 写一条快照时,请填这个周期的第一天,或者干脆不填 `period_start`,让派生替你放好它。变的是理由。现在的理由不再是"落在周期中间的行不好被找到",而是这一行根本存不下来。"不好被找到"恰恰是这些规则存在的**原因**:预测列表的 *This Quarter* 页签和销售仪表盘的 *Quota Attainment by Rep* 表格,都是拿 `period_start` 去和季度真正的第一天做等值匹配的;而每晚的定时快照,是按"包含今天的那个窗口"挑出当前周期的那一行的。

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 @@ -43,7 +43,7 @@ description: 按負責人和週期的銷售管道快照——營收預測的基
**你自己填進來的值會被檢核,而不只是被原樣保留。** 這套衍生只補空缺,也依然不會改寫隨記錄一起送進來的值——但一個與它所標記的週期對不上的值,現在會被直接拒收,而不是存下來。這個區間的兩端都被釘住了:

- **`period_start` 必須是所在週期真正的第一天。** 月度快照落在 1 號;季度快照還必須落在季度邊界上——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。所以給一筆季度快照送 `period_start` = 2026-07-15,它不會再以 **Q3 2026** 的標籤落在季度中間:這次儲存會被拒絕,錯誤訊息是 *Period Start must be the first day of the period — e.g. 2026-08-01 for Aug 2026*。
- **`period_end` 必須收在同一個週期的結尾——或者乾脆留空。** 留空的話,衍生會替你補上;手動填寫的話,它必須是 `period_start` 所打開的那個週期的最後一天:起始為 2026-07-01 的季度對應 2026-09-30,2026 年 8 月對應 2026-08-31。一個長達十個月的區間,掛不到一個季度的標籤底下。
- **`period_end` 必須收在同一個週期的結尾——或者乾脆留空。** 留空的話,衍生會替你補上;手動填寫的話,它必須是 `period_start` 所打開的那個週期的最後一天:起始為 2026-07-01 的季度對應 2026-09-30,2026 年 8 月對應 2026-08-31。一個長達十個月的區間,掛不到一個季度的標籤底下。只要 `period_start` 本身不在邊界上,拒收就只指出 `period_start`:等起始日真正打開一個週期,才會再檢核結束日。

所以建議本身還是老樣子——當你手動或透過 API 寫一筆快照時,請填這個週期的第一天,或者乾脆不填 `period_start`,讓衍生替你放好它。變的是理由。現在的理由不再是「落在週期中間的資料列不好被找到」,而是這筆資料列根本存不下來。「不好被找到」恰恰是這些規則存在的**原因**:預測列表的 *This Quarter* 頁籤和銷售儀表板的 *Quota Attainment by Rep* 表格,都是拿 `period_start` 去和季度真正的第一天做等值比對的;而每晚的定時快照,是按「包含今天的那個區間」挑出當前週期的那一筆的。

Expand Down
7 changes: 6 additions & 1 deletion src/revenue/objects/quote_line_item.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,15 @@ export const QuoteLineItem = ObjectSchema.create({
readonly: true,
}),

// A NOT NULL column the server fills, ⛔ not `required` (#2060 item 7b),
// the twin's A-21 change; read the note on the twin. `quote_line_item_price_fill`
// (`_line-item-price-fill.ts`) defaults a blank price to the product's list
// price on insert. Measured on 18.0.0-next.5 while it was `required`: the
// New Quote Line Item form refused "Please check the highlighted fields:
// Sales Price" and sent nothing, while the same insert stored the list price.
unit_price: Field.currency({
label: 'Sales Price',
group: 'pricing',
required: true,
storage: { notNull: true },
trackHistory: true,
}),
Expand Down
16 changes: 14 additions & 2 deletions src/sales/objects/forecast.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,12 +383,24 @@ export const Forecast = ObjectSchema.create({
// invariant (a row's window is exactly the calendar period it is labelled
// with), not three independent gates.
//
// So the rule STANDS DOWN while a start rule fires (#2060 item 7c): each
// branch also requires the start that period opens on, in the start rules'
// own patterns. Off a boundary, the arithmetic judges a window that is no
// calendar period. Measured on 18.0.0-next.5 before this: a month starting
// 2026-10-15 drew "Period End must be the last day …" on the end the hook
// derived (2026-10-31), on a typed 2026-10-31 and on an edit that moved only
// the start, and it passed a typed 2026-11-14. Now the refusal names Period
// Start alone, and every row this leaves alone is still refused, by a start
// rule. The hook keeps deriving: the boundary is known here and nowhere else.
//
// ⚠️ `daysBetween(a, b) != 0` rather than `a == b`: both drivers this app
// runs on hand dates back as `YYYY-MM-DD` strings while `addDays` returns a
// timestamp, and a string never `==` a timestamp — the rule would be
// permanently green. `daysBetween` puts both sides through the same
// `toDate`, so the string, an ISO datetime spelling and a JS `Date` all
// compare alike.
// compare alike. (The start's pattern check reads `string()`, which has no
// `Date` overload, so a JS `Date` start is refused here as by the start
// rules; no writer in this app sends one.)
//
// ⚠️ The `!= null` guards are load-bearing for a THIRD hazard beyond the two
// AGENTS.md names: `daysBetween(null, …)` reaches `BigInt(NaN)` and THROWS,
Expand All @@ -407,7 +419,7 @@ export const Forecast = ObjectSchema.create({
// an undeclared `period` is refused BY NAME by the picklist ("Period must
// be one of: month, quarter", measured), so this rule judges only the two
// values it understands and leaves the rest to the schema.
condition: P`has(record.period) && record.period != null && has(record.period_start) && record.period_start != null && has(record.period_end) && record.period_end != null && ((record.period == "month" && daysBetween(record.period_end, addDays(addMonths(record.period_start, 1), -1)) != 0) || (record.period == "quarter" && daysBetween(record.period_end, addDays(addMonths(record.period_start, 3), -1)) != 0))`,
condition: P`has(record.period) && record.period != null && has(record.period_start) && record.period_start != null && has(record.period_end) && record.period_end != null && ((record.period == "month" && matches(string(record.period_start), "^[0-9]{4}-[0-9]{2}-01") && daysBetween(record.period_end, addDays(addMonths(record.period_start, 1), -1)) != 0) || (record.period == "quarter" && matches(string(record.period_start), "^[0-9]{4}-(01|04|07|10)-01") && daysBetween(record.period_end, addDays(addMonths(record.period_start, 3), -1)) != 0))`,
},
{
name: 'snapshot_amounts_non_negative',
Expand Down
5 changes: 5 additions & 0 deletions test/forecast-period-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ describe('the write is REFUSED, not warned about', () => {
// rather than a silent drift in what a caller can rely on.
expect(env.status).toBeUndefined();
expect(env.message).toMatch(/first day of the period|quarter boundary/i);
// …and names the START alone (#2060 item 7c). The hook still derives an
// end from this start, and the window rule no longer judges it: measured
// on 18.0.0-next.5 before that change, the month row drew "Period End must
// be the last day …" as well, for an end nobody typed.
expect(env.message).not.toMatch(/Period End/);
});

it('names the QUARTER half specifically when the day is right and the month is not', async () => {
Expand Down
47 changes: 47 additions & 0 deletions test/forecast-period-end-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,53 @@ describe('the hand-typed window is REFUSED, not warned about', () => {
});
});

// ─────────────────── it stands down while a start rule fires (#2060 7c) ─────

describe('the window rule stands down while a start rule fires (#2060 item 7c)', () => {
/**
* The rule's arithmetic judges the window that an ADMITTED start opens. Off
* that boundary its verdict meant nothing, and it was measured on
* 18.0.0-next.5 before this change on every row below: each drew "Period End
* must be the last day of the period …" beside the start's own refusal. The
* end it judged was one the hook derived, a correctly typed calendar end, or
* one the edit never touched. Each row is still REFUSED, by a start rule, and
* that is now the only refusal: the messages are read off the declared rules,
* so the assertion names which rules fired.
*/
const desk = deskOn(hotcrmStack, 'stand-down');
const START = ruleNamed('period_start_first_of_period')!.message as string;
const QUARTER = ruleNamed('quarter_starts_on_quarter_boundary')!.message as string;
const firedRules = (env: Awaited<ReturnType<typeof refusal>>) => env.fields.map((f) => f.message).sort();

it.each([
{ period: 'month', period_start: '2026-10-15', why: "the card's row: no Period End, the hook derives one", only: [START] },
{ period: 'quarter', period_start: '2026-10-15', why: 'mid-month and mid-quarter, no Period End', only: [START, QUARTER] },
{ period: 'month', period_start: '2026-10-15', period_end: '2026-10-31', why: "October's real last day, typed", only: [START] },
{ period: 'quarter', period_start: '2026-08-01', period_end: '2026-09-30', why: "Q3's real end under a month start", only: [QUARTER] },
])('refuses $period from $period_start naming the start alone ($why)', async ({ why: _why, only, ...doc }) => {
const env = await refusal(() => viaHook(desk, 'insert', { ...BASE, ...doc }));
expect(env.code).toBe('VALIDATION_FAILED');
expect(firedRules(env)).toEqual([...only].sort());
expect(env.message).not.toMatch(/Period End/);
});

it('refuses an edit that moves only the start off its boundary, naming the start alone', async () => {
const row = await viaHook(desk, 'insert', { ...BASE, period: 'month', period_start: '2026-09-01' });
const env = await refusal(() => viaHook(desk, 'update', { id: row.id, period_start: '2026-09-15' }));
expect(firedRules(env)).toEqual([START]);
expect((await storedForecast(desk, row.id))?.period_start).toBe('2026-09-01');
});

it('judges the end again once the start is on its boundary', async () => {
// The control: the rule stands down for the start, not for good. The same
// October with the start corrected and a short end is the window refusal.
const env = await refusal(() =>
viaHook(desk, 'insert', { ...BASE, period: 'month', period_start: '2026-10-01', period_end: '2026-10-15' }),
);
expectCalendarRefusal(env);
});
});

// ────────────────────────────────────────────── the gate can FAIL ──────────

describe('the gate can fail — the same row is admitted without the rule', () => {
Expand Down
Loading
Loading