Skip to content
Merged
38 changes: 38 additions & 0 deletions .changeset/2000-shared-picklists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'hotcrm': minor
---

Shared option lists are now platform picklists: Industry, Lead Source, Salutation, Payment Terms and Related To Type are each declared once

Five option lists that several fields offer are now declared once, as platform
`picklist` metadata, and every field that offers one references it by name
instead of carrying its own copy:

| Picklist | Fields that offer it |
|:--|:--|
| `industry` | Lead and Account **Industry** |
| `lead_source` | Lead, Contact and Opportunity **Lead Source** |
| `salutation` | Lead and Contact **Salutation** |
| `payment_terms` | Quote and Contract **Payment Terms** |
| `related_to_type` | Task and Event **Related To Type** |

Nothing a user picks or stores changes: every value, label, color and default is
the same, and so is the order. What changes is that each list can no longer
drift between the objects that share it, because there is only one. A value
converted from a lead onto an account or an opportunity is always a value the
target accepts, and a value outside the list is refused on every object that
offers it, with a message that names the list.

**FROM → TO for integrations that read metadata.** A picklist-bound field is
served with its resolved `options` as before, and now also carries
`picklist: 'NAME'`. The option labels of these five lists are translated once
per locale, under `picklists.NAME` in the language packs, instead of under
each field. Chart and report categories over these fields keep their
translated labels: the pinned platform (18.0.0-next.1) resolves a
picklist-bound dimension through the same `picklists.NAME` translations.

Lists that only one field offers (Opportunity **Stage**, Task and Event **Type**,
Event **Status**, Attendee **Response**, Lead **Need Type** and Lead
**Duplicate Of**) moved back onto that field as its own options. The
**Update Stage** action and the **Schedule Follow-up** screen now read
their option lists from the field they mirror.
2 changes: 1 addition & 1 deletion content/docs/administration/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Confirm the 7-stage default pipeline matches your sales process. Stages and prob
| Closed Won | 100% |
| Closed Lost | 0% |

If your process differs, that is a source edit rather than a screen: **there is no Setup → Opportunity → Stages page**. The seven stages are the `stage` field's own options (`OPPORTUNITY_STAGE_OPTIONS` in `src/sales/objects/_picklists.ts`), and the probabilities are the `STAGE_PROBABILITY` map in `src/sales/objects/opportunity.hook.ts` — which re-derives **Probability** and **Expected Revenue** from the stage on every save, so a probability typed onto a deal by hand does not survive the next one. Changing either is a code change plus a redeploy.
If your process differs, that is a source edit rather than a screen: **there is no Setup → Opportunity → Stages page**. The seven stages are the `stage` field's own options (in `src/sales/objects/opportunity.object.ts`), and the probabilities are the `STAGE_PROBABILITY` map in `src/sales/objects/opportunity.hook.ts` — which re-derives **Probability** and **Expected Revenue** from the stage on every save, so a probability typed onto a deal by hand does not survive the next one. Changing either is a code change plus a redeploy.

### 11. Case SLAs

Expand Down
2 changes: 1 addition & 1 deletion content/docs/administration/setup.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ description: 让 HotCRM 为你的团队做好生产准备的第一周任务。
| Closed Won | 100% |
| Closed Lost | 0% |

如果你的流程不同,那是一次源码修改,不是在某个屏上调整:**并不存在「系统设置 → 商机 → 阶段」这个页面**。七个阶段是 `stage` 字段自己的选项(`src/sales/objects/_picklists.ts` 里的 `OPPORTUNITY_STAGE_OPTIONS`),概率则是 `src/sales/objects/opportunity.hook.ts` 里的 `STAGE_PROBABILITY` 映射 —— 它在每次保存时都按阶段重新推导 **Probability** 与 **Expected Revenue**,所以手工填在商机上的概率撑不过下一次保存。改动其中任何一项都是代码改动加一次重新部署。
如果你的流程不同,那是一次源码修改,不是在某个屏上调整:**并不存在「系统设置 → 商机 → 阶段」这个页面**。七个阶段是 `stage` 字段自己的选项(在 `src/sales/objects/opportunity.object.ts` 里),概率则是 `src/sales/objects/opportunity.hook.ts` 里的 `STAGE_PROBABILITY` 映射 —— 它在每次保存时都按阶段重新推导 **Probability** 与 **Expected Revenue**,所以手工填在商机上的概率撑不过下一次保存。改动其中任何一项都是代码改动加一次重新部署。

### 11. 工单 SLA

Expand Down
2 changes: 1 addition & 1 deletion content/docs/administration/setup.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ description: 讓 HotCRM 為你的團隊做好生產準備的第一週任務。
| Closed Won | 100% |
| Closed Lost | 0% |

