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
13 changes: 13 additions & 0 deletions .changeset/2060-item-7-required-vs-defaults.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/administration/automation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/administration/automation.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 计划说得出「每晚」,却说不出「本季度的第一天」—— 所以这个周期边界由对象统一推导一次,供每个写入方共用。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/administration/automation.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 排程說得出「每晚」,卻說不出「本季的第一天」—— 所以這個週期邊界由物件統一推導一次,供每個寫入方共用。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/forecasting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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
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 @@ -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
Expand Down
1 change: 1 addition & 0 deletions content/docs/sales/quotes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions content/docs/sales/quotes.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling

## 自动发生的事

- **到期日期留空,就是报价日期后 30 天**——新建报价时不填**到期日期**,报价就在**报价日期**(不填也就是当天)30 天后到期。你自己填的日期会原样保留,但它仍须晚于报价日期。
- **每日过期扫描**——在运行计划类工作的部署上,每晚凌晨 1 点,系统会将任何到期日期已过的报价标记为**已过期**(除非它已被接受或拒绝),让你的活跃列表保持干净整洁。计划类工作要由部署方开启才会运行(见[系统管理 › 自动化](/zh-Hans/docs/administration/automation#scheduled-automation))。没有开启时,没有任何东西会让报价过期:过了到期日期的报价会一直保持原状态,直到有人去改它。
- **接受时**——把一份报价标记为*已接受*,就是起草合同的那个动作。系统会插入一份*草稿*状态的合同,合同类型为*订阅合同*,期限 12 个月、从今天起算,带着报价上的所属客户、主要联系人、关联商机、负责人和总金额。起草合同是系统自己的写入,所以不论是谁记录了接受都会起草——销售代表也一样,尽管销售代表不能亲手新建合同。随后它把关联的商机推进到*成交*,预计成交日期取今天;并且仅当销售代表没有填赢单原因时,才补上**报价被接受**。已经关闭的商机——不论成交还是失败——原样不动。

Expand Down
1 change: 1 addition & 0 deletions content/docs/sales/quotes.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling

## 自動發生的事

- **到期日期留空,就是報價日期後 30 天**——建立報價時不填**到期日期**,報價就在**報價日期**(不填也就是當天)30 天後到期。你自己填的日期會原樣保留,但它仍須晚於報價日期。
- **每日到期掃描**——在執行排程類工作的部署上,每晚凌晨 1 點,系統會將任何到期日期已過的報價標記為**已過期**(除非它已被接受或拒絕),讓你的活躍列表保持乾淨整潔。排程類工作要由部署方開啟才會執行(見[系統管理 › 自動化](/zh-Hant/docs/administration/automation#scheduled-automation))。沒有開啟時,沒有任何東西會讓報價過期:過了到期日期的報價會一直保持原狀態,直到有人去改它。
- **接受時**——把一份報價標記為*已接受*,就是起草合約的那個動作。系統會插入一份*草稿*狀態的合約,合約類型為*訂閱合約*,期限 12 個月、從今天起算,帶著報價上的所屬客戶、主要聯絡人、關聯商機、負責人和總金額。起草合約是系統自己的寫入,所以不論是誰記錄了接受都會起草——銷售代表也一樣,儘管銷售代表不能親手新建合約。隨後它把關聯的商機推進到*成交*,預計成交日期取今天;並且僅當銷售代表沒有填贏單原因時,才補上**報價被接受**。已經關閉的商機——不論成交還是失敗——原樣不動。

Expand Down
15 changes: 14 additions & 1 deletion src/revenue/objects/opportunity_line_item.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading