From 4264b2fb03b9dc8d33d07541a254c595a2f73c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:29:19 +0000 Subject: [PATCH 1/4] fix(forms): a quote line no longer requires a Sales Price the server fills (#2060 item 7b) crm_quote_line_item.unit_price was `required`, so the New Quote Line Item form refused "Please check the highlighted fields: Sales Price" and sent nothing, while the shared price fill (quote_line_item_price_fill) stores the product's list price on every insert that leaves it blank (measured on 18.0.0-next.5: REST insert without a price, 201 with unit_price 75000 = list_price). This is item 7's A-21 change on the twin line object: `required` is dropped, `storage.notNull` keeps the column whole, and the hook stays the producer, so both line objects declare the price alike. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- src/revenue/objects/quote_line_item.object.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/revenue/objects/quote_line_item.object.ts b/src/revenue/objects/quote_line_item.object.ts index 80741a6c9..e7e88d621 100644 --- a/src/revenue/objects/quote_line_item.object.ts +++ b/src/revenue/objects/quote_line_item.object.ts @@ -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, }), From 37e207283f0a0dd82d5f1f9f3975e593857b8d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:29:19 +0000 Subject: [PATCH 2/4] fix(forecast): a mid-period Period Start is refused naming Period Start alone (#2060 item 7c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a start off its calendar boundary, period_end_matches_calendar_period judged a window that is no calendar period. Measured on 18.0.0-next.5: New Forecast, Month, Period Start 2026-10-15 and no Period End answered 400 with "Period Start must be the first day …; Period End must be the last day …", because the hook derives an end and the end rule judged it. The same rule refused a typed 2026-10-31 and an edit that moved only the start, and passed a typed 2026-11-14. Each branch of the end rule now also requires the start its period opens on, in the start rules' own patterns, so the rule stands down exactly when a start rule refuses the row. Of the two remedies the card names, this one was measured to cover every shape above; stopping the hook's derivation cleared only the blank-end insert. The hook is unchanged. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- src/sales/objects/forecast.object.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/sales/objects/forecast.object.ts b/src/sales/objects/forecast.object.ts index af5e06de8..6ba7a2dac 100644 --- a/src/sales/objects/forecast.object.ts +++ b/src/sales/objects/forecast.object.ts @@ -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, @@ -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', From 131cf8430ee145c61ee42b162fe48fa2c5944eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:32:13 +0000 Subject: [PATCH 3/4] test: pin the quote line's Sales Price and the start-only forecast refusal (#2060 items 7b, 7c) 7b: crm_quote_line_item.unit_price joins the NOT NULL server-filled declaration pin, and the Sales Price block runs on both line objects: a line with no price lands at list, a negotiated price is kept, the dry-run preview no longer reports the price missing, and a clear on update is refused by the column. 7c: every mid-period start the boundary suite refuses now names no Period End, and a new block pins the window rule standing down while a start rule fires (the blank-end insert, a typed calendar end, an edit that moves only the start) with the control that it judges the end again once the start is on its boundary. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- test/forecast-period-boundary.test.ts | 5 +++ test/forecast-period-end-boundary.test.ts | 47 +++++++++++++++++++ test/form-required-server-defaults.test.ts | 52 +++++++++++++++++----- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/test/forecast-period-boundary.test.ts b/test/forecast-period-boundary.test.ts index bc91a7302..329545bf6 100644 --- a/test/forecast-period-boundary.test.ts +++ b/test/forecast-period-boundary.test.ts @@ -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 () => { diff --git a/test/forecast-period-end-boundary.test.ts b/test/forecast-period-end-boundary.test.ts index 8943ab5bb..237a9e4d3 100644 --- a/test/forecast-period-end-boundary.test.ts +++ b/test/forecast-period-end-boundary.test.ts @@ -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>) => 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', () => { diff --git a/test/form-required-server-defaults.test.ts b/test/form-required-server-defaults.test.ts index 3eaeff993..d6d41a73c 100644 --- a/test/form-required-server-defaults.test.ts +++ b/test/form-required-server-defaults.test.ts @@ -16,6 +16,10 @@ import { hotcrmStack, signUpPerson, today, type Person } from './helpers/verify- * crm_quote.expiration_date "… Expiration Date" 201, quote_date + 30 * crm_forecast.period_end "… Period End" 201, the period's last day * crm_forecast.snapshot_date "… Snapshot Date" 201, today + * crm_quote_line_item.unit_price "… Sales Price" 201, the list price + * + * (The last row is item 7b, measured on 18.0.0-next.5: the twin line object + * the shared price fill also serves.) * * A hook filled each one, and a form cannot see a hook: it reads `required` as * "the user must type this". Two constructs, chosen by what the spec can say: @@ -58,6 +62,8 @@ const refusalOf = async (write: Promise): Promise => { describe('the create form is told who supplies each value', () => { it.each([ ['crm_opportunity_line_item', 'unit_price'], + // Item 7b: the twin line object, which the shared price fill serves too. + ['crm_quote_line_item', 'unit_price'], ['crm_forecast', 'period_end'], ])('%s.%s is a NOT NULL column the server fills, not a value the writer must supply', (object, field) => { const def = fieldOf(object, field); @@ -197,15 +203,32 @@ describe('the save lands, the value is supplied, and the protection stays', () = }); }); - describe('Sales Price (A-21)', () => { - /** A deal line the rep adds as the form posts it: a product and no price. */ - const repLine = async (doc: AnyRec = {}): Promise<{ line: AnyRec; listPrice: number }> => { - const [product] = await verify.seed('crm_product', [{ name: `Defaults Product ${++k}`, product_code: `DF-${k}`, is_active: true, list_price: 420 }]); + // The shared price fill serves both line objects, so both are pinned alike: + // the deal line (A-21, item 7) and the quote line (item 7b). Measured on + // 18.0.0-next.5 before 7b: the New Quote Line Item form refused "Please check + // the highlighted fields: Sales Price" and sent nothing, while a REST insert + // without a price answered 201 at the list price. + describe.each([ + ['a deal line (A-21)', 'crm_opportunity_line_item'], + ['a quote line (7b)', 'crm_quote_line_item'], + ])('Sales Price on %s', (_line, object) => { + /** The rep's parent for a line: their deal, or a draft quote on it. */ + const parentOf = async (): Promise => { const p = await parties(); - const written = await verify.hooks.run('crm_opportunity_line_item', 'insert', { - crm_opportunity: p.crm_opportunity, crm_product: product!.id, quantity: 2, ...doc, + if (object === 'crm_opportunity_line_item') return { crm_opportunity: p.crm_opportunity }; + const quote = await verify.hooks.run('crm_quote', 'insert', { name: `Q-${++k}`, ...p }, as(rep)); + return { crm_quote: quote.id }; + }; + const productAt420 = async (): Promise => + (await verify.seed('crm_product', [{ name: `Defaults Product ${++k}`, product_code: `DF-${k}`, is_active: true, list_price: 420 }]))[0]!; + + /** A line the rep adds as the form posts it: a parent, a product and no price. */ + const repLine = async (doc: AnyRec = {}): Promise<{ line: AnyRec; listPrice: number }> => { + const product = await productAt420(); + const written = await verify.hooks.run(object, 'insert', { + ...(await parentOf()), crm_product: product.id, quantity: 2, ...doc, }, as(rep)); - return { line: await stored('crm_opportunity_line_item', written.id), listPrice: 420 }; + return { line: await stored(object, written.id), listPrice: 420 }; }; it('a line with no price lands at the product list price', async () => { @@ -217,15 +240,24 @@ describe('the save lands, the value is supplied, and the protection stays', () = expect((await repLine({ unit_price: 399 })).line.unit_price).toBe(399); }); + it('the dry-run preview, which runs no hook, no longer reports the price as missing', async () => { + // `verify.validate` is the import preview's door (see Expiration Date + // above). While the price was `required` it answered "Sales Price is + // required" for a line the import itself would price. + const product = await productAt420(); + const verdict = await verify.validate(object, { ...(await parentOf()), crm_product: product.id, quantity: 2 }, as(rep)); + expect(verdict.results[0]!.errors).toEqual([]); + }); + it('an update that clears the price is refused by the column, and the price stays', async () => { const { line } = await repLine(); // In process this is the driver's NOT NULL refusal, which carries no // `code`; the REST door translates it to 400 `VALIDATION_FAILED` // (measured in the PR). The table and column name it, so this cannot be // some other failure passing for it. - const refusal = await refusalOf(verify.hooks.run('crm_opportunity_line_item', 'update', { id: line.id, unit_price: null }, as(rep))); - expect(refusal.message).toMatch(/NOT NULL constraint failed: crm_opportunity_line_item\.unit_price/); - expect((await stored('crm_opportunity_line_item', line.id)).unit_price).toBe(line.unit_price); + const refusal = await refusalOf(verify.hooks.run(object, 'update', { id: line.id, unit_price: null }, as(rep))); + expect(refusal.message).toContain(`NOT NULL constraint failed: ${object}.unit_price`); + expect((await stored(object, line.id)).unit_price).toBe(line.unit_price); }); }); }); From 8e2d307b477d84b0b6aebbd0a1d0e9366a7de11c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:34:51 +0000 Subject: [PATCH 4/4] docs: a forecast start off its boundary is the only thing the refusal names (#2060 items 7b, 7c) forecasting (en, zh-Hans, zh-Hant) gains one sentence on the period_end bullet: while period_start is itself off its boundary, the refusal names period_start alone, and the end is judged once the start opens a real period. The quotes page already says a line's unit price defaults to the product's list price, which the form now honours, so it is unchanged. The changeset describes both items for the release notes. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- .../2060-items-7b-7c-line-price-and-period-start.md | 10 ++++++++++ content/docs/sales/forecasting.mdx | 2 +- content/docs/sales/forecasting.zh-Hans.mdx | 2 +- content/docs/sales/forecasting.zh-Hant.mdx | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .changeset/2060-items-7b-7c-line-price-and-period-start.md diff --git a/.changeset/2060-items-7b-7c-line-price-and-period-start.md b/.changeset/2060-items-7b-7c-line-price-and-period-start.md new file mode 100644 index 000000000..32748e1a2 --- /dev/null +++ b/.changeset/2060-items-7b-7c-line-price-and-period-start.md @@ -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. diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index a923fade6..c8d8ad9f9 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -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. diff --git a/content/docs/sales/forecasting.zh-Hans.mdx b/content/docs/sales/forecasting.zh-Hans.mdx index 127ce65df..e7ac7561a 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -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` 去和季度真正的第一天做等值匹配的;而每晚的定时快照,是按"包含今天的那个窗口"挑出当前周期的那一行的。 diff --git a/content/docs/sales/forecasting.zh-Hant.mdx b/content/docs/sales/forecasting.zh-Hant.mdx index 171cc71c7..0c83e1688 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -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` 去和季度真正的第一天做等值比對的;而每晚的定時快照,是按「包含今天的那個區間」挑出當前週期的那一筆的。