如果你的流程不同,那是一次原始碼修改,不是在某個畫面上調整:**並不存在「Setup → Opportunity → Stages」這個頁面**。七個階段是 `stage` 欄位自己的選項(`src/sales/objects/_picklists.ts` 裡的 `OPPORTUNITY_STAGE_OPTIONS`),機率則是 `src/sales/objects/opportunity.hook.ts` 裡的 `STAGE_PROBABILITY` 對應表 —— 它在每次儲存時都按階段重新推導 **Probability** 與 **Expected Revenue**,所以手工填在商機上的機率撐不過下一次儲存。改動其中任何一項都是程式碼改動加一次重新部署。
如果你的流程不同,那是一次原始碼修改,不是在某個畫面上調整:**並不存在「Setup → Opportunity → Stages」這個頁面**。七個階段是 `stage` 欄位自己的選項(在 `src/sales/objects/opportunity.object.ts` 裡),機率則是 `src/sales/objects/opportunity.hook.ts` 裡的 `STAGE_PROBABILITY` 對應表 —— 它在每次儲存時都按階段重新推導 **Probability** 與 **Expected Revenue**,所以手工填在商機上的機率撐不過下一次儲存。改動其中任何一項都是程式碼改動加一次重新部署。

### 11. 工單 SLA

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,6 @@ When you open an opportunity, ask:

## Tips for admins

- The stage list, the default probabilities and the legal transitions live in three places in the source, not on a Setup screen: the shared `OPPORTUNITY_STAGE_OPTIONS` picklist (`src/sales/objects/_picklists.ts`), which the `stage` field renders; the `STAGE_PROBABILITY` map inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), which re-syncs *Probability* on every save; and the `opportunity_stage_progression` state-machine validation rule on the opportunity object (`src/sales/objects/opportunity.object.ts`). Change the three in lockstep. See [Customization › Extending Objects](/docs/customization/extending-objects).
- The stage list, the default probabilities and the legal transitions live in three places in the source, not on a Setup screen: the `stage` field's own options (`src/sales/objects/opportunity.object.ts`); the `STAGE_PROBABILITY` map inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), which re-syncs *Probability* on every save; and the `opportunity_stage_progression` state-machine validation rule on the opportunity object (`src/sales/objects/opportunity.object.ts`). Change the three in lockstep. See [Customization › Extending Objects](/docs/customization/extending-objects).
- The $500K discount approval threshold can be changed in the [approval process configuration](/docs/revenue/approvals).
- The $100K large-deal notification threshold is not a Setup field either: it is the CEL start condition of the `opportunity_won_alert` flow — `record.amount >= 100000` in `src/sales/flows/opportunity-won-alert.flow.ts`. Who gets the alert is that flow's `notify` node, whose `recipients` list is the single entry `{record.owner_id}` — the deal owner alone.
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,6 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶

## 给管理员的提示

- 阶段列表、默认概率和合法的状态转换分散在源码的三处,而不是某个 Setup 界面:共享选择列表 `OPPORTUNITY_STAGE_OPTIONS`(`src/sales/objects/_picklists.ts`),由 `stage` 字段渲染;`opportunity_lifecycle` 钩子里的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),每次保存都据此重新同步*成交概率*;以及商机对象上名为 `opportunity_stage_progression` 的状态机校验规则(`src/sales/objects/opportunity.object.ts`)。三处需同步修改。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。
- 阶段列表、默认概率和合法的状态转换分散在源码的三处,而不是某个 Setup 界面:`stage` 字段自己的选项(`src/sales/objects/opportunity.object.ts`);`opportunity_lifecycle` 钩子里的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),每次保存都据此重新同步*成交概率*;以及商机对象上名为 `opportunity_stage_progression` 的状态机校验规则(`src/sales/objects/opportunity.object.ts`)。三处需同步修改。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。
- $500K 折扣审批阈值可以在 [审批流程配置](/zh-Hans/docs/revenue/approvals) 中更改。
- $100K 大单通知阈值同样不是 Setup 界面上的字段:它是 `opportunity_won_alert` 流程的 CEL 起始条件——`src/sales/flows/opportunity-won-alert.flow.ts` 里的 `record.amount >= 100000`。收件人则由该流程 `notify` 节点的 `recipients` 决定,列表里只有 `{record.owner_id}` 一项,即商机负责人本人。
2 changes: 1 addition & 1 deletion content/docs/sales/opportunities.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,6 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階

## 給管理員的提示

