diff --git a/.changeset/2060-item-7-required-vs-defaults.md b/.changeset/2060-item-7-required-vs-defaults.md new file mode 100644 index 000000000..995eaec62 --- /dev/null +++ b/.changeset/2060-item-7-required-vs-defaults.md @@ -0,0 +1,13 @@ +--- +"hotcrm": patch +--- + +**Three New forms no longer make you type a value the app fills in for you.** + +- **Opportunity line item.** Leave **Sales Price** blank and the line takes the product's **List Price**, which you can still change. Before, the form refused to save with "Please check the highlighted fields: Sales Price", although the app already defaulted a blank price to the list price for lines added any other way. +- **Quote.** Leave **Expiration Date** blank and the quote expires 30 days after its **Quote Date**. The form now says "Set automatically when saved." under the field, as it already did for **Quote Date**. Before, the form refused to save until you picked a date. +- **Forecast.** Leave **Period End** blank and it is filled in from **Period** and **Period Start**. Leave **Snapshot Date** blank and it is today, with the same "Set automatically when saved." note. Before, the form refused to save until you filled both in. **Period Start** is still asked for. + +Nothing else changes. A date or price you enter yourself is kept, and the existing rules still apply to it. Saving a quote or forecast with its Expiration Date or Snapshot Date cleared is still refused. Clearing a line's Sales Price is still refused too, but the message now names the field `unit_price` rather than "Sales Price". An import's dry-run preview no longer reports a blank Sales Price, Expiration Date, Period End or Snapshot Date as an error, so the preview agrees with the import itself, which already filled them in. + +The documentation now says so too: the quotes page lists the 30-day default, the forecasting page says a blank snapshot date is today, and the automation page says these two defaults are declared on their fields rather than computed by a hook. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index 365ed4713..a2d4390fb 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -102,7 +102,7 @@ Time-based automation is implemented as **scheduled flows** — flows whose star > **From ObjectStack 17.5.0, scheduled flows are off until the deployment turns them on.** The platform runs package-authored scheduled work only when the deployment sets `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` (`1`, `on` and `yes` also count). Without it, none of the eight flows above runs — no contract or quote expiry, no SLA monitor, no reminders, no forecast snapshots — and `os doctor` prints the effective value. Under the `isolated` tenancy posture a scheduled flow must also name the organization it acts as; these eight do not, so they are not armed there. -Date-driven field logic that needs no orchestration — defaulting a quote's expiration date, freezing an expired/accepted quote, deriving a forecast period — lives in lightweight **object hooks** (`beforeInsert` / `beforeUpdate`) rather than a scheduled sweep. +Date-driven field logic that needs no orchestration lives on the object rather than in a scheduled sweep. A default the field can state for itself is declared on the field: a new quote's expiration date is 30 days after its quote date, and a new forecast's snapshot date is today, unless the person saving it enters another date. The rest — freezing an expired/accepted quote, deriving a forecast period — lives in lightweight **object hooks** (`beforeInsert` / `beforeUpdate`). The division of labour on forecasts is worth calling out, because it is the pattern to copy: the **Forecast Snapshot** flow decides *who* gets a snapshot and *what the totals are*, while the forecast object's hook decides *which calendar period the snapshot belongs to*. A cron expression can say "every night"; it cannot say "the first day of this quarter", so that boundary is derived once, by the object, for every writer. diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index 35ba9b153..9078c6291 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -102,7 +102,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自 ObjectStack 17.5.0 起,计划类流程默认关闭,需由部署方开启。** 平台只在部署设置了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同样有效)时,才运行包内编写的计划类工作。不设置时,上表八个计划类流程一个都不会运行 —— 没有合同与报价的自动过期、没有 SLA 监控、没有提醒、没有预测快照;`os doctor` 会打印当前生效的取值。在 `isolated` 租户隔离模式下,计划类流程还必须声明它代表哪个组织运行;这八个流程都没有声明,因此在该模式下不会被挂载。 -无需编排的日期驱动字段逻辑 —— 例如为报价设置默认过期日、冻结已过期/已接受的报价、推导预测周期 —— 放在轻量的 **对象钩子**(`beforeInsert` / `beforeUpdate`)中,而非计划清扫。 +无需编排的日期驱动字段逻辑放在对象上,而非计划清扫。字段自己就能表达的默认值直接声明在字段上:新报价的到期日期默认为报价日期后 30 天,新预测的快照日期默认为当天,除非保存的人填了别的日期。其余的 —— 例如冻结已过期/已接受的报价、推导预测周期 —— 放在轻量的 **对象钩子**(`beforeInsert` / `beforeUpdate`)中。 预测这一块的分工值得单独点出来,因为它正是要照抄的模式:**预测快照** 流程决定*谁*会拿到快照、*各项合计是多少*,而预测对象的钩子决定*这条快照属于哪个日历周期*。一条 cron 计划说得出「每晚」,却说不出「本季度的第一天」—— 所以这个周期边界由对象统一推导一次,供每个写入方共用。 diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index d76a46619..a9b800a09 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -104,7 +104,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自 ObjectStack 17.5.0 起,排程類流程預設關閉,需由部署方開啟。** 平台只在部署設定了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同樣有效)時,才執行套件內撰寫的排程類工作。未設定時,上表八個排程類流程一個都不會執行 —— 沒有合約與報價的自動到期、沒有 SLA 監控、沒有提醒、沒有預測快照;`os doctor` 會列印目前生效的取值。在 `isolated` 租戶隔離模式下,排程類流程還必須宣告它代表哪個組織執行;這八個流程都沒有宣告,因此在該模式下不會被掛載。 -無需編排的日期驅動欄位邏輯 —— 例如為報價設定預設過期日、凍結已過期/已接受的報價、推導預測週期 —— 放在輕量的 **物件鉤子**(`beforeInsert` / `beforeUpdate`)中,而非排程清掃。 +無需編排的日期驅動欄位邏輯放在物件上,而非排程清掃。欄位自己就能表達的預設值直接宣告在欄位上:新報價的到期日期預設為報價日期後 30 天,新預測的快照日期預設為當天,除非儲存的人填了別的日期。其餘的 —— 例如凍結已過期/已接受的報價、推導預測週期 —— 放在輕量的 **物件鉤子**(`beforeInsert` / `beforeUpdate`)中。 預測這一塊的分工值得單獨點出來,因為它正是要照抄的模式:**預測快照** 流程決定*誰*會拿到快照、*各項合計是多少*,而預測物件的鉤子決定*這筆快照屬於哪個日曆週期*。一條 cron 排程說得出「每晚」,卻說不出「本季的第一天」—— 所以這個週期邊界由物件統一推導一次,供每個寫入方共用。 diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index 81311e0ad..a923fade6 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -30,7 +30,7 @@ A deal's Forecast Category is set automatically from its **Stage**, so the same ## How periods are derived -You choose a `period`; the system computes the rest. Supply a `period_start` — that period's own first day — to snapshot a specific quarter or month, or leave it out and the snapshot lands on the calendar period containing its snapshot date. For a quarterly forecast taken on 2026-08-02: +You choose a `period`; the system computes the rest. Supply a `period_start` — that period's own first day — to snapshot a specific quarter or month, or leave it out and the snapshot lands on the calendar period containing its snapshot date, which is today unless you enter another. For a quarterly forecast taken on 2026-08-02: - `period_start` → 2026-07-01 - `period_end` → 2026-09-30 diff --git a/content/docs/sales/forecasting.zh-Hans.mdx b/content/docs/sales/forecasting.zh-Hans.mdx index 714adc1b4..127ce65df 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -30,7 +30,7 @@ description: 按负责人和周期的销售管道快照——营收预测的基 ## 周期是如何派生的 -你只需要选定 `period`,其余的由系统算出。想给某个特定的季度或月份拍快照,就补上一个 `period_start`——填那个周期真正的第一天;不给的话,这条快照就会落在包含它快照日期的那个日历周期上。以一次在 2026-08-02 拍下的季度预测为例: +你只需要选定 `period`,其余的由系统算出。想给某个特定的季度或月份拍快照,就补上一个 `period_start`——填那个周期真正的第一天;不给的话,这条快照就会落在包含它快照日期的那个日历周期上;快照日期不填就是当天。以一次在 2026-08-02 拍下的季度预测为例: - `period_start` → 2026-07-01 - `period_end` → 2026-09-30 diff --git a/content/docs/sales/forecasting.zh-Hant.mdx b/content/docs/sales/forecasting.zh-Hant.mdx index cc87bcdb9..171cc71c7 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -32,7 +32,7 @@ description: 按負責人和週期的銷售管道快照——營收預測的基 ## 週期是如何衍生的 -你只需要選定 `period`,其餘的由系統算出。想給某個特定的季度或月份拍快照,就補上一個 `period_start`——填那個週期真正的第一天;不給的話,這筆快照就會落在包含它快照日期的那個日曆週期上。以一次在 2026-08-02 拍下的季度預測為例: +你只需要選定 `period`,其餘的由系統算出。想給某個特定的季度或月份拍快照,就補上一個 `period_start`——填那個週期真正的第一天;不給的話,這筆快照就會落在包含它快照日期的那個日曆週期上;快照日期不填就是當天。以一次在 2026-08-02 拍下的季度預測為例: - `period_start` → 2026-07-01 - `period_end` → 2026-09-30 diff --git a/content/docs/sales/quotes.mdx b/content/docs/sales/quotes.mdx index 7a8d903a8..a89d329ec 100644 --- a/content/docs/sales/quotes.mdx +++ b/content/docs/sales/quotes.mdx @@ -72,6 +72,7 @@ On the quote's *Related* tab, the line items panel shows each line's product, qu ## What happens automatically +- **A blank expiration date is 30 days after the quote date** — leave **Expiration Date** empty on a new quote and the quote expires 30 days after its **Quote Date**, which is itself today unless you pick another. A date you enter is kept, and it still has to fall after the quote date. - **Daily expiration sweep** — where your deployment runs scheduled work, every night at 1 AM the system marks any quote whose expiration has passed as **Expired** (unless it's already accepted or rejected), which keeps your active list clean. Scheduled work is off until the deployment switches it on (see [Administration › Automation](/docs/administration/automation#scheduled-automation)). Without it nothing expires a quote: one past its expiration date keeps its status until someone changes it. - **On acceptance** — marking a quote *Accepted* is what drafts the contract. The system inserts a contract in **Draft** status, typed *Subscription*, on a 12-month term starting today, carrying the quote's account, primary contact, related opportunity, owner and total price. The draft is the system's own write, so it is made whoever records the acceptance — a sales rep included, although a rep cannot create a contract by hand. It then advances the linked opportunity to *Closed Won*, dated today, and — only when the rep recorded no win reason — stamps **Quote Accepted** as one. An opportunity already closed, won or lost, is left where it is. diff --git a/content/docs/sales/quotes.zh-Hans.mdx b/content/docs/sales/quotes.zh-Hans.mdx index 23d5ff2a8..e39349224 100644 --- a/content/docs/sales/quotes.zh-Hans.mdx +++ b/content/docs/sales/quotes.zh-Hans.mdx @@ -71,6 +71,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling ## 自动发生的事 +- **到期日期留空,就是报价日期后 30 天**——新建报价时不填**到期日期**,报价就在**报价日期**(不填也就是当天)30 天后到期。你自己填的日期会原样保留,但它仍须晚于报价日期。 - **每日过期扫描**——在运行计划类工作的部署上,每晚凌晨 1 点,系统会将任何到期日期已过的报价标记为**已过期**(除非它已被接受或拒绝),让你的活跃列表保持干净整洁。计划类工作要由部署方开启才会运行(见[系统管理 › 自动化](/zh-Hans/docs/administration/automation#scheduled-automation))。没有开启时,没有任何东西会让报价过期:过了到期日期的报价会一直保持原状态,直到有人去改它。 - **接受时**——把一份报价标记为*已接受*,就是起草合同的那个动作。系统会插入一份*草稿*状态的合同,合同类型为*订阅合同*,期限 12 个月、从今天起算,带着报价上的所属客户、主要联系人、关联商机、负责人和总金额。起草合同是系统自己的写入,所以不论是谁记录了接受都会起草——销售代表也一样,尽管销售代表不能亲手新建合同。随后它把关联的商机推进到*成交*,预计成交日期取今天;并且仅当销售代表没有填赢单原因时,才补上**报价被接受**。已经关闭的商机——不论成交还是失败——原样不动。 diff --git a/content/docs/sales/quotes.zh-Hant.mdx b/content/docs/sales/quotes.zh-Hant.mdx index 4acd59294..8915b6b73 100644 --- a/content/docs/sales/quotes.zh-Hant.mdx +++ b/content/docs/sales/quotes.zh-Hant.mdx @@ -73,6 +73,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling ## 自動發生的事 +- **到期日期留空,就是報價日期後 30 天**——建立報價時不填**到期日期**,報價就在**報價日期**(不填也就是當天)30 天後到期。你自己填的日期會原樣保留,但它仍須晚於報價日期。 - **每日到期掃描**——在執行排程類工作的部署上,每晚凌晨 1 點,系統會將任何到期日期已過的報價標記為**已過期**(除非它已被接受或拒絕),讓你的活躍列表保持乾淨整潔。排程類工作要由部署方開啟才會執行(見[系統管理 › 自動化](/zh-Hant/docs/administration/automation#scheduled-automation))。沒有開啟時,沒有任何東西會讓報價過期:過了到期日期的報價會一直保持原狀態,直到有人去改它。 - **接受時**——把一份報價標記為*已接受*,就是起草合約的那個動作。系統會插入一份*草稿*狀態的合約,合約類型為*訂閱合約*,期限 12 個月、從今天起算,帶著報價上的所屬客戶、主要聯絡人、關聯商機、負責人和總金額。起草合約是系統自己的寫入,所以不論是誰記錄了接受都會起草——銷售代表也一樣,儘管銷售代表不能親手新建合約。隨後它把關聯的商機推進到*成交*,預計成交日期取今天;並且僅當銷售代表沒有填贏單原因時,才補上**報價被接受**。已經關閉的商機——不論成交還是失敗——原樣不動。 diff --git a/src/revenue/objects/opportunity_line_item.object.ts b/src/revenue/objects/opportunity_line_item.object.ts index a0301cdb7..4452eeb85 100644 --- a/src/revenue/objects/opportunity_line_item.object.ts +++ b/src/revenue/objects/opportunity_line_item.object.ts @@ -175,10 +175,23 @@ export const OpportunityLineItem = ObjectSchema.create({ description: "Auto-populated from the product's List Price.", }), + // A NOT NULL column the server fills, ⛔ not `required` (#2060 item 7, A-21). + // `opportunity_line_item_price_fill` (`_line-item-price-fill.ts`) defaults + // a blank price to the product's list price on insert, and + // `crm_product.list_price` is itself required, so every insert that runs + // the app's hooks stores one. `required` is the WRITER's contract, and the + // console form reads it as "the user must type this": measured on + // 18.0.0-next.3, the create form refused "Please check the highlighted + // fields: Sales Price" and sent nothing, while the same insert over REST + // stored the list price. A cross-record default has no declared form + // (`defaultValue` reads this record only), so the hook stays the producer + // and `storage.notNull` keeps the column whole: an import with automations + // off, or an update that clears the price, is still refused (400 + // `VALIDATION_FAILED`). This is the spec's "engine-populated column" + // posture (`storage.notNull` without `required`, ADR-0113). unit_price: Field.currency({ label: 'Sales Price', group: 'pricing', - required: true, storage: { notNull: true }, description: 'Negotiated unit price (may differ from list price)', trackHistory: true, diff --git a/src/revenue/objects/quote.hook.ts b/src/revenue/objects/quote.hook.ts index 31d19c073..a189cc730 100644 --- a/src/revenue/objects/quote.hook.ts +++ b/src/revenue/objects/quote.hook.ts @@ -6,7 +6,6 @@ import type { HookApi } from '../../sales/objects/_hook-api'; /** * Quote workflow hook. * - * - Defaults `expiration_date` to `quote_date + 30 days` when missing. * - Freezes quotes once `accepted` or `expired` — against USER edits only: a * write that is purely the engine clearing a link is let through. The * freeze covers the quote's LINE ITEMS too (#2060, #2088): a user may not @@ -41,7 +40,7 @@ const quoteValidation: Hook = { // delete fires it too and is let through below, unjudged. events: ['beforeInsert', 'beforeUpdate', 'beforeDelete'], priority: 200, - description: 'Default expiration date; freeze accepted/expired quotes and their lines; derive the header totals.', + description: 'Freeze accepted/expired quotes and their lines; derive the header totals.', handler: async (ctx: HookContext) => { // The refusal envelope. ⚠️ Mirrored from `src/sales/objects/_refusal.ts` because a lowered // body has no module scope and `extractHookBody` THROWS on an import; @@ -125,35 +124,9 @@ const quoteValidation: Hook = { return label ? `Quote ${label}` : 'Quote'; } - /** - * `iso` + `days`, on ONE calendar — UTC, end to end. - * - * The base is not an instant here, it is a stored DATE: a bare - * `YYYY-MM-DD` is parsed by the date-only form of the spec, which anchors - * it at UTC midnight (measured: `new Date('2026-01-01')` is - * `2026-01-01T00:00:00.000Z` in every zone). So the anchor was already - * UTC and only the arithmetic was not — and reading a UTC-midnight anchor - * on the LOCAL calendar is off by a whole day west of Greenwich, where - * that instant is the previous evening (`getDate()` on the value above - * answers 31, not 1). - * - * That is why this site's exposure is not the one-hour window the - * "advance now by N days" hooks have: the anchor does not move with the - * clock, so every quote whose [quote_date, quote_date + days] span crosses - * a DST transition took a 23 h day and rendered a day short — measured at - * 60 of 730 consecutive base dates in `America/New_York`, `Europe/Berlin`, - * `America/Santiago`, `Australia/Sydney` and `Pacific/Auckland` alike. - */ - function addDays(iso: string, days: number): string { - const d = new Date(iso); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); - } - - if (event === 'beforeInsert' && !input.expiration_date) { - const base = typeof input.quote_date === 'string' ? input.quote_date : new Date().toISOString().slice(0, 10); - input.expiration_date = addDays(base, 30); - } + // `expiration_date`'s 30-day default is the field's own `defaultValue` + // (`quote.object.ts`, #2060 item 7), which the engine applies before this + // hook runs, so this hook no longer stamps it. // ⚠️ Guard ONLY genuine USER edits (`ctx.user?.id` present). System / seed / // backfill writes carry no user and legitimately re-apply business fields @@ -394,11 +367,12 @@ const quoteAcceptedContractDraft: Hook = { // Real calendar months — `days * 30` shorted a 12-month term by ~5 days // and only slipped past contract_validation's ±1-month tolerance by luck. // - // On the UTC calendar throughout, for the reason `addDays` documents in - // the sibling hook above: the base is a UTC-midnight-anchored date string, - // so a local `getMonth`/`setMonth` step reads that anchor as the previous - // evening and renders a day short whenever the offset at the end of the - // term differs from the offset at its start. A 12-month term makes that + // On the UTC calendar throughout: the base is a stored DATE, and a bare + // `YYYY-MM-DD` parses to UTC midnight (measured: `new Date('2026-01-01')` + // is `2026-01-01T00:00:00.000Z` in every zone), so a local + // `getMonth`/`setMonth` step reads that anchor as the previous evening + // west of Greenwich and renders a day short whenever the offset at the + // end of the term differs from the offset at its start. A 12-month term makes that // rare but not absent — measured on 9 of 730 consecutive start dates in // `America/New_York` (e.g. `2026-11-02` + 12 months answered // `2027-11-01`), and it is `crm_contract.end_date`, a date the customer is diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index a17bd7326..b0493a41e 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -198,11 +198,28 @@ export const Quote = ObjectSchema.create({ defaultValue: cel`today()`, }), + // A quote is valid for 30 days from its quote date unless the rep sets + // another date (#2060 item 7, B-11). Declared here rather than stamped by + // `quote_workflow`, which did it until this change: measured on + // 18.0.0-next.3, the console's New Quote form refused "Please check the + // highlighted fields: Expiration Date" and sent nothing, because the form + // cannot see a hook's default, while a REST insert stored quote_date + 30. + // A runtime `defaultValue` is the one default the form reads as the + // server's to fill ("Set automatically when saved.", no required mark on + // create), and the engine applies it on every insert door, an import with + // automations off and the dry-run preview included. `required` stays: an + // update may still not clear the date. + // + // ⚠️ `record.quote_date` is in scope because `quote_date` is declared + // ABOVE this field: the engine applies defaults in declaration order, so + // a quote that names no date already holds today's here. + // `test/form-required-server-defaults.test.ts` pins both. expiration_date: Field.date({ label: 'Expiration Date', group: 'terms', required: true, storage: { notNull: true }, + defaultValue: cel`addDays(record.quote_date, 30)`, }), // Pricing diff --git a/src/sales/objects/forecast.hook.ts b/src/sales/objects/forecast.hook.ts index 735df21af..49522eb68 100644 --- a/src/sales/objects/forecast.hook.ts +++ b/src/sales/objects/forecast.hook.ts @@ -8,8 +8,10 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; * Auto-derives the whole period family — `period_start`, `period_end` and * `period_label` — from `period` (+ `period_start`, when the caller supplies * one) so callers (UI, AI skill, the `forecast_snapshot` scheduled flow) don't - * have to compute them. Also stamps `snapshot_date` to today on insert if - * unset. + * have to compute them. `snapshot_date` is not stamped here: it defaults to + * today through its own `defaultValue` (`forecast.object.ts`, #2060 item 7), + * which the engine applies before this hook, so the anchor below always sees + * it on insert. * * Why `period_start` is derived here and nowhere else: a flow template can * express `{TODAY()}` and day offsets, but it has no way to say "the first day @@ -27,7 +29,7 @@ const forecastDerive: Hook = { object: 'crm_forecast', events: ['beforeInsert', 'beforeUpdate'], priority: 200, - description: 'Derive period_start, period_end and period_label from period (+ snapshot_date).', + description: 'Derive period_start, period_end and period_label from period (anchored on snapshot_date).', handler: async (ctx: HookContext) => { // ⛔ A PREDICATE UPDATE DERIVES NOTHING HERE — ADR-0058 Addendum II D3. // @@ -127,10 +129,6 @@ const forecastDerive: Hook = { if (!input.period_label) input.period_label = labelFor(period, start); } } - - if (!input.snapshot_date && !previous?.snapshot_date) { - input.snapshot_date = isoDate(new Date()); - } }, }; diff --git a/src/sales/objects/forecast.object.ts b/src/sales/objects/forecast.object.ts index 31f9a37bd..af5e06de8 100644 --- a/src/sales/objects/forecast.object.ts +++ b/src/sales/objects/forecast.object.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { F, P } from '@objectstack/spec'; +import { F, P, cel } from '@objectstack/spec'; /** * Forecast Object @@ -16,8 +16,9 @@ import { F, P } from '@objectstack/spec'; * scheduled flow, nightly at 03:00, one current-quarter row per active * opportunity owner (#590). It never touches `quota`. * • `revenue_forecasting` AI skill on demand - * • `forecast.hook.ts` derives the period family + `snapshot_date` on every - * write; it creates nothing on its own. + * • `forecast.hook.ts` derives the period family on every write; it creates + * nothing on its own. `snapshot_date` defaults to today through its own + * `defaultValue` below. * * Forecast categories follow the standard Salesforce ladder, and the buckets * are CUMULATIVE — each is a subset of the one above it: @@ -99,11 +100,21 @@ export const Forecast = ObjectSchema.create({ // (`period_end_matches_calendar_period` below), and the description states // that rule rather than the weaker `period_end_after_start` it subsumes: // the form says what is actually enforced. + // + // A NOT NULL column the hook fills, ⛔ not `required` (#2060 item 7, C-08). + // Measured on 18.0.0-next.3: the New Forecast form refused "Please check + // the highlighted fields: … Period End …" and sent nothing, because the + // form reads `required` as "the user must type this" and cannot see the + // derivation, while a REST insert carrying only period + period_start + // stored the derived end. The derivation cannot be a declared default: + // `period_start` may itself be derived by the hook, after defaults run. + // So the hook stays the producer (one derivation, see its header), and + // `storage.notNull` keeps the column whole: an import with automations off + // that leaves it out is still refused. period_end: Field.date({ label: 'Period End', description: 'Normally derived automatically from Period and Period Start. If set by hand, it must be the last day of that period — e.g. 2026-09-30 for a quarter starting 2026-07-01, or 2026-08-31 for Aug 2026.', - required: true, storage: { notNull: true }, group: 'basic', }), @@ -131,11 +142,21 @@ export const Forecast = ObjectSchema.create({ group: 'basic', }), + // Today unless the writer names a day (#2060 item 7, C-08). Declared + // rather than stamped by `forecast_derive_period`, which did it until this + // change: measured on 18.0.0-next.3, the New Forecast form refused on + // Snapshot Date and sent nothing, because the form cannot see a hook's + // default. A runtime `defaultValue` is the one default the form reads as + // the server's to fill ("Set automatically when saved."), and the engine + // applies it on every insert door, before the hook, which still anchors a + // derived `period_start` on it. `required` stays: an update may still not + // clear the date. snapshot_date: Field.date({ label: 'Snapshot Date', description: 'The day this snapshot was captured.', required: true, storage: { notNull: true }, + defaultValue: cel`today()`, group: 'meta', }), diff --git a/src/sales/pages/opportunity_detail.page.ts b/src/sales/pages/opportunity_detail.page.ts index 6da43042e..c583d9f49 100644 --- a/src/sales/pages/opportunity_detail.page.ts +++ b/src/sales/pages/opportunity_detail.page.ts @@ -285,8 +285,8 @@ export const OpportunityDetailPage: Page = { // [linkField]: }` — and // nothing else. On this object that would leave // `quantity` and `unit_price` unset, and both are - // `required` + `storage.notNull`, so the insert - // would be refused. It is not, because the two + // `storage.notNull` (`quantity` also `required`), + // so the insert would be refused. It is not, because the two // gaps are already filled by machinery that // predates this list: // @@ -306,8 +306,8 @@ export const OpportunityDetailPage: Page = { // inserts the picker's exact two-key shape and // asserts the complete priced row, with an ablation // that unbinds the hook and shows the same insert - // rejected as "Sales Price is required" — so the - // assertion cannot pass vacuously. + // refused by the `unit_price` NOT NULL column — so + // the assertion cannot pass vacuously. // // ⛔ Do not add a `label`. The button falls back to // the platform's own localized "Add"; the diff --git a/test/forecast-period-boundary.test.ts b/test/forecast-period-boundary.test.ts index 7c19c9ec4..bc91a7302 100644 --- a/test/forecast-period-boundary.test.ts +++ b/test/forecast-period-boundary.test.ts @@ -107,10 +107,10 @@ const deskOn = (boot: () => Promise, label: string) => { * A write as PRODUCTION performs it: the manager's, through the engine — so * `forecast_derive_period` runs first and the engine's checks second. * - * This is not decoration. `period_end` is `required` + `notNull`, and the - * required-field check runs BEFORE the `validations[]` rules — so a write the - * hook did not see would be refused with "Period End is required" and never - * reach the boundary rule at all. The hook is what puts the drifting + * This is not decoration. `period_end` is a NOT NULL column (no longer + * `required`, #2060 item 7) and only the hook fills it — so a write the hook + * did not see would carry no end, the boundary rule would have nothing to + * judge, and the column would refuse the row. The hook is what puts the drifting * `period_end` (2026-08-15 → 2026-10-31) on the record, which is the exact row * the rule has to refuse. */ diff --git a/test/form-required-server-defaults.test.ts b/test/form-required-server-defaults.test.ts new file mode 100644 index 000000000..3eaeff993 --- /dev/null +++ b/test/form-required-server-defaults.test.ts @@ -0,0 +1,231 @@ +// 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 stack, { type AnyRec } from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, today, type Person } from './helpers/verify-stack'; + +/** + * #2060 item 7 — a create form refused a save the server would complete + * (findings A-21, B-11, C-08 of the browser pass #2058). + * + * Measured on 18.0.0-next.3 before this change, in the console and over REST: + * + * field create form REST insert without it + * crm_opportunity_line_item.unit_price "… highlighted: Sales Price" 201, the list price + * 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 + * + * 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: + * + * - a default the spec can state is DECLARED (`defaultValue`), and the hook + * that stamped it no longer does. `required` stays. The form reads a + * runtime default as the server's to fill, and the engine applies it on + * every insert door, including the ones that run no hook. + * - a value only a hook can compute (from another record, or from a sibling + * the hook itself derives) keeps the hook as its producer and drops + * `required`: the column stays NOT NULL (`storage.notNull` alone, the + * spec's engine-populated column), so a write that ends up without one is + * still refused. + * + * Every write here is a real person's, through the engine's write door + * (`hooks.run`) on the app booted by `@objectstack/verify`. + */ + +const objects: AnyRec[] = (stack as AnyRec).objects ?? []; +const fieldsOf = (name: string): AnyRec => { + const object = objects.find((o) => o.name === name); + if (!object) throw new Error(`object ${name} is not registered`); + return object.fields; +}; +const fieldOf = (object: string, field: string): AnyRec => fieldsOf(object)[field]; + +/** The refusal envelope a rejected write carries, read off the thrown error. */ +const refusalOf = async (write: Promise): Promise => { + try { + await write; + } catch (err) { + const e = err as AnyRec; + return { code: e?.code, status: e?.status, message: String(e?.message ?? ''), fields: e?.fields }; + } + throw new Error('the write was admitted'); +}; + +// ─────────────────────────────────────────────── what the form reads ── + +describe('the create form is told who supplies each value', () => { + it.each([ + ['crm_opportunity_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); + expect(def.required, `${object}.${field} is required again, so the create form refuses a blank one`).toBeFalsy(); + expect(def.storage?.notNull, `${object}.${field} lost its column constraint`).toBe(true); + expect(def.defaultValue).toBeUndefined(); + }); + + it.each([ + ['crm_quote', 'expiration_date', 'addDays(record.quote_date, 30)'], + ['crm_forecast', 'snapshot_date', 'today()'], + ])('%s.%s declares its default as a runtime expression, and stays required', (object, field, source) => { + const def = fieldOf(object, field); + // The CEL envelope is the shape the console classifies as server-owned + // (no required mark on create, "Set automatically when saved."). + expect(def.defaultValue).toEqual({ dialect: 'cel', source }); + expect(def.required).toBe(true); + expect(def.storage?.notNull).toBe(true); + }); + + it('quote_date is declared before expiration_date, the order the expiration default reads it in', () => { + // The engine applies defaults in declaration order (`applyFieldDefaults` + // walks the object's fields), so a quote that names no date holds today's + // in `record.quote_date` only because its field comes first. + const order = Object.keys(fieldsOf('crm_quote')); + expect(order.indexOf('quote_date')).toBeGreaterThanOrEqual(0); + expect(order.indexOf('quote_date')).toBeLessThan(order.indexOf('expiration_date')); + }); +}); + +// ─────────────────────────────────────────────── on the booted app ── + +describe('the save lands, the value is supplied, and the protection stays', () => { + let verify: VerifyStack; + let rep: Person; + let manager: Person; + let k = 0; + beforeAll(async () => { + verify = await hotcrmStack(); + rep = await signUpPerson(verify, 'rep@form-required-server-defaults.test', { + name: 'Sales Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + manager = await signUpPerson(verify, 'manager@form-required-server-defaults.test', { + name: 'Sales Manager', positions: ['sales_manager'], permissionSets: ['sales_manager'], + }); + }, 120_000); + + /** The rep's account, contact and deal, written as the system. */ + const parties = async (): Promise => { + const [account] = await verify.seed('crm_account', [{ name: `Defaults Co ${++k}`, owner_id: rep.id }]); + const [contact] = await verify.seed('crm_contact', [{ + first_name: 'Dana', last_name: `Defaults ${k}`, email: `dana${k}@form-required-server-defaults.test`, crm_account: account!.id, owner_id: rep.id, + }]); + const [deal] = await verify.seed('crm_opportunity', [{ + name: `Defaults Deal ${k}`, amount: 10_000, stage: 'proposal', close_date: '2030-06-30', crm_account: account!.id, + primary_contact: contact!.id, owner_id: rep.id, + }]); + return { crm_account: account!.id, crm_contact: contact!.id, crm_opportunity: deal!.id }; + }; + const stored = async (object: string, id: string): Promise => (await verify.rows(object, { id }))[0]!; + const as = (p: Person) => ({ as: p.token }); + + describe('Expiration Date (B-11)', () => { + /** The rep's quote, as the form posts it: no expiration date. */ + const repQuote = async (doc: AnyRec = {}): Promise => { + const p = await parties(); + const written = await verify.hooks.run('crm_quote', 'insert', { name: `Q-${++k}`, ...p, ...doc }, as(rep)); + return stored('crm_quote', written.id); + }; + + it.each([ + // Across a DST change in most zones that keep one: the default is + // calendar arithmetic, and a date is never a day short. + ['2026-03-01', '2026-03-31'], + ['2026-10-15', '2026-11-14'], + ['2026-12-15', '2027-01-14'], + ])('a quote dated %s that names no expiration expires on %s', async (quote_date, expiration_date) => { + expect((await repQuote({ quote_date })).expiration_date).toBe(expiration_date); + }); + + it('reaches a door that runs no hook: the dry-run preview admits a quote with no expiration date', async () => { + // `verify.validate` is `ObjectQL.validate` — the import preview's door: + // the same field defaults and rules as the write, and no hooks. Before + // this change it answered "Expiration Date is required". + const verdict = await verify.validate('crm_quote', { name: `Q-${++k}`, ...(await parties()) }, as(rep)); + expect(verdict.results[0]!.errors).toEqual([]); + expect(verdict.valid).toBe(true); + }); + + it('an explicit date is kept', async () => { + expect((await repQuote({ quote_date: '2026-01-01', expiration_date: '2026-06-30' })).expiration_date).toBe('2026-06-30'); + }); + + it('an update still may not clear it', async () => { + const quote = await repQuote(); + const refusal = await refusalOf(verify.hooks.run('crm_quote', 'update', { id: quote.id, expiration_date: null }, as(rep))); + expect(refusal.code).toBe('VALIDATION_FAILED'); + // Measured: the engine's in-process ValidationError carries no `status` + // (the REST door answers 400); pinned as measured, as + // `forecast-period-boundary.test.ts` does. + expect(refusal.status).toBeUndefined(); + expect(refusal.fields).toEqual(expect.arrayContaining([expect.objectContaining({ field: 'expiration_date' })])); + expect((await stored('crm_quote', quote.id)).expiration_date).toBe(quote.expiration_date); + }); + }); + + describe('Period End and Snapshot Date (C-08)', () => { + /** The manager's forecast, as the form posts it: a period and its start. */ + const managerForecast = async (doc: AnyRec): Promise => + stored('crm_forecast', (await verify.hooks.run('crm_forecast', 'insert', doc, as(manager))).id); + + it('a forecast with only a period and its start lands with its end and today as its snapshot date', async () => { + const f = await managerForecast({ period: 'quarter', period_start: '2026-10-01', quota: 300_000 }); + expect(f).toMatchObject({ period_end: '2026-12-31', period_label: 'Q4 2026', snapshot_date: today() }); + }); + + it('the snapshot date reaches the dry-run preview, which runs no hook', async () => { + // The preview still has no `period_end`: that one is the hook's to derive, + // and the hook does not run here. What the preview no longer reports is + // either of them as REQUIRED. + const verdict = await verify.validate('crm_forecast', { period: 'month', period_start: '2026-11-01' }, as(manager)); + expect(verdict.results[0]!.errors).toEqual([]); + }); + + it('an update still may not clear the snapshot date', async () => { + const f = await managerForecast({ period: 'month', period_start: '2026-09-01' }); + const refusal = await refusalOf(verify.hooks.run('crm_forecast', 'update', { id: f.id, snapshot_date: null }, as(manager))); + expect(refusal.code).toBe('VALIDATION_FAILED'); + expect(refusal.status).toBeUndefined(); + expect(refusal.fields).toEqual(expect.arrayContaining([expect.objectContaining({ field: 'snapshot_date' })])); + }); + + it('clearing the period end re-derives it rather than storing a blank', async () => { + const f = await managerForecast({ period: 'month', period_start: '2026-08-01' }); + await verify.hooks.run('crm_forecast', 'update', { id: f.id, period_end: null }, as(manager)); + expect((await stored('crm_forecast', f.id)).period_end).toBe('2026-08-31'); + }); + }); + + 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 }]); + 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, + }, as(rep)); + return { line: await stored('crm_opportunity_line_item', written.id), listPrice: 420 }; + }; + + it('a line with no price lands at the product list price', async () => { + const { line, listPrice } = await repLine(); + expect(line).toMatchObject({ unit_price: listPrice, list_price: listPrice }); + }); + + it('a negotiated price is kept', async () => { + expect((await repLine({ unit_price: 399 })).line.unit_price).toBe(399); + }); + + 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); + }); + }); +}); diff --git a/test/opportunity-line-item-add-picker.test.ts b/test/opportunity-line-item-add-picker.test.ts index 890d71c99..401e3bf51 100644 --- a/test/opportunity-line-item-add-picker.test.ts +++ b/test/opportunity-line-item-add-picker.test.ts @@ -37,7 +37,9 @@ const pages: AnyRec[] = (stack as AnyRec).pages ?? []; * { [relationshipField]: , [linkField]: } * * On this object that leaves `quantity` and `unit_price` unset, and both are - * `required` + `storage.notNull`. A picker that produced a row the engine + * `storage.notNull` (`quantity` is also `required`; `unit_price` stopped being + * `required` in #2060 item 7, because the hook below is its producer). A + * picker that produced a row the engine * refuses — or worse, a row with no quantity and no price, which prices the * deal at nothing — would mean "A needs more than a picker". It does not, and * the reason is machinery that predates this list: @@ -53,9 +55,9 @@ const pages: AnyRec[] = (stack as AnyRec).pages ?? []; * write, which is why the end-to-end block below measures the real insert on a * real engine rather than asserting the page's shape and calling it done. The * ABLATION is the other half: unbind the price-fill hook and the same insert is - * rejected as "Sales Price is required". Without it, a future change that - * silently made `unit_price` optional would leave this suite green while the - * picker started writing priceless lines into the billing hand-off. + * refused by the `unit_price` NOT NULL column. Without it, a future change that + * silently dropped that column constraint would leave this suite green while + * the picker started writing priceless lines into the billing hand-off. */ const byName = (n: string) => objects.find((o) => o.name === n) as AnyRec; @@ -223,10 +225,16 @@ describe('the row a picker-shaped insert actually produces', () => { }); it('ABLATION: without the price-fill hook the same insert is refused', async () => { - // The negative control. `unit_price` is `required` + NOT NULL and the picker + // The negative control. `unit_price` is a NOT NULL column and the picker // authors no price, so the ONLY reason the assertions above can pass is the // `beforeInsert` hook. Unbind it and the insert must fail — otherwise those // assertions would keep passing on a day the hook stopped running. + // + // The refusal is the COLUMN's, named by table and column: since #2060 + // item 7 the field is not `required`, so no engine check runs first. In + // process it surfaces as the driver's error, which carries no `code`; the + // REST door translates the same failure to 400 `VALIDATION_FAILED` + // (measured in the PR), which is the platform's envelope, not this app's. const { api, product, opportunity } = await boot(false); await expect( @@ -234,6 +242,7 @@ describe('the row a picker-shaped insert actually produces', () => { crm_opportunity: opportunity.id, crm_product: product.id, }), - ).rejects.toThrow(/Sales Price is required/); + ).rejects.toThrow(/NOT NULL constraint failed: crm_opportunity_line_item\.unit_price/); + expect(await api.object('crm_opportunity_line_item').find({ where: { crm_opportunity: opportunity.id } })).toHaveLength(0); }); });