- 階段列表、預設機率和合法的狀態轉換分散在原始碼的三處,而不是某個 Setup 介面:共享選擇列表 `OPPORTUNITY_STAGE_OPTIONS`(`src/sales/objects/_picklists.ts`),由 `stage` 欄位算繪;`opportunity_lifecycle` 鉤子裡的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),每次儲存都據此重新同步*成交機率*;以及商機物件上名為 `opportunity_stage_progression` 的狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。三處需同步修改。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。
- 階段列表、預設機率和合法的狀態轉換分散在原始碼的三處,而不是某個 Setup 介面:`stage` 欄位自己的選項(`src/sales/objects/opportunity.object.ts`);`opportunity_lifecycle` 鉤子裡的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),每次儲存都據此重新同步*成交機率*;以及商機物件上名為 `opportunity_stage_progression` 的狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。三處需同步修改。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。
- $500K 折扣審批閾值可以在 [審批流程設定](/zh-Hant/docs/revenue/approvals) 中更改。
- $100K 大單通知閾值同樣不是 Setup 介面上的欄位:它是 `opportunity_won_alert` 流程的 CEL 起始條件——`src/sales/flows/opportunity-won-alert.flow.ts` 裡的 `record.amount >= 100000`。收件人則由該流程 `notify` 節點的 `recipients` 決定,列表裡只有 `{record.owner_id}` 一項,即商機負責人本人。
2 changes: 1 addition & 1 deletion content/docs/sales/pipeline-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ What that dashboard will not give you is a *top deals* ranking, and no renaming

## Tips for admins

- Add or remove stages? You need to update three places in the source, none of them a Setup screen: the `OPPORTUNITY_STAGE_OPTIONS` picklist the `stage` field renders (`src/sales/objects/_picklists.ts`), the `STAGE_PROBABILITY` map that sets probabilities inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), and the `opportunity_stage_progression` state-machine validation rule that controls transitions (`src/sales/objects/opportunity.object.ts`). See [Customization › Extending Objects](/docs/customization/extending-objects).
- Add or remove stages? You need to update three places in the source, none of them a Setup screen: the `stage` field's own options (`src/sales/objects/opportunity.object.ts`), the `STAGE_PROBABILITY` map that sets probabilities inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), and the `opportunity_stage_progression` state-machine validation rule that controls transitions (`src/sales/objects/opportunity.object.ts`). See [Customization › Extending Objects](/docs/customization/extending-objects).
- The $100K *large deal* notification threshold is not a Setup field either: it is the CEL start condition of the `opportunity_won_alert` flow — `record.amount >= 100000` in `src/sales/flows/opportunity-won-alert.flow.ts`. Who gets the alert is that flow's `notify` node, whose `recipients` list is the single entry `{record.owner_id}` — the deal owner alone.
2 changes: 1 addition & 1 deletion content/docs/sales/pipeline-management.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ description: 销售管道的日常运作方式——推进交易、自动概率

## 给管理员的提示

- 添加或移除阶段?你需要更新源码里的三处,没有一处是 Setup 界面:`stage` 字段所渲染的 `OPPORTUNITY_STAGE_OPTIONS` 选择列表(`src/sales/objects/_picklists.ts`)、`opportunity_lifecycle` 钩子里设定概率的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),以及控制状态转换的 `opportunity_stage_progression` 状态机校验规则(`src/sales/objects/opportunity.object.ts`)。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。
- 添加或移除阶段?你需要更新源码里的三处,没有一处是 Setup 界面:`stage` 字段自己的选项(`src/sales/objects/opportunity.object.ts`)、`opportunity_lifecycle` 钩子里设定概率的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),以及控制状态转换的 `opportunity_stage_progression` 状态机校验规则(`src/sales/objects/opportunity.object.ts`)。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。
- $100K *大单*通知阈值同样不是 Setup 界面上的字段:它是 `opportunity_won_alert` 流程的 CEL 起始条件——`src/sales/flows/opportunity-won-alert.flow.ts` 里的 `record.amount >= 100000`。收件人则由该流程 `notify` 节点的 `recipients` 决定,列表里只有 `{record.owner_id}` 一项,即商机负责人本人。
2 changes: 1 addition & 1 deletion content/docs/sales/pipeline-management.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,5 @@ description: 銷售管道的日常運作方式——推進交易、自動機率

## 給管理員的提示

- 新增或移除階段?你需要更新原始碼裡的三處,沒有一處是 Setup 介面:`stage` 欄位所算繪的 `OPPORTUNITY_STAGE_OPTIONS` 選擇列表(`src/sales/objects/_picklists.ts`)、`opportunity_lifecycle` 鉤子裡設定機率的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),以及控制狀態轉換的 `opportunity_stage_progression` 狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。
- 新增或移除階段?你需要更新原始碼裡的三處,沒有一處是 Setup 介面:`stage` 欄位自己的選項(`src/sales/objects/opportunity.object.ts`)、`opportunity_lifecycle` 鉤子裡設定機率的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),以及控制狀態轉換的 `opportunity_stage_progression` 狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。
- $100K *大單*通知閾值同樣不是 Setup 介面上的欄位:它是 `opportunity_won_alert` 流程的 CEL 起始條件——`src/sales/flows/opportunity-won-alert.flow.ts` 裡的 `record.amount >= 100000`。收件人則由該流程 `notify` 節點的 `recipients` 決定,列表裡只有 `{record.owner_id}` 一項,即商機負責人本人。
18 changes: 18 additions & 0 deletions objectstack.composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ import * as salesObjects from './src/sales/objects/index.js';
import * as revenueObjects from './src/revenue/objects/index.js';
import * as marketingObjects from './src/marketing/objects/index.js';

import * as salesPicklists from './src/sales/picklists/index.js';
import * as revenuePicklists from './src/revenue/picklists/index.js';

import * as salesActions from './src/sales/actions/index.js';
import * as marketingActions from './src/marketing/actions/index.js';

Expand Down Expand Up @@ -258,6 +261,16 @@ export const appObjects = byExportName({
...salesObjects, ...revenueObjects, ...marketingObjects,
});

/**
* Every shared option list (`*.picklist.ts`) it registers — the lists a select
* field references by name with `Field.select({ picklist })` instead of copying
* options. Every field that references one is an app-package field (sales and
* revenue objects); the service package references none. A field naming a list
* registered here by nothing is refused by `pnpm validate` and fails the boot,
* so this line is load-bearing.
*/
export const appPicklists = byExportName({ ...salesPicklists, ...revenuePicklists });

/** Every UI / AI-callable action it registers. */
export const appActions = byExportName({ ...salesActions, ...marketingActions });

Expand Down Expand Up @@ -544,6 +557,11 @@ export const serviceStack = createHotCrmServiceStack({ data: seedData.service })
export const appStack = createHotCrmAppStack(
{
objects: appObjects,
// Shared option lists (#2000, objectstack#18164). Each select field that
// offers one names it — `Field.select({ picklist: 'industry' })` — and the
// platform serves that field with the list's resolved options, refuses a
// write outside them, and relabels them from `picklists.NAME` per locale.
picklists: appPicklists,
actions: appActions,
dashboards: appDashboards,
datasets: appDatasets,
Expand Down
11 changes: 5 additions & 6 deletions src/revenue/objects/contract.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { ObjectSchema, Field } from '@objectstack/spec/data';
import { P } from '@objectstack/spec';
import { PAYMENT_TERMS_OPTIONS } from '../../sales/objects/_picklists';

/**
* Contract Object
Expand Down Expand Up @@ -146,12 +145,12 @@ export const Contract = ObjectSchema.create({
payment_terms: Field.select({
label: 'Payment Terms',
group: 'value',
// Canonical set shared with Quote: an accepted quote's terms (incl.
// The list shared with Quote: an accepted quote's terms (incl.
// due_on_receipt) must survive the copy onto the contract. That copy is
// `quote_accepted_contract_draft` (src/revenue/objects/quote.hook.ts). This field's `net_30`
// option default applies only when the quote itself carried no term,
// which is the intended fall-through, not a gap.
options: [...PAYMENT_TERMS_OPTIONS],
// `quote_accepted_contract_draft` (src/revenue/objects/quote.hook.ts). The
// list's `net_30` option default applies only when the quote itself
// carried no term, which is the intended fall-through, not a gap.
picklist: 'payment_terms',
}),

// Renewal
Expand Down
6 changes: 3 additions & 3 deletions src/revenue/objects/quote.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { ObjectSchema, Field } from '@objectstack/spec/data';
import { F, P, cel } from '@objectstack/spec';
import { PAYMENT_TERMS_OPTIONS } from '../../sales/objects/_picklists';
import { QUOTE_DISCOUNT_CEILING } from '../../sales/objects/_thresholds';

/**
Expand Down Expand Up @@ -228,8 +227,9 @@ export const Quote = ObjectSchema.create({
payment_terms: Field.select({
label: 'Payment Terms',
group: 'terms',
// Canonical set shared with Contract (#490) — see _picklists.ts.
options: [...PAYMENT_TERMS_OPTIONS],
// The list shared with Contract (#490) —
// `src/revenue/picklists/payment_terms.picklist.ts`.
picklist: 'payment_terms',
}),

shipping_terms: Field.text({
Expand Down
Loading
Loading