diff --git a/.changeset/2371-ux-feedback-round.md b/.changeset/2371-ux-feedback-round.md new file mode 100644 index 000000000..a678abe56 --- /dev/null +++ b/.changeset/2371-ux-feedback-round.md @@ -0,0 +1,16 @@ +--- +"hotcrm": minor +--- + +**Ten UX gaps from the feedback round, each answered on screen (#2371).** + +- **Enrolling people in a campaign says what happened.** The row action on a lead or contact reads "Q3 Enterprise Email Nurture: 1 added, 0 already a member." The campaign's **Enroll** flow ends on a result screen that counts the people added, the ones already enrolled and the ones skipped because they opted out of email or have no address, with a link back to the campaign. +- **A deal waiting for sign-off says so.** While a large deal waits for approval, its page shows a blue banner naming who must approve it (a Sales Manager, then also a Sales Director above $500,000), that the deal is locked until they decide, and that **Recall** pulls the request back. +- **An early win saves cleanly.** A deal moved from Prospecting, Qualification or Needs Analysis straight to **Closed Won** no longer saves with a warning. +- **Cloning a deal names the copy.** The toast reads `Created "Copy of …". You are now on the copy.` +- **One approval notice, not two.** A rep who raised their own deal for approval gets the platform's approved/rejected notice plus the email; the in-app notice from the approval flow now goes only when someone else raised the deal. +- **A generated quote and an auto-drafted contract are announced.** **Generate Quote** ends on a result screen linking the new draft quote. Accepting a quote that drafts a contract sends whoever accepted it an in-app notice linking the contract (new flow **Contract Drafted Notice**, 34 flows). +- **Refusals name the way forward.** A quote moved off its usual path is warned with that path spelled out by status label, not "Invalid quote status transition". A quarterly forecast that starts mid-quarter gets one refusal naming the four quarter starts, not two joined refusals. +- **The manual explains two quiet behaviours.** Only a deal's owner sees a deal marked **Private**, and deleting a lead or contact also deletes its campaign memberships. + +The README's sales-package token banner is restated to the measured ~68k / ~31k. diff --git a/README.md b/README.md index 54ca1802e..69df4277f 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ **HotCRM is the reference implementation of that statement for a CRM.** -- **Its business ontology is the app.** 20 business objects and the relations between them, **38 actions**, 33 flows, 6 permission sets, 12 positions and 11 sharing rules, and 6 AI skills — typed [ObjectStack](https://github.com/objectstack-ai/objectstack) metadata under `src/`, checked by `pnpm validate`. +- **Its business ontology is the app.** 20 business objects and the relations between them, **38 actions**, 34 flows, 6 permission sets, 12 positions and 11 sharing rules, and 6 AI skills — typed [ObjectStack](https://github.com/objectstack-ai/objectstack) metadata under `src/`, checked by `pnpm validate`. - **The database, the API, the console UI and the agent tools derive from it.** One `pnpm dev` boot creates the tables, serves the REST API at `/api/v1/data/crm_account` and its siblings, the Console at `/_console/`, and an MCP server at `/api/v1/mcp`. Nothing under `src/` defines a route handler or a React component. - **Views, dashboards and translations are projections of it.** List views, record pages, 5 dashboards and the app's navigation, in 4 languages (en, zh-CN, es-ES, ja-JP): the surfaces people use the ontology through, not a second copy of it. > [!NOTE] -> **Small enough for an agent to hold whole.** The sales package carries its whole business semantics (objects, flows, actions, hooks) in **~64k tokens**, and its whole interaction layer (views, pages, dashboards, app shell) in another **~29k**. +> **Small enough for an agent to hold whole.** The sales package carries its whole business semantics (objects, flows, actions, hooks) in **~68k tokens**, and its whole interaction layer (views, pages, dashboards, app shell) in another **~31k**. > Measured, not estimated: `node scripts/check-source-token-ratchet.mjs` prints both figures, and every other package's, and CI fails when a package's business semantics, interaction layer or authored total grows past its committed ceiling. Comments and blank lines are stripped; translations and seed data are outside the count — a fifth locale is not business logic. [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) @@ -89,7 +89,7 @@ HotCRM defines no agent of its own; its 6 AI skills are metadata for a platform ## ✨ What you get -**20 business objects** across four business domains — sales, service, revenue and marketing; the repository layout below says what each package covers, and the roster itself is `src/*/objects/*.object.ts`. Around them: **38 actions**, 33 flows, **5 dashboards**, 10 reports, 9 datasets, 6 AI skills, 4 language bundles (en, zh-CN, es-ES, ja-JP), 6 permission sets, 12 positions and 11 sharing rules. `pnpm build` prints most of these counts on every build. +**20 business objects** across four business domains — sales, service, revenue and marketing; the repository layout below says what each package covers, and the roster itself is `src/*/objects/*.object.ts`. Around them: **38 actions**, 34 flows, **5 dashboards**, 10 reports, 9 datasets, 6 AI skills, 4 language bundles (en, zh-CN, es-ES, ja-JP), 6 permission sets, 12 positions and 11 sharing rules. `pnpm build` prints most of these counts on every build. > **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index ac54e34cb..2d56ee89e 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -50,7 +50,7 @@ A flow fires one of three ways, set by its start node: > **Auto-launch needs the `triggers` capability.** Record-change and scheduled flows only fire when the stack's `requires` list includes `triggers` — it installs the record-change + schedule trigger providers (schedule triggers also use the job service). Screen flows are always launched manually. -**Built-in flows in HotCRM** (33). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: +**Built-in flows in HotCRM** (34). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: | Flow | Trigger | What it does | | --- | --- | --- | @@ -75,6 +75,7 @@ A flow fires one of three ways, set by its start node: | **Lead Conversion Approval** | Record change (insert) | Sign-off a lead needs before it can be converted. Ships switched OFF — it opens nothing until an install arms the gate on the lead's **Conversion Approval** field | | **Account Approval** | Record change (insert) | Sign-off a newly created account needs before it counts as established data. Ships switched **ON** — a new account starts *Pending* and is decided in the approval inbox, except one **Convert Lead** creates, which is not submitted (on a deployment without approvals it stays blank and editable instead); an install that wants no account sign-off sets the default on the account's **Approval Status** field to *Approved* | | **Large Deal Won Alert** | Record change (update) | When an opportunity of $100K or more turns *Closed Won*, notify the owner — the owner alone, not their manager | +| **Contract Drafted Notice** | Record change (insert) | When accepting a quote drafts its contract, tell the person who accepted it in the app, with a link to the contract | | **Billing Hand-off: Closed Won** | Record change (update) | On the transition into *Closed Won*, enqueue one durable POST of the deal + account + line items to the billing endpoint | | **Billing Hand-off: Contract Activated** | Record change (update) | On the transition into *Activated*, enqueue one durable POST of the contract + account + the originating deal's line items | | **Case Escalation Process** | Record change (update) | When a case turns *Critical*, flag it escalated, hand it to the least-loaded holder of the `service_manager` position (it stays with its owner while that pool is unstaffed) and notify the agent it came from; escalating also opens an urgent follow-up task for the account owner | diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index 59817ac18..8fa966999 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -50,7 +50,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。 -**HotCRM 中的内置流程**(33 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(34 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | @@ -75,6 +75,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **线索转化审批** | 记录变更(插入) | 线索转化前需要的签核。出厂默认是**关闭**的:在线索的**转化审批**字段上打开闸门之前,它不会发起任何审批 | | **客户审批** | 记录变更(插入) | 新建客户在成为正式数据之前需要的签核。出厂默认是**打开**的:新客户以**待审批**状态创建,并在审批收件箱中裁定,**转化线索**创建的客户除外,它不送审(部署不含审批功能时,它保持为空且可编辑);不需要客户签核的安装,把客户**审批状态**字段的默认值设为*已通过*即可 | | **大额商机赢单提醒** | 记录变更(更新) | 金额 $100K 及以上的商机转为 *Closed Won* 时,通知负责人 —— 只通知负责人本人,不通知其经理 | +| **合同起草通知** | 记录变更(插入) | 接受报价单自动起草合同时,在应用内通知接受报价的人,并附上合同链接 | | **计费交接:赢单** | 记录变更(更新) | 商机转入 *Closed Won* 的那一刻,向计费端点投递一次带重试的 POST:商机 + 客户 + 行项目 | | **计费交接:合同激活** | 记录变更(更新) | 合同转入 *Activated* 的那一刻,向计费端点投递一次带重试的 POST:合同 + 客户 + 来源商机的行项目 | | **工单升级流程** | 记录变更(更新) | 当工单变为 *Critical* 时,标记为已升级、移交给 `service_manager` 岗位中负载最轻的持有者(该岗位无人时工单保留原负责人),并通知它原来那位负责人;升级同时会给客户负责人创建一条紧急跟进任务 | diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index 5a67289a9..a59c6f256 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -52,7 +52,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。 -**HotCRM 中的內建流程**(33 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: +**HotCRM 中的內建流程**(34 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | @@ -77,6 +77,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **線索轉化審批** | 記錄變更(插入) | 線索轉化前需要的簽核。出廠預設是**關閉**的:在線索的**轉化審批**欄位上打開閘門之前,它不會發起任何審批 | | **客戶審批** | 記錄變更(插入) | 新建客戶在成為正式資料之前需要的簽核。出廠預設是**打開**的:新客戶以**待審批**狀態建立,並在審批收件匣中裁定,**轉化線索**建立的客戶除外,它不送審(部署不含審批功能時,它保持空白且可編輯);不需要客戶簽核的安裝,把客戶**審批狀態**欄位的預設值設為*已通過*即可 | | **大額商機贏單提醒** | 記錄變更(更新) | 金額 $100K 及以上的商機轉為 *Closed Won* 時,通知負責人 —— 只通知負責人本人,不通知其經理 | +| **合約起草通知** | 記錄變更(插入) | 接受報價單自動起草合約時,在應用內通知接受報價的人,並附上合約連結 | | **計費交接:贏單** | 記錄變更(更新) | 商機轉入 *Closed Won* 的那一刻,向計費端點投遞一次帶重試的 POST:商機 + 客戶 + 行項目 | | **計費交接:合約啟用** | 記錄變更(更新) | 合約轉入 *Activated* 的那一刻,向計費端點投遞一次帶重試的 POST:合約 + 客戶 + 來源商機的行項目 | | **工單升級流程** | 記錄變更(更新) | 當工單變為 *Critical* 時,標記為已升級、移交給 `service_manager` 職位中負載最輕的持有者(該職位無人時工單保留原負責人),並通知它原來那位負責人;升級同時會給客戶負責人建立一條緊急跟進任務 | diff --git a/content/docs/administration/state-machines.mdx b/content/docs/administration/state-machines.mdx index 79caf7c74..0124ead63 100644 --- a/content/docs/administration/state-machines.mdx +++ b/content/docs/administration/state-machines.mdx @@ -19,7 +19,7 @@ HotCRM uses state machines on **five** objects: All five are declared the same way — a named `state_machine` validation rule on the object, over that object's lifecycle field (`status`, or `stage` on opportunities). Cases, contracts and quotes are not a lighter mechanism than leads and opportunities; they are the same one. The top-level `stateMachines` key was removed in platform 7.7 and every machine moved onto its object. -**A declared route is advice, not a gate — except on contracts.** The lead, opportunity, case and quote rules carry **warning** severity, so a move outside the transition table is written to the server log — *"Invalid quote status transition"*, and so on — and **the save still goes through**. The contract rule, `contract_status_progression`, carries **error** severity: a move outside its table is refused and the contract keeps its status. Contracts are the exception because both of their end states are final — a renewal is a new contract, never a revived one — and activation promotes the account and hands the contract to billing, so a contract that skipped approval or came back from *Expired* would count as live everywhere downstream. On every object, a rule judges only a change of the status: an edit that leaves it as it is never meets the rule. Nor is a record checked when it is *created*: the rules compare a new value against the previous one, so they only run on update, and a record imported straight into a late state passes untouched — a contract included. What a warning-level table buys you is a declared, machine-readable route that automation, reports and the Copilot can rely on — not a lock on the field. +**A declared route is advice, not a gate — except on contracts.** The lead, opportunity, case and quote rules carry **warning** severity, so a move outside the transition table is written to the server log — *"Invalid opportunity stage transition"*, and so on — and **the save still goes through**. The contract rule, `contract_status_progression`, carries **error** severity: a move outside its table is refused and the contract keeps its status. Contracts are the exception because both of their end states are final — a renewal is a new contract, never a revived one — and activation promotes the account and hands the contract to billing, so a contract that skipped approval or came back from *Expired* would count as live everywhere downstream. On every object, a rule judges only a change of the status: an edit that leaves it as it is never meets the rule. Nor is a record checked when it is *created*: the rules compare a new value against the previous one, so they only run on update, and a record imported straight into a late state passes untouched — a contract included. What a warning-level table buys you is a declared, machine-readable route that automation, reports and the Copilot can rely on — not a lock on the field. **Campaigns have no state machine, and that is deliberate.** A campaign's status is descriptive (*Planning / In Progress / Completed / Aborted*) rather than a controlled lifecycle, and the same is true of the status fields on campaign members, events, knowledge articles and tasks. A transition table on any of them would warn on ordinary edits and teach users to ignore the warning. @@ -58,11 +58,8 @@ Two of those edges have machinery behind them. Moving to *Unqualified* needs a * ``` Prospecting → Qualification → Needs Analysis → Proposal → Negotiation - │ │ - └─────┬──────┘ - ▼ - Closed Won +Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Won Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Lost ``` @@ -72,15 +69,15 @@ There are **two** closed stages, not three: *Closed Won* and *Closed Lost*. Ther | From | May move to | Notes | | --- | --- | --- | -| Prospecting | Qualification, Proposal, Closed Lost | | -| Qualification | Needs Analysis, Proposal, Closed Lost | | -| Needs Analysis | Proposal, Closed Lost | 40% default probability, *Best Case* forecast category | +| Prospecting | Qualification, Proposal, Closed Won, Closed Lost | | +| Qualification | Needs Analysis, Proposal, Closed Won, Closed Lost | | +| Needs Analysis | Proposal, Closed Won, Closed Lost | 40% default probability, *Best Case* forecast category | | Proposal | Negotiation, Closed Won, Closed Lost | | | Negotiation | Closed Won, Closed Lost | | | Closed Won | — | Terminal | | Closed Lost | — | Terminal | -Two of those edges exist for the CPQ path rather than for a rep clicking through stages: *→ Proposal* is legal from every pre-proposal stage because the `quote_generation` flow fast-forwards the deal to *Proposal* the moment a quote is generated, which can happen at any open stage — though where your organisation requires qualification approval (立项), not before the deal is approved ([Opportunity Qualification](/docs/sales/opportunity-qualification#qualification-approval)); and *Proposal → Closed Won* is legal because the `quote_on_accepted` hook wins the linked deal straight from an accepted quote, with no separate negotiation step. +Two of those edges exist for the CPQ path rather than for a rep clicking through stages: *→ Proposal* is legal from every pre-proposal stage because the `quote_generation` flow fast-forwards the deal to *Proposal* the moment a quote is generated, which can happen at any open stage — though where your organisation requires qualification approval (立项), not before the deal is approved ([Opportunity Qualification](/docs/sales/opportunity-qualification#qualification-approval)); and *Proposal → Closed Won* is legal because the `quote_on_accepted` hook wins the linked deal straight from an accepted quote, with no separate negotiation step. And every open stage may close: *→ Closed Won* is legal from each of them, as *→ Closed Lost* is, so a deal won early saves without the warning. That close is never the way around an approval. While an approval holds the deal — its large-deal approval pending, or, where they are switched on, its status-change or qualification approval pending or rejected — accepting the quote is itself refused, so an accepted quote never sits on a deal it could not close. See [Quotes](/docs/sales/quotes#built-in-rules). @@ -99,7 +96,7 @@ The heading is fair for the first three, unlike the transition tables above them ## What a state machine buys you -Not prevention, on four of the five — the contract's table is the one that refuses a move (see above). On the other four, everything this section used to list as impossible still happens exactly as it did before the table was declared: a rep can mark a lead *Converted* by accident, an opportunity can go from *Prospecting* straight to *Closed Won*, and records can skip stages. Each of those writes one warning to the server log and is then saved. So a funnel report does **not** "reflect reality" by virtue of a table existing — it reports whatever stages the records actually passed through. +Not prevention, on four of the five — the contract's table is the one that refuses a move (see above). On the other four, everything this section used to list as impossible still happens exactly as it did before the table was declared: a rep can mark a lead *Converted* by accident, an opportunity can go from *Prospecting* straight to *Negotiation*, and records can skip stages. Each of those writes one warning to the server log and is then saved. So a funnel report does **not** "reflect reality" by virtue of a table existing — it reports whatever stages the records actually passed through. What the table does buy you is the route itself, **written down in one machine-readable place**: diff --git a/content/docs/administration/state-machines.zh-Hans.mdx b/content/docs/administration/state-machines.zh-Hans.mdx index b364de571..290870331 100644 --- a/content/docs/administration/state-machines.zh-Hans.mdx +++ b/content/docs/administration/state-machines.zh-Hans.mdx @@ -19,7 +19,7 @@ HotCRM 在 **五个** 对象上使用状态机: 五者的声明方式完全相同 —— 都是对象上一条具名的 `state_machine` 验证规则,作用于该对象的生命周期字段(`status`,商机上是 `stage`)。工单、合同、报价用的不是比潜在客户和商机更轻的机制,就是同一套机制。顶层 `stateMachines` 键在平台 7.7 被移除后,所有状态机都迁到了各自的对象上。 -**声明出来的路线是建议,不是闸门——合同除外。** 潜在客户、商机、工单和报价的规则都是 **警告** 级:走出转换表的状态变更只会写进服务端日志——比如 *“Invalid quote status transition”*——而 **保存照样通过**。合同的规则 `contract_status_progression` 是 **错误** 级:走出转换表的变更会被拒绝,合同保持原来的状态。合同之所以例外,是因为它的两个终点都是最终状态——续约是一份新合同,绝不是把旧合同救活——而激活会把客户升级成 *Customer* 并把合同交给计费,跳过审批或从 *Expired* 救活的合同会在下游处处被当成生效中。在每个对象上,规则只判断状态的 **变更**:不改状态的编辑根本碰不到它。记录在 **新建** 时也根本不受检查:规则拿新值与前一个值比对,因此只在更新时运行,直接导入到后期状态的记录不会被碰——合同也一样。警告级的转换表给你的是一条声明出来、机器可读的路线,供自动化、报表和 Copilot 依赖——而不是给字段上锁。 +**声明出来的路线是建议,不是闸门——合同除外。** 潜在客户、商机、工单和报价的规则都是 **警告** 级:走出转换表的状态变更只会写进服务端日志——比如 *“Invalid opportunity stage transition”*——而 **保存照样通过**。合同的规则 `contract_status_progression` 是 **错误** 级:走出转换表的变更会被拒绝,合同保持原来的状态。合同之所以例外,是因为它的两个终点都是最终状态——续约是一份新合同,绝不是把旧合同救活——而激活会把客户升级成 *Customer* 并把合同交给计费,跳过审批或从 *Expired* 救活的合同会在下游处处被当成生效中。在每个对象上,规则只判断状态的 **变更**:不改状态的编辑根本碰不到它。记录在 **新建** 时也根本不受检查:规则拿新值与前一个值比对,因此只在更新时运行,直接导入到后期状态的记录不会被碰——合同也一样。警告级的转换表给你的是一条声明出来、机器可读的路线,供自动化、报表和 Copilot 依赖——而不是给字段上锁。 **营销活动没有状态机,这是有意的。** 营销活动的状态是描述性的(*Planning / In Progress / Completed / Aborted*),不是受控的生命周期;营销活动成员、活动、知识文章和任务的状态字段同理。给它们中任何一个加转换表,只会在正常编辑时报警告,教用户忽略警告。 @@ -58,11 +58,8 @@ HotCRM 在 **五个** 对象上使用状态机: ``` Prospecting → Qualification → Needs Analysis → Proposal → Negotiation - │ │ - └─────┬──────┘ - ▼ - Closed Won +Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Won Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Lost ``` @@ -72,15 +69,15 @@ Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ | 从 | 可以移动到 | 说明 | | --- | --- | --- | -| Prospecting | Qualification、Proposal、Closed Lost | | -| Qualification | Needs Analysis、Proposal、Closed Lost | | -| Needs Analysis | Proposal、Closed Lost | 默认赢率 40%,预测类别为 *Best Case* | +| Prospecting | Qualification、Proposal、Closed Won、Closed Lost | | +| Qualification | Needs Analysis、Proposal、Closed Won、Closed Lost | | +| Needs Analysis | Proposal、Closed Won、Closed Lost | 默认赢率 40%,预测类别为 *Best Case* | | Proposal | Negotiation、Closed Won、Closed Lost | | | Negotiation | Closed Won、Closed Lost | | | Closed Won | — | 终态 | | Closed Lost | — | 终态 | -其中两条边是为 CPQ 路径存在的,不是给销售一个阶段一个阶段点出来的:*→ Proposal* 从每一个 Proposal 之前的阶段都合法,因为 `quote_generation` 流程一旦生成报价就把交易快进到 *Proposal*,而生成报价在任何开放阶段都可能发生——不过如果你们的组织要求立项,交易在立项通过之前不能生成报价([商机资格评估](/zh-Hans/docs/sales/opportunity-qualification#qualification-approval));*Proposal → Closed Won* 合法,则是因为 `quote_on_accepted` 钩子会从一份被接受的报价上直接赢下关联交易,中间不再经过一次谈判。 +其中两条边是为 CPQ 路径存在的,不是给销售一个阶段一个阶段点出来的:*→ Proposal* 从每一个 Proposal 之前的阶段都合法,因为 `quote_generation` 流程一旦生成报价就把交易快进到 *Proposal*,而生成报价在任何开放阶段都可能发生——不过如果你们的组织要求立项,交易在立项通过之前不能生成报价([商机资格评估](/zh-Hans/docs/sales/opportunity-qualification#qualification-approval));*Proposal → Closed Won* 合法,则是因为 `quote_on_accepted` 钩子会从一份被接受的报价上直接赢下关联交易,中间不再经过一次谈判。此外,每个未关闭的阶段都可以直接关闭:*→ Closed Won* 和 *→ Closed Lost* 一样,从任何未关闭阶段都合法,所以提前赢下的交易保存时不带警告。 这一步成交永远不是绕过审批的路。只要审批还卡着这笔交易——大额审批在审批中,或者在启用了它们的部署里,状态变更审批、立项审批在审批中或已驳回——接受报价这一步本身就会被拒绝,所以不会出现一份已接受的报价挂在一笔关不掉的交易上。见[报价](/zh-Hans/docs/sales/quotes#内置规则)。 @@ -99,7 +96,7 @@ Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ ## 状态机给你的是什么 -五条中有四条不是拦截——合同的转换表是会拒绝变更的那一条(见上文)。在另外四条上,本节原先列为「不会发生」的几件事,在转换表声明之后照样发生:代表仍可能不小心把潜在客户标记为 *Converted*,商机仍可以从 *Prospecting* 直接跳到 *Closed Won*,记录仍会跳过阶段。这些写入各自只在服务端日志里留下一条警告,然后照样落库。所以漏斗报表并不会因为存在一张表就 **反映现实** —— 它报的是记录实际走过的那些阶段。 +五条中有四条不是拦截——合同的转换表是会拒绝变更的那一条(见上文)。在另外四条上,本节原先列为「不会发生」的几件事,在转换表声明之后照样发生:代表仍可能不小心把潜在客户标记为 *Converted*,商机仍可以从 *Prospecting* 直接跳到 *Negotiation*,记录仍会跳过阶段。这些写入各自只在服务端日志里留下一条警告,然后照样落库。所以漏斗报表并不会因为存在一张表就 **反映现实** —— 它报的是记录实际走过的那些阶段。 转换表真正给你的,就是这条路线本身 —— **写在一个机器能读的地方**: diff --git a/content/docs/administration/state-machines.zh-Hant.mdx b/content/docs/administration/state-machines.zh-Hant.mdx index 63fe6a49d..6fffdf285 100644 --- a/content/docs/administration/state-machines.zh-Hant.mdx +++ b/content/docs/administration/state-machines.zh-Hant.mdx @@ -21,7 +21,7 @@ HotCRM 在 **五個** 物件上使用狀態機: 五者的宣告方式完全相同 —— 都是物件上一條具名的 `state_machine` 驗證規則,作用於該物件的生命週期欄位(`status`,商機上是 `stage`)。工單、合約、報價用的不是比潛在客戶和商機更輕的機制,就是同一套機制。頂層 `stateMachines` 鍵在平台 7.7 被移除後,所有狀態機都遷到了各自的物件上。 -**宣告出來的路線是建議,不是閘門——合約除外。** 潛在客戶、商機、工單和報價的規則都是 **警告** 級:走出轉換表的狀態變更只會寫進伺服器日誌——比如 *「Invalid quote status transition」*——而 **儲存照樣通過**。合約的規則 `contract_status_progression` 是 **錯誤** 級:走出轉換表的變更會被拒絕,合約保持原來的狀態。合約之所以例外,是因為它的兩個終點都是最終狀態——續約是一份新合約,絕不是把舊合約救活——而啟用會把客戶升級成 *Customer* 並把合約交給計費,跳過審批或從 *Expired* 救活的合約會在下游處處被當成生效中。在每個物件上,規則只判斷狀態的 **變更**:不改狀態的編輯根本碰不到它。記錄在 **新建** 時也根本不受檢查:規則拿新值與前一個值比對,因此只在更新時執行,直接匯入到後期狀態的記錄不會被碰——合約也一樣。警告級的轉換表給你的是一條宣告出來、機器可讀的路線,供自動化、報表和 Copilot 依賴——而不是給欄位上鎖。 +**宣告出來的路線是建議,不是閘門——合約除外。** 潛在客戶、商機、工單和報價的規則都是 **警告** 級:走出轉換表的狀態變更只會寫進伺服器日誌——比如 *「Invalid opportunity stage transition」*——而 **儲存照樣通過**。合約的規則 `contract_status_progression` 是 **錯誤** 級:走出轉換表的變更會被拒絕,合約保持原來的狀態。合約之所以例外,是因為它的兩個終點都是最終狀態——續約是一份新合約,絕不是把舊合約救活——而啟用會把客戶升級成 *Customer* 並把合約交給計費,跳過審批或從 *Expired* 救活的合約會在下游處處被當成生效中。在每個物件上,規則只判斷狀態的 **變更**:不改狀態的編輯根本碰不到它。記錄在 **新建** 時也根本不受檢查:規則拿新值與前一個值比對,因此只在更新時執行,直接匯入到後期狀態的記錄不會被碰——合約也一樣。警告級的轉換表給你的是一條宣告出來、機器可讀的路線,供自動化、報表和 Copilot 依賴——而不是給欄位上鎖。 **行銷活動沒有狀態機,這是有意的。** 行銷活動的狀態是描述性的(*Planning / In Progress / Completed / Aborted*),不是受控的生命週期;行銷活動成員、活動、知識文章和任務的狀態欄位同理。給它們中任何一個加轉換表,只會在正常編輯時報警告,教使用者忽略警告。 @@ -60,11 +60,8 @@ HotCRM 在 **五個** 物件上使用狀態機: ``` Prospecting → Qualification → Needs Analysis → Proposal → Negotiation - │ │ - └─────┬──────┘ - ▼ - Closed Won +Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Won Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Lost ``` @@ -74,15 +71,15 @@ Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ | 從 | 可以移動到 | 說明 | | --- | --- | --- | -| Prospecting | Qualification、Proposal、Closed Lost | | -| Qualification | Needs Analysis、Proposal、Closed Lost | | -| Needs Analysis | Proposal、Closed Lost | 預設贏率 40%,預測類別為 *Best Case* | +| Prospecting | Qualification、Proposal、Closed Won、Closed Lost | | +| Qualification | Needs Analysis、Proposal、Closed Won、Closed Lost | | +| Needs Analysis | Proposal、Closed Won、Closed Lost | 預設贏率 40%,預測類別為 *Best Case* | | Proposal | Negotiation、Closed Won、Closed Lost | | | Negotiation | Closed Won、Closed Lost | | | Closed Won | — | 終態 | | Closed Lost | — | 終態 | -其中兩條邊是為 CPQ 路徑存在的,不是給業務一個階段一個階段點出來的:*→ Proposal* 從每一個 Proposal 之前的階段都合法,因為 `quote_generation` 流程一旦產生報價就把交易快進到 *Proposal*,而產生報價在任何開放階段都可能發生——不過如果你們的組織要求立項,交易在立項通過之前不能產生報價([商機資格評估](/zh-Hant/docs/sales/opportunity-qualification#qualification-approval));*Proposal → Closed Won* 合法,則是因為 `quote_on_accepted` 鉤子會從一份被接受的報價上直接贏下關聯交易,中間不再經過一次談判。 +其中兩條邊是為 CPQ 路徑存在的,不是給業務一個階段一個階段點出來的:*→ Proposal* 從每一個 Proposal 之前的階段都合法,因為 `quote_generation` 流程一旦產生報價就把交易快進到 *Proposal*,而產生報價在任何開放階段都可能發生——不過如果你們的組織要求立項,交易在立項通過之前不能產生報價([商機資格評估](/zh-Hant/docs/sales/opportunity-qualification#qualification-approval));*Proposal → Closed Won* 合法,則是因為 `quote_on_accepted` 鉤子會從一份被接受的報價上直接贏下關聯交易,中間不再經過一次談判。此外,每個未關閉的階段都可以直接關閉:*→ Closed Won* 和 *→ Closed Lost* 一樣,從任何未關閉階段都合法,所以提前贏下的交易儲存時不帶警告。 這一步成交永遠不是繞過審批的路。只要審批還卡著這筆交易——大額審批在審批中,或者在啟用了它們的部署裡,狀態變更審批、立項審批在審批中或已駁回——接受報價這一步本身就會被拒絕,所以不會出現一份已接受的報價掛在一筆關不掉的交易上。見[報價](/zh-Hant/docs/sales/quotes#內建規則)。 @@ -101,7 +98,7 @@ Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ ## 狀態機給你的是什麼 -五條中有四條不是攔截——合約的轉換表是會拒絕變更的那一條(見上文)。在另外四條上,本節原先列為「不會發生」的幾件事,在轉換表宣告之後照樣發生:代表仍可能不小心把潛在客戶標記為 *Converted*,商機仍可以從 *Prospecting* 直接跳到 *Closed Won*,記錄仍會跳過階段。這些寫入各自只在伺服器日誌裡留下一條警告,然後照樣落庫。所以漏斗報表並不會因為存在一張表就 **反映現實** —— 它報的是記錄實際走過的那些階段。 +五條中有四條不是攔截——合約的轉換表是會拒絕變更的那一條(見上文)。在另外四條上,本節原先列為「不會發生」的幾件事,在轉換表宣告之後照樣發生:代表仍可能不小心把潛在客戶標記為 *Converted*,商機仍可以從 *Prospecting* 直接跳到 *Negotiation*,記錄仍會跳過階段。這些寫入各自只在伺服器日誌裡留下一條警告,然後照樣落庫。所以漏斗報表並不會因為存在一張表就 **反映現實** —— 它報的是記錄實際走過的那些階段。 轉換表真正給你的,就是這條路線本身 —— **寫在一個機器能讀的地方**: diff --git a/content/docs/marketing/campaign-members.mdx b/content/docs/marketing/campaign-members.mdx index 4d0c48d26..832957050 100644 --- a/content/docs/marketing/campaign-members.mdx +++ b/content/docs/marketing/campaign-members.mdx @@ -11,8 +11,8 @@ A **campaign member** is the link between a [campaign](/docs/marketing/campaigns Three ways: -1. **Bulk enrollment, run from the campaign** — open a campaign and click **Enroll Members** in the record header; the action is offered while the campaign is *Planning* or *In Progress*. It opens the *Enrollment Criteria* screen of the [Campaign Enrollment flow](/docs/marketing/campaigns#campaign-enrollment-flow), where you pick the audience yourself — leads by status, or contacts by department — and it then creates a member per matching person with status *Sent*, skipping the already-enrolled and the opted-out. Nothing runs it for you: the criteria are answered on that screen, and a scheduled run would have nobody to answer them. A run that added everyone it could ends with *Eligible members enrolled in campaign.* A run in which anyone was refused ends with a refusal instead, giving both counts: how many people were enrolled and how many were refused. A refused person is not on the campaign. -2. **From a lead list or a contact list** — **Add to Campaign** ships on both. On **All Leads** or **All Contacts**, tick the rows you want and run it from the selection toolbar; on any list laid out in rows, you can also run it on a single row from that row's ⋮ menu. Either way you choose the campaign from a record picker. It is not a button in the list header, where nothing is selected yet. From the toolbar, a confirmation step lists the people you ticked, each one is enrolled on its own, and the result reads *Succeeded* with a count; from a row's menu, a confirmation toast follows. It is a list action on both objects, so opening a lead or a contact does not offer it. People already on that campaign are skipped, and the run still reports success for them, so re-running it never double-counts a touch — the skip-check is per relationship, so a person enrolled once as a lead and once as a contact is deliberately two memberships rather than a duplicate. There is still no *Add Members* picker on the campaign that reaches lead and contact list views. +1. **Bulk enrollment, run from the campaign** — open a campaign and click **Enroll Members** in the record header; the action is offered while the campaign is *Planning* or *In Progress*. It opens the *Enrollment Criteria* screen of the [Campaign Enrollment flow](/docs/marketing/campaigns#campaign-enrollment-flow), where you pick the audience yourself — leads by status, or contacts by department — and it then creates a member per matching person with status *Sent*, skipping the already-enrolled and the opted-out. Nothing runs it for you: the criteria are answered on that screen, and a scheduled run would have nobody to answer them. A run in which nobody was refused ends on an *Enrollment Result* screen with three counts: how many people were added, how many were already members, and how many were skipped because they opted out of email or have no email address; the campaign's name on it links back to the campaign. A run in which anyone was refused ends with a refusal instead, giving those three counts and how many were refused. A refused person is not on the campaign. +2. **From a lead list or a contact list** — **Add to Campaign** ships on both. On **All Leads** or **All Contacts**, tick the rows you want and run it from the selection toolbar; on any list laid out in rows, you can also run it on a single row from that row's ⋮ menu. Either way you choose the campaign from a record picker. It is not a button in the list header, where nothing is selected yet. From the toolbar, a confirmation step lists the people you ticked, each one is enrolled on its own, and the result reads *Succeeded* with a count; from a row's menu, a toast names the campaign and says whether the person was added or was already a member (*Spring Launch: 0 added, 1 already a member.*). It is a list action on both objects, so opening a lead or a contact does not offer it. People already on that campaign are skipped; the toolbar's *Succeeded* count still includes them, since it counts the rows the action ran on, so re-running it never double-counts a touch — the skip-check is per relationship, so a person enrolled once as a lead and once as a contact is deliberately two memberships rather than a duplicate. There is still no *Add Members* picker on the campaign that reaches lead and contact list views. 3. **One at a time on the campaign** — open a campaign, find the **Campaign Members** panel on the *Related* tab, and use its **New** button to fill in a single membership by hand. **Who can enroll.** Enrolling someone creates a campaign member, and all three ways check that one right, as the person doing it: their profile's permission to create campaign members. Marketing User and the administrators hold it. Sales Rep and Sales Manager do not: their profiles read campaign members and leave enrollment to marketing, so they are not offered **Enroll Members** or **Add to Campaign**, and a member they try to create anyway — through either of those, the panel's **New** or the API — is refused. diff --git a/content/docs/marketing/campaign-members.zh-Hans.mdx b/content/docs/marketing/campaign-members.zh-Hans.mdx index dcf3002ce..6a94b6555 100644 --- a/content/docs/marketing/campaign-members.zh-Hans.mdx +++ b/content/docs/marketing/campaign-members.zh-Hans.mdx @@ -11,8 +11,8 @@ description: 加入每个营销活动的线索和联系人,以及如何跟踪 三种方式: -1. **在营销活动上批量加入** — 打开一个营销活动,在记录页头部点击 **Enroll Members**(批量加入成员);该操作在营销活动处于 *Planning* 或 *In Progress* 时提供。它会打开 [Campaign Enrollment flow](/zh-Hans/docs/marketing/campaigns#campaign-enrollment-flow) 的 *Enrollment Criteria* 屏幕,由你自己在那里挑选受众——线索按状态,联系人按部门——然后为每个匹配的人创建一条状态为 *Sent* 的成员记录,并跳过已加入的人和已退订的人。没有任何东西会替你运行它:条件是在那个屏幕上回答的,而定时运行没有人来回答它们。能加的人都加上了,运行结束时显示 *Eligible members enrolled in campaign.*;只要有人被拒绝,运行就以拒绝结束,并同时给出两个数字:加入了多少人、被拒绝了多少人。被拒绝的人不在该活动中。 -2. **从线索列表或联系人列表加入** — **Add to Campaign**(加入营销活动)两边都有。在**全部线索**或**全部联系人**里勾选需要的记录后,从选择工具栏批量运行;在任何按行排列的列表里,也可以从某一行的 ⋮ 菜单对这一条运行。两种方式都由记录选择器来选定营销活动。列表标题栏上没有这个按钮,那里还什么都没有选中。从工具栏运行时,确认步骤会列出你勾选的人,每个人单独加入,结果显示 *Succeeded* 和人数;从行菜单运行时,随后弹出一条确认提示。它在两个对象上都是列表操作,所以打开一条线索或一个联系人并不会提供它。已经在该活动里的人会被跳过,运行对他们同样报告成功,所以重复运行不会把一次触达算成两次——跳过检查按关系各查各的,因此同一个人以线索身份加入一次、又以联系人身份加入一次,是刻意保留的两条成员记录,而不是一次重复。营销活动上仍然没有一个 *Add Members* 选人器供你从线索和联系人列表视图中挑人。 +1. **在营销活动上批量加入** — 打开一个营销活动,在记录页头部点击 **Enroll Members**(批量加入成员);该操作在营销活动处于 *Planning* 或 *In Progress* 时提供。它会打开 [Campaign Enrollment flow](/zh-Hans/docs/marketing/campaigns#campaign-enrollment-flow) 的 *Enrollment Criteria* 屏幕,由你自己在那里挑选受众——线索按状态,联系人按部门——然后为每个匹配的人创建一条状态为 *Sent* 的成员记录,并跳过已加入的人和已退订的人。没有任何东西会替你运行它:条件是在那个屏幕上回答的,而定时运行没有人来回答它们。没有人被拒绝时,运行结束于 *Enrollment Result*(加入结果)屏幕,给出三个数字:新加入多少人、已是成员多少人、因退订邮件或没有邮箱地址而跳过多少人;屏幕上的营销活动名称可以点回该活动。只要有人被拒绝,运行就以拒绝结束,给出这三个数字和被拒绝的人数。被拒绝的人不在该活动中。 +2. **从线索列表或联系人列表加入** — **Add to Campaign**(加入营销活动)两边都有。在**全部线索**或**全部联系人**里勾选需要的记录后,从选择工具栏批量运行;在任何按行排列的列表里,也可以从某一行的 ⋮ 菜单对这一条运行。两种方式都由记录选择器来选定营销活动。列表标题栏上没有这个按钮,那里还什么都没有选中。从工具栏运行时,确认步骤会列出你勾选的人,每个人单独加入,结果显示 *Succeeded* 和人数;从行菜单运行时,随后弹出的提示会写明营销活动,并说明这个人是新加入还是已是成员(*Spring Launch: 0 added, 1 already a member.*)。它在两个对象上都是列表操作,所以打开一条线索或一个联系人并不会提供它。已经在该活动里的人会被跳过;工具栏的 *Succeeded* 计数仍包含他们,因为它统计的是动作运行了多少行,所以重复运行不会把一次触达算成两次——跳过检查按关系各查各的,因此同一个人以线索身份加入一次、又以联系人身份加入一次,是刻意保留的两条成员记录,而不是一次重复。营销活动上仍然没有一个 *Add Members* 选人器供你从线索和联系人列表视图中挑人。 3. **在营销活动上逐条新建** — 打开营销活动,在 *Related*(相关)选项卡里找到 **Campaign Members** 面板,用面板上的 **New**(新建)按钮手工填写一条成员记录。 **谁能加入成员。** 把一个人加入活动就是创建一条营销活动成员记录,三种方式都以操作者本人的身份检查同一项权限:其权限档案是否允许创建营销活动成员。Marketing User 和管理员拥有这项权限。Sales Rep 和 Sales Manager 没有:他们的权限档案只能查看营销活动成员,加入成员交给市场部,所以他们看不到 **Enroll Members** 和 **Add to Campaign**;无论经由这两个操作、面板上的 **New** 还是 API,他们想创建的成员都会被拒绝。 diff --git a/content/docs/marketing/campaign-members.zh-Hant.mdx b/content/docs/marketing/campaign-members.zh-Hant.mdx index 495450538..b3a2dd2ab 100644 --- a/content/docs/marketing/campaign-members.zh-Hant.mdx +++ b/content/docs/marketing/campaign-members.zh-Hant.mdx @@ -13,8 +13,8 @@ description: 加入每個行銷活動的潛在客戶和聯絡人,以及如何 三種方式: -1. **在行銷活動上批次加入** — 開啟一個行銷活動,在記錄頁標頭點擊 **Enroll Members**(批次加入成員);該操作在行銷活動處於 *Planning* 或 *In Progress* 時提供。它會開啟 [Campaign Enrollment flow](/zh-Hant/docs/marketing/campaigns#campaign-enrollment-flow) 的 *Enrollment Criteria* 畫面,由你自己在那裡挑選受眾——潛在客戶按狀態,聯絡人按部門——然後為每個符合的人建立一筆狀態為 *Sent* 的成員記錄,並略過已加入的人和已退訂的人。沒有任何東西會替你執行它:條件是在那個畫面上回答的,而定時執行沒有人來回答它們。能加的人都加上了,執行結束時顯示 *Eligible members enrolled in campaign.*;只要有人被拒絕,執行就以拒絕結束,並同時給出兩個數字:加入了多少人、被拒絕了多少人。被拒絕的人不在該活動中。 -2. **從潛在客戶清單或聯絡人清單加入** — **Add to Campaign**(加入行銷活動)兩邊都有。在**全部線索**或**全部聯絡人**裡勾選需要的記錄後,從選擇工具列批次執行;在任何按列排列的清單裡,也可以從某一列的 ⋮ 選單對這一筆執行。兩種方式都由記錄選擇器來選定行銷活動。清單標題列上沒有這個按鈕,那裡還什麼都沒有選取。從工具列執行時,確認步驟會列出你勾選的人,每個人單獨加入,結果顯示 *Succeeded* 和人數;從列選單執行時,隨後彈出一則確認提示。它在兩個物件上都是清單操作,所以開啟一筆潛在客戶或一個聯絡人並不會提供它。已經在該活動裡的人會被略過,執行對他們同樣回報成功,所以重複執行不會把一次觸達算成兩次——略過檢查按關係各查各的,因此同一個人以潛在客戶身分加入一次、又以聯絡人身分加入一次,是刻意保留的兩筆成員記錄,而不是一次重複。行銷活動上仍然沒有一個 *Add Members* 選人器讓你從潛在客戶和聯絡人清單檢視中挑人。 +1. **在行銷活動上批次加入** — 開啟一個行銷活動,在記錄頁標頭點擊 **Enroll Members**(批次加入成員);該操作在行銷活動處於 *Planning* 或 *In Progress* 時提供。它會開啟 [Campaign Enrollment flow](/zh-Hant/docs/marketing/campaigns#campaign-enrollment-flow) 的 *Enrollment Criteria* 畫面,由你自己在那裡挑選受眾——潛在客戶按狀態,聯絡人按部門——然後為每個符合的人建立一筆狀態為 *Sent* 的成員記錄,並略過已加入的人和已退訂的人。沒有任何東西會替你執行它:條件是在那個畫面上回答的,而定時執行沒有人來回答它們。沒有人被拒絕時,執行結束於 *Enrollment Result*(加入結果)畫面,給出三個數字:新加入多少人、已是成員多少人、因退訂郵件或沒有郵件地址而略過多少人;畫面上的行銷活動名稱可以點回該活動。只要有人被拒絕,執行就以拒絕結束,給出這三個數字和被拒絕的人數。被拒絕的人不在該活動中。 +2. **從潛在客戶清單或聯絡人清單加入** — **Add to Campaign**(加入行銷活動)兩邊都有。在**全部線索**或**全部聯絡人**裡勾選需要的記錄後,從選擇工具列批次執行;在任何按列排列的清單裡,也可以從某一列的 ⋮ 選單對這一筆執行。兩種方式都由記錄選擇器來選定行銷活動。清單標題列上沒有這個按鈕,那裡還什麼都沒有選取。從工具列執行時,確認步驟會列出你勾選的人,每個人單獨加入,結果顯示 *Succeeded* 和人數;從列選單執行時,隨後彈出的提示會寫明行銷活動,並說明這個人是新加入還是已是成員(*Spring Launch: 0 added, 1 already a member.*)。它在兩個物件上都是清單操作,所以開啟一筆潛在客戶或一個聯絡人並不會提供它。已經在該活動裡的人會被略過;工具列的 *Succeeded* 計數仍包含他們,因為它統計的是動作執行了多少列,所以重複執行不會把一次觸達算成兩次——略過檢查按關係各查各的,因此同一個人以潛在客戶身分加入一次、又以聯絡人身分加入一次,是刻意保留的兩筆成員記錄,而不是一次重複。行銷活動上仍然沒有一個 *Add Members* 選人器讓你從潛在客戶和聯絡人清單檢視中挑人。 3. **在行銷活動上逐筆新增** — 開啟行銷活動,在 *Related*(相關)索引標籤裡找到 **Campaign Members** 面板,用面板上的 **New**(新增)按鈕手動填寫一筆成員記錄。 **誰能加入成員。** 把一個人加入活動就是建立一筆行銷活動成員記錄,三種方式都以操作者本人的身分檢查同一項權限:其權限設定檔是否允許建立行銷活動成員。Marketing User 和管理員擁有這項權限。Sales Rep 和 Sales Manager 沒有:他們的權限設定檔只能檢視行銷活動成員,加入成員交給行銷部,所以他們看不到 **Enroll Members** 和 **Add to Campaign**;無論經由這兩個操作、面板上的 **New** 還是 API,他們想建立的成員都會被拒絕。 diff --git a/content/docs/revenue/approvals.mdx b/content/docs/revenue/approvals.mdx index ac3700672..98b3e28df 100644 --- a/content/docs/revenue/approvals.mdx +++ b/content/docs/revenue/approvals.mdx @@ -56,11 +56,11 @@ The process uses **first-response** behavior — the first approver to respond a ### ✅ Approved (all required tiers) 1. The opportunity is **unlocked**. 2. `approval_status` is stamped **approved** and `approved_date` is set to today. -3. The owner is notified (*"Deal approved"*). +3. The owner hears it once in the app and once by email. The in-app notice is the platform's *"Request approved"*, which goes to whoever submitted the request, with the approver's comment; the email is HotCRM's *"Deal approved"*. When someone other than the owner sent the deal for approval, the owner gets HotCRM's *"Deal approved"* in the app as well, since the platform's notice went to that person. ### ❌ Rejected (at any tier) - The run ends immediately as rejected — a reject at the Manager tier never reaches the Director. -- `approval_status` is stamped **rejected** and the owner is notified to revise and resubmit. +- `approval_status` is stamped **rejected** and the owner is told to revise and resubmit, the same way as an approval: the platform's *"Request rejected"* in the app, with the approver's comment, and HotCRM's email. - The deal is unlocked so the owner can revise it, and **the next save sends it for approval again** as a new request. - Until a later request is approved, the deal **cannot be closed won** (see [Closing a large deal won](#closing-a-large-deal-won)). Closing it as lost is still allowed. diff --git a/content/docs/revenue/approvals.zh-Hans.mdx b/content/docs/revenue/approvals.zh-Hans.mdx index 1cae12f5e..40acb149b 100644 --- a/content/docs/revenue/approvals.zh-Hans.mdx +++ b/content/docs/revenue/approvals.zh-Hans.mdx @@ -56,11 +56,11 @@ approval 节点使用**首次响应**(`first_response`)行为——在每个 ### ✅ 已批准(所有所需层级) 1. 商机被**解锁**。 2. `approval_status` 被标记为 **approved**,`approved_date` 设为今天。 -3. 负责人收到通知(*"交易已批准"*)。 +3. 负责人在应用内和邮件里各收到一次。应用内通知是平台的 *"请求已批准"*,发给提交这次审批的人,附带审批人的意见;邮件是 HotCRM 的 *"交易已批准"*。如果送审的不是负责人本人,平台通知会发给送审人,所以负责人还会在应用内收到 HotCRM 的 *"交易已批准"*。 ### ❌ 已拒绝(任一层级) - 流程立即以"已拒绝"结束——经理层级的拒绝绝不会到达总监。 -- `approval_status` 被标记为 **rejected**,负责人收到通知去修订并重新提交。 +- `approval_status` 被标记为 **rejected**,负责人收到通知去修订并重新提交,方式与批准相同:应用内是平台的 *"请求已驳回"*(附审批人意见),另有 HotCRM 的邮件。 - 商机随即解锁,负责人可以修订它;**修订后的下一次保存会再次送审**,作为一个新的审批请求。 - 在后续请求获批之前,商机**不能成交**(见[大额商机的成交](#closing-a-large-deal-won))。把它关闭为丢单仍然可以。 diff --git a/content/docs/revenue/approvals.zh-Hant.mdx b/content/docs/revenue/approvals.zh-Hant.mdx index 36247be76..ad8bd75ab 100644 --- a/content/docs/revenue/approvals.zh-Hant.mdx +++ b/content/docs/revenue/approvals.zh-Hant.mdx @@ -58,11 +58,11 @@ approval 節點使用**首次回應**(`first_response`)行為——在每個 ### ✅ 已批准(所有所需層級) 1. 商機被**解鎖**。 2. `approval_status` 被標記為 **approved**,`approved_date` 設為今天。 -3. 負責人收到通知(*「交易已批准」*)。 +3. 負責人在應用內和郵件裡各收到一次。應用內通知是平台的 *「請求已批准」*,發給提交這次審批的人,附帶審批人的意見;郵件是 HotCRM 的 *「交易已批准」*。如果送審的不是負責人本人,平台通知會發給送審人,所以負責人還會在應用內收到 HotCRM 的 *「交易已批准」*。 ### ❌ 已拒絕(任一層級) - 流程立即以「已拒絕」結束——經理層級的拒絕絕不會到達總監。 -- `approval_status` 被標記為 **rejected**,負責人收到通知去修訂並重新提交。 +- `approval_status` 被標記為 **rejected**,負責人收到通知去修訂並重新提交,方式與批准相同:應用內是平台的 *「請求已駁回」*(附審批人意見),另有 HotCRM 的郵件。 - 商機隨即解鎖,負責人可以修訂它;**修訂後的下一次儲存會再次送審**,作為一個新的審批請求。 - 在後續請求獲批之前,商機**不能成交**(見[大額商機的成交](#closing-a-large-deal-won))。把它關閉為丟單仍然可以。 diff --git a/content/docs/sales/forecasting.mdx b/content/docs/sales/forecasting.mdx index b8156cd23..7b9a8e8a0 100644 --- a/content/docs/sales/forecasting.mdx +++ b/content/docs/sales/forecasting.mdx @@ -40,7 +40,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_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 *A quarterly forecast's Period Start must be on a quarter boundary — January 1, April 1, July 1 or October 1*. - **`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 fecb8a0d8..54d4dc9a3 100644 --- a/content/docs/sales/forecasting.zh-Hans.mdx +++ b/content/docs/sales/forecasting.zh-Hans.mdx @@ -40,7 +40,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_start` 必须是所在周期真正的第一天。** 月度快照落在 1 号;季度快照还必须落在季度边界上——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。所以给一条季度快照送 `period_start` = 2026-07-15,它不会再以 **Q3 2026** 的标签落在季度中间:这次保存会被拒绝,报错是 *A quarterly forecast's Period Start must be on a quarter boundary — January 1, April 1, July 1 or October 1*。 - **`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 d406bdd40..b370e5a3a 100644 --- a/content/docs/sales/forecasting.zh-Hant.mdx +++ b/content/docs/sales/forecasting.zh-Hant.mdx @@ -42,7 +42,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_start` 必須是所在週期真正的第一天。** 月度快照落在 1 號;季度快照還必須落在季度邊界上——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。所以給一筆季度快照送 `period_start` = 2026-07-15,它不會再以 **Q3 2026** 的標籤落在季度中間:這次儲存會被拒絕,錯誤訊息是 *A quarterly forecast's Period Start must be on a quarter boundary — January 1, April 1, July 1 or October 1*。 - **`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/pipeline-management.mdx b/content/docs/sales/pipeline-management.mdx index 4f3c764cc..24c1eb544 100644 --- a/content/docs/sales/pipeline-management.mdx +++ b/content/docs/sales/pipeline-management.mdx @@ -48,7 +48,7 @@ Each card carries the four fields the board binds (`kanban.columns`): **Owner is not on the card.** `owner_id` sits in the view's top-level `columns` list, which is what the grid renderings read, but the card binding names those four fields and stops there. To see the owner next to a deal, use **All Opportunities**, which carries an *Owner* column, or the **Deal Cards** gallery, whose cards do show it. -To advance a deal, just **drag the card** to the next column. The stage rules are **advice, not a gate**. `opportunity_stage_progression` (`src/sales/objects/opportunity.object.ts`) declares which moves are legal — *Prospecting* straight to *Closed Won* is not one of them — but the rule carries **warning** severity, so a move outside the table writes one line to the server log, *"Invalid opportunity stage transition"*, and **the save still goes through**. Nothing in the board stops you. What the log buys you is that a bad import or a misbehaving integration can be found afterwards; see [Administration › State Machines](/docs/administration/state-machines) for what the table is and is not good for. +To advance a deal, just **drag the card** to the next column. The stage rules are **advice, not a gate**. `opportunity_stage_progression` (`src/sales/objects/opportunity.object.ts`) declares which moves are legal — *Prospecting* straight to *Negotiation* is not one of them, while closing a deal as won or lost is legal from any open stage — but the rule carries **warning** severity, so a move outside the table writes one line to the server log, *"Invalid opportunity stage transition"*, and **the save still goes through**. Nothing in the board stops you. What the log buys you is that a bad import or a misbehaving integration can be found afterwards; see [Administration › State Machines](/docs/administration/state-machines) for what the table is and is not good for. At the top of each column you'll see **one** number: the sum of *Amount* across that column's deals — the unweighted total for that stage. One is all a kanban column can carry, by design: a board declares a single `summarizeField` (`src/sales/views/opportunity.view.ts`), and this board binds it to `amount`. diff --git a/content/docs/sales/pipeline-management.zh-Hans.mdx b/content/docs/sales/pipeline-management.zh-Hans.mdx index 92dbc30f3..8a757fd11 100644 --- a/content/docs/sales/pipeline-management.zh-Hans.mdx +++ b/content/docs/sales/pipeline-management.zh-Hans.mdx @@ -48,7 +48,7 @@ description: 销售管道的日常运作方式——推进交易、自动概率 **卡片上没有负责人。** `owner_id` 在这个视图顶层的 `columns` 清单里,那是给网格类算绘读的;卡面绑定只点了上面四个字段,到此为止。要在交易旁边看到负责人,请用带 *Owner* 列的**全部商机**视图,或者卡面确实带负责人的 **Deal Cards** 图库视图。 -要推进一笔交易,只需**拖动卡片**到下一列。阶段规则是**建议,不是闸门**。`opportunity_stage_progression`(`src/sales/objects/opportunity.object.ts`)声明了哪些跃迁合法——*寻找客户*直接到*成交*并不在其中——但这条规则是**警告**级,所以走出转换表的移动只会往服务端日志写一行 *"Invalid opportunity stage transition"*,而**保存照样通过**。看板上没有任何东西会拦住你。日志给你的价值是:一次糟糕的导入、一个行为异常的集成,事后还能被找出来。这张表能做什么、不能做什么,详见 [系统管理 › 状态机](/zh-Hans/docs/administration/state-machines)。 +要推进一笔交易,只需**拖动卡片**到下一列。阶段规则是**建议,不是闸门**。`opportunity_stage_progression`(`src/sales/objects/opportunity.object.ts`)声明了哪些跃迁合法——*寻找客户*直接到*谈判*并不在其中,而从任何未关闭阶段直接成交或失败都合法——但这条规则是**警告**级,所以走出转换表的移动只会往服务端日志写一行 *"Invalid opportunity stage transition"*,而**保存照样通过**。看板上没有任何东西会拦住你。日志给你的价值是:一次糟糕的导入、一个行为异常的集成,事后还能被找出来。这张表能做什么、不能做什么,详见 [系统管理 › 状态机](/zh-Hans/docs/administration/state-machines)。 在每列顶部你会看到**一个**数字:该列所有交易的*金额*之和,也就是这个阶段的未加权合计。看板每列只能有这一个数,这是设计使然:一块看板只声明一个 `summarizeField`(`src/sales/views/opportunity.view.ts`),而这块板把它绑到了 `amount`。 diff --git a/content/docs/sales/pipeline-management.zh-Hant.mdx b/content/docs/sales/pipeline-management.zh-Hant.mdx index 0f80bb996..0658c68f4 100644 --- a/content/docs/sales/pipeline-management.zh-Hant.mdx +++ b/content/docs/sales/pipeline-management.zh-Hant.mdx @@ -50,7 +50,7 @@ description: 銷售管道的日常運作方式——推進交易、自動機率 **卡片上沒有負責人。** `owner_id` 在這個檢視頂層的 `columns` 清單裡,那是給網格類算繪讀的;卡面綁定只點了上面四個欄位,到此為止。要在交易旁邊看到負責人,請用帶 *Owner* 欄的**全部商機**檢視,或者卡面確實帶負責人的 **Deal Cards** 圖庫檢視。 -要推進一筆交易,只需**拖動卡片**到下一列。階段規則是**建議,不是閘門**。`opportunity_stage_progression`(`src/sales/objects/opportunity.object.ts`)宣告了哪些躍遷合法——*尋找客戶*直接到*成交*並不在其中——但這條規則是**警告**級,所以走出轉換表的移動只會往伺服器日誌寫一行 *「Invalid opportunity stage transition」*,而**儲存照樣通過**。看板上沒有任何東西會攔住你。日誌給你的價值是:一次糟糕的匯入、一個行為異常的整合,事後還能被找出來。這張表能做什麼、不能做什麼,詳見 [系統管理 › 狀態機](/zh-Hant/docs/administration/state-machines)。 +要推進一筆交易,只需**拖動卡片**到下一列。階段規則是**建議,不是閘門**。`opportunity_stage_progression`(`src/sales/objects/opportunity.object.ts`)宣告了哪些躍遷合法——*尋找客戶*直接到*談判*並不在其中,而從任何未關閉階段直接成交或失敗都合法——但這條規則是**警告**級,所以走出轉換表的移動只會往伺服器日誌寫一行 *「Invalid opportunity stage transition」*,而**儲存照樣通過**。看板上沒有任何東西會攔住你。日誌給你的價值是:一次糟糕的匯入、一個行為異常的整合,事後還能被找出來。這張表能做什麼、不能做什麼,詳見 [系統管理 › 狀態機](/zh-Hant/docs/administration/state-machines)。 在每列頂部你會看到**一個**數字:該列所有交易的*金額*之和,也就是這個階段的未加權合計。看板每列只能有這一個數,這是設計使然:一塊看板只宣告一個 `summarizeField`(`src/sales/views/opportunity.view.ts`),而這塊板把它綁到了 `amount`。 diff --git a/docs/STATUS.md b/docs/STATUS.md index e35d8b8e0..af4812f4c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -21,7 +21,7 @@ loader registers: HotCRM v6.0.0 Data: 20 Objects 375 Fields UI: 1 Apps 14 Views 6 Pages 5 Dashboards 10 Reports 38 Actions -Logic: 33 Flows +Logic: 34 Flows Security: 12 Positions 7 Permissions ``` diff --git a/objectstack.composition.ts b/objectstack.composition.ts index 6ca3e302c..be4ae7a47 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -109,6 +109,7 @@ import { OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow, TaskDueReminderFlow, TaskUrgentAlertFlow, BillingHandoffClosedWonFlow, ContractRenewalFlow, ContractExpirationFlow, BillingHandoffContractActivatedFlow, + ContractDraftedNoticeFlow, } from './src/sales/flows/index.js'; // Skills — likewise. @@ -222,6 +223,8 @@ export const appFlows = [ ContractRenewalFlow, ContractExpirationFlow, BillingHandoffContractActivatedFlow, + // Tells whoever accepted a quote that it drafted this contract (#2371 row 6). + ContractDraftedNoticeFlow, ]; /** diff --git a/src/docs/crm_sales_opportunities.md b/src/docs/crm_sales_opportunities.md index b30fde0e1..9b3fd464f 100644 --- a/src/docs/crm_sales_opportunities.md +++ b/src/docs/crm_sales_opportunities.md @@ -20,8 +20,10 @@ applies automatically along the way. ## Stages Opportunities advance through these stages. The arrows are the moves the app -treats as normal; a move off them still saves, and the app only logs a warning. -**Closed Won** and **Closed Lost** are where a deal ends. +treats as normal; a move off them still saves, and the save comes back with a +warning. Any open stage can close straight to **Closed Won** or **Closed Lost**, +so a deal won early saves without one. **Closed Won** and **Closed Lost** are +where a deal ends. ```mermaid stateDiagram-v2 @@ -34,11 +36,14 @@ stateDiagram-v2 state "Closed Lost" as closed_lost prospecting --> qualification prospecting --> proposal + prospecting --> closed_won prospecting --> closed_lost qualification --> needs_analysis qualification --> proposal + qualification --> closed_won qualification --> closed_lost needs_analysis --> proposal + needs_analysis --> closed_won needs_analysis --> closed_lost proposal --> negotiation proposal --> closed_won @@ -77,9 +82,16 @@ off. | **> $500,000** | Sales Manager **and** Sales Director sign-off | While an approval is pending, the opportunity is **locked** and its -**Approval Status** shows the live state. On full approval it is stamped -**Approved** (with date) and you're notified. On rejection it is stamped -**Rejected** — the deal is unlocked; revise it and save to resubmit. The +**Approval Status** shows the live state. A blue banner under the stage path +says the deal was sent for approval, to whom (a Sales Manager, or a Sales +Manager and then a Sales Director over $500,000), and that **Recall**, above +the title, withdraws the request. On full approval it is stamped +**Approved** (with date). On rejection it is stamped **Rejected** — the deal +is unlocked; revise it and save to resubmit. Either way you hear the outcome +once in the app — **Request approved** or **Request rejected**, with the +approver's comment — and once by email. If someone else's save sent your deal +for approval, that notice goes to them, and you get **Deal approved** or +**Deal rejected** in the app instead. The submitter can also **Recall** a pending request to unlock the deal; the next save resubmits it. Approvers act from their **Approvals → Inbox**. @@ -98,6 +110,15 @@ approval. --- +## Private deals + +Tick **Private** on a deal and only its owner can see it. That includes the +people who otherwise read every deal in the organisation, such as a Sales +Manager or a Marketing User: the deal drops out of their lists and reports, and +a link to it opens a page saying the record does not exist or may have been +deleted. Nothing is wrong with the deal; it is hidden from them. To share it +again, the owner clears **Private**. + **Related:** a deal starts life as a lead — see [Leads](./crm_sales_leads.md); once it is won, the relationship continues as a contract — see [Contracts](./crm_sales_contracts.md); the tasks, calls and meetings that move diff --git a/src/docs/crm_sales_opportunities.zh.md b/src/docs/crm_sales_opportunities.zh.md index a52bd5c17..f2cb16221 100644 --- a/src/docs/crm_sales_opportunities.zh.md +++ b/src/docs/crm_sales_opportunities.zh.md @@ -9,7 +9,7 @@ description: 商机阶段、停滞商机提醒、大单成交提醒和大额商 ## 阶段 -商机沿下图推进。箭头是应用视为正常的变更;偏离箭头的变更照样会保存,应用只记一条警告。**成交**和**失败**是商机的终点。 +商机沿下图推进。箭头是应用视为正常的变更;偏离箭头的变更照样会保存,保存结果会附带一条警告。任何未关闭的阶段都可以直接转为**成交**或**失败**,所以提前赢下的商机保存时不会再出现警告。**成交**和**失败**是商机的终点。 ```mermaid stateDiagram-v2 @@ -22,11 +22,14 @@ stateDiagram-v2 state "失败" as closed_lost prospecting --> qualification prospecting --> proposal + prospecting --> closed_won prospecting --> closed_lost qualification --> needs_analysis qualification --> proposal + qualification --> closed_won qualification --> closed_lost needs_analysis --> proposal + needs_analysis --> closed_won needs_analysis --> closed_lost proposal --> negotiation proposal --> closed_won @@ -54,7 +57,7 @@ stateDiagram-v2 | **$100,000 或以上** | 销售经理审核 | | **> $500,000** | 销售经理**和**销售总监签批 | -审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。全部通过后,它会被标记为**已批准**(并记录日期),你会收到通知。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 +审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。阶段进度条下方的蓝色提示会说明商机已提交审批、由谁审批(销售经理;金额超过 $500,000 时先由销售经理、再由销售总监审批),以及标题上方的**撤回**会撤回这次请求。全部通过后,它会被标记为**已批准**(并记录日期)。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。无论结果如何,你都会在应用内收到一次通知(**请求已批准**或**请求已驳回**,附审批人的意见),并收到一封邮件。如果是别人的保存让你的商机进入审批,这条应用内通知会发给那个人,你则会在应用内收到**商机已批准**或**商机未批准**。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 **金额 $100,000 或以上的商机,只有审批通过后才能成交。** 除非其**审批状态**为**已批准**,否则把它改为**成交**会被拒绝——无论请求在审批中、已被驳回、已被撤回,还是从未送审(**无需审批**,例如导入的商机,或在同一次保存里把金额提到线上并同时成交)。先在不成交的情况下保存商机以送审,审批通过后再成交。接受报价会把对应商机标记为成交,所以同一规则也会拦下报价的接受。丢单不需要审批。 @@ -62,4 +65,10 @@ stateDiagram-v2 --- +## 私密商机 + +在商机上勾选**私密**后,只有它的负责人能看到它。平时能看到全组织商机的人也不例外,例如销售经理和市场用户:这条商机会从他们的列表和报表中消失,打开它的链接会看到"记录不存在或已被删除"的提示。商机本身并没有问题,只是对他们隐藏了。要重新共享,由负责人取消勾选**私密**。 + +--- + **相关:** 商机的前身是线索——见[线索](./crm_sales_leads.md);赢单之后,客户关系以合同延续——见[合同](./crm_sales_contracts.md);推动它前进的任务、电话和会议——见[活动](./crm_sales_activities.md)。 diff --git a/src/marketing/docs/crm_marketing_automation.md b/src/marketing/docs/crm_marketing_automation.md index 03a061f03..75117b9f3 100644 --- a/src/marketing/docs/crm_marketing_automation.md +++ b/src/marketing/docs/crm_marketing_automation.md @@ -35,10 +35,14 @@ now a contact. Each new member starts at **Sent**. **What it skips and refuses:** anyone already on the campaign is skipped, so running it again tops the campaign up and never counts a person twice. If the -campaign is no longer open, nobody is added and the run says so. If anyone in -the segment is refused (for example, by permission), the run ends with both -counts, how many were enrolled and how many were refused, and the refused -people are not on the campaign. +campaign is no longer open, nobody is added and the run says so. + +**What it tells you:** the run ends on an **Enrollment Result** screen with +three counts: how many were added, how many were already members, and how many +were skipped because they opted out of email or have no email address. The +campaign's name on that screen links back to it. If anyone in the segment is +refused (for example, by permission), the run ends with those three counts and +how many were refused instead, and the refused people are not on the campaign. ## Add to Campaign — the people you picked @@ -48,8 +52,12 @@ toolbar, or run it on one row from that row's menu. You pick the campaign. It is offered to the same people as **Enroll Members**. **What it does:** adds each person you picked as a member at **Sent**, one by -one. Someone already on that campaign is skipped, and the run still reports -success for them. +one. Someone already on that campaign is skipped. Run on one row, the message +names the campaign and says whether the person was added or was already a +member, for example *Spring Launch: 0 added, 1 already a member.* Run from the +selection toolbar, the dialog counts the rows the action ran on (*Succeeded 2 / +2*), which includes people who were already members; for a count of who was +added, use **Enroll Members** on the campaign. **What it refuses:** a campaign that is not **Planning** or **In Progress**, and a person who has opted out of email. Nobody is added, the campaign's numbers do diff --git a/src/marketing/docs/crm_marketing_automation.zh.md b/src/marketing/docs/crm_marketing_automation.zh.md index 8fbd6b0e6..6285b40fb 100644 --- a/src/marketing/docs/crm_marketing_automation.zh.md +++ b/src/marketing/docs/crm_marketing_automation.zh.md @@ -13,13 +13,15 @@ description: 每个营销活动流程何时运行、做什么、拒绝什么, **它做什么:**它的界面先问两件事。一是加入**线索**还是**联系人**(每次运行只加入一种),二是这一种里的细分人群:线索按状态(*新建*、*已联系*或*已确认*),联系人按部门。然后它把这个细分人群中有邮箱、且没有退订邮件的人全部加入;线索还必须尚未转化,因为已转化的线索现在是联系人。每个新成员都从**已发送**开始。 -**它跳过和拒绝什么:**已经在活动中的人会被跳过,所以再运行一次只会补齐,绝不会把同一个人算两次。活动已不在可加入成员的状态时,不会加入任何人,运行结果会说明这一点。细分人群中如果有人被拒绝(例如因为权限),运行结束时会给出两个数字:加入了多少人、拒绝了多少人;被拒绝的人不在活动中。 +**它跳过和拒绝什么:**已经在活动中的人会被跳过,所以再运行一次只会补齐,绝不会把同一个人算两次。活动已不在可加入成员的状态时,不会加入任何人,运行结果会说明这一点。 + +**它告诉你什么:**运行结束时会显示**加入结果**界面,列出三个数字:新加入多少人、已是成员多少人、因退订邮件或没有邮箱地址而跳过多少人。界面上的营销活动名称可以点回该活动。细分人群中如果有人被拒绝(例如因为权限),运行结束时会改为给出这三个数字和被拒绝的人数;被拒绝的人不在活动中。 ## 加入营销活动——加入你选中的人 **何时运行:**在线索或联系人上运行**加入营销活动**时:在**全部线索**或**全部联系人**中勾选若干行,从选择工具栏运行;或者在某一行的菜单里对这一行运行。营销活动由你挑选。它提供给与**批量加入成员**相同的人。 -**它做什么:**把你选中的每个人逐一加入为**已发送**状态的成员。已经在该活动中的人会被跳过,运行仍对他们报告成功。 +**它做什么:**把你选中的每个人逐一加入为**已发送**状态的成员。已经在该活动中的人会被跳过。对单独一行运行时,提示会写明营销活动,并说明这个人是新加入还是已是成员,例如*春季发布:新加入 0 人,已是成员 1 人。*从选择工具栏运行时,对话框统计的是动作运行了多少行(*Succeeded 2 / 2*),其中包括已是成员的人;要知道实际新加入了多少人,请在营销活动上使用**批量加入成员**。 **它拒绝什么:**不处于**规划中**或**进行中**的营销活动,以及已退订邮件的人。这时不会加入任何人,活动的数字也不会变化,提示会写明是哪个活动或哪个人。无论成员以其他什么方式创建,这两条拒绝都同样适用:活动成员列表上的**新建**、导入,或 API。 diff --git a/src/marketing/docs/crm_marketing_campaigns.md b/src/marketing/docs/crm_marketing_campaigns.md index c6bd3d4b4..a0defc371 100644 --- a/src/marketing/docs/crm_marketing_campaigns.md +++ b/src/marketing/docs/crm_marketing_campaigns.md @@ -51,6 +51,11 @@ is converted becomes **Converted** for you; an **Unsubscribed** member never does. Setting a member to **Unsubscribed** also ticks **Email Opt Out** on that lead or contact, which keeps them out of every later campaign. +Deleting a lead or a contact also deletes every campaign membership it holds, +on every campaign, and those campaigns' numbers recount without it within a few +seconds. The delete confirmation does not mention the memberships, so check the +**Campaign Members** list on the person's record first if the campaign figures matter. + HotCRM does not track opens, clicks or bounces. What it can observe is a person answering and a lead converting, and those are the statuses it has. diff --git a/src/marketing/docs/crm_marketing_campaigns.zh.md b/src/marketing/docs/crm_marketing_campaigns.zh.md index ace4fa1e6..920e9fb8a 100644 --- a/src/marketing/docs/crm_marketing_campaigns.zh.md +++ b/src/marketing/docs/crm_marketing_campaigns.zh.md @@ -33,6 +33,8 @@ description: 营销活动的状态、每种成员状态的含义、活动上每 大多数成员停留在**已发送**。处于**已发送**或**已响应**的成员,其线索转化后会自动变为**已转化**;**已退订**的成员永远不会。把成员设为**已退订**,还会在对应线索上勾选**拒收邮件**(联系人上为**拒绝邮件**),使这个人不再进入之后的任何活动。 +删除一条线索或一个联系人,会同时删除它在所有活动中的成员记录,这些活动的数字会在几秒内重新计算,不再算上这个人。删除确认框不会提到这些成员记录,所以如果活动数字很重要,请先查看这个人记录上的**营销活动成员**列表。 + HotCRM 不跟踪打开、点击或退信。它能观察到的是一个人作出回应、一条线索完成转化,它的成员状态也就只有这些。 ## 各个数字统计的是什么 diff --git a/src/marketing/flows/campaign-enrollment.flow.ts b/src/marketing/flows/campaign-enrollment.flow.ts index 3902c489e..49d422dbd 100644 --- a/src/marketing/flows/campaign-enrollment.flow.ts +++ b/src/marketing/flows/campaign-enrollment.flow.ts @@ -55,7 +55,12 @@ export const CampaignEnrollmentFlow: Flow = { // as `flows.campaign_enrollment.successMessage` in the packs). A plain // string; the engine interpolates nothing here, which is why the counts // live on the refused ending instead. - successMessage: 'Eligible members enrolled in campaign.', + // Neutral on purpose (#2371 row 1): the result screen below carries the + // counts, and this sentence follows it whatever they were. It used to say + // the members were enrolled whether or not anyone was. A screen flow's + // `successMessage` cannot read the run's variables on 18.2.0 (measured: the + // holes render raw), so the counts cannot live here. + successMessage: 'Campaign enrollment finished.', variables: [ // `recordId` matches the console's flow-action trigger contract @@ -118,6 +123,13 @@ export const CampaignEnrollmentFlow: Flow = { // business seeding a count. { name: 'enrolledCount', type: 'number', isInput: false, isOutput: false, defaultValue: 0 }, { name: 'refusedCount', type: 'number', isInput: false, isOutput: false, defaultValue: 0 }, + // The other two counts the result names (#2371 row 1): people the segment + // matched who were already on the campaign, and people it matched who + // were left out before the loop because they opted out of email or have + // no email address (the eligibility filters on `query_leads` / + // `query_contacts`). + { name: 'alreadyCount', type: 'number', isInput: false, isOutput: false, defaultValue: 0 }, + { name: 'skippedCount', type: 'number', isInput: false, isOutput: false, defaultValue: 0 }, ], nodes: [ @@ -233,6 +245,22 @@ export const CampaignEnrollmentFlow: Flow = { outputVariable: 'leadList', }, }, + { + // The same segment without the eligibility filters, read only to count + // who those filters left out (#2371 row 1). Same limit as the eligible + // query, so the difference is the people this run skipped. + id: 'query_lead_segment', type: 'get_record', label: 'Find Leads in Segment', + config: { + objectName: 'crm_lead', + filter: { status: cel`leadStatus`, is_converted: false }, + limit: 1000, + outputVariable: 'leadSegment', + }, + }, + { + id: 'count_skipped_leads', type: 'assignment', label: 'Count Skipped', + config: { assignments: { skippedCount: cel`size(leadSegment) - size(leadList)` } }, + }, { id: 'loop_leads', type: 'loop', label: 'Process Each Lead', config: { @@ -262,14 +290,27 @@ export const CampaignEnrollmentFlow: Flow = { id: 'count_enrolled_lead', type: 'assignment', label: 'Count Enrolled', config: { assignments: { enrolledCount: cel`enrolledCount + 1` } }, }, + { + id: 'count_already_lead', type: 'assignment', label: 'Count Already a Member', + config: { assignments: { alreadyCount: cel`alreadyCount + 1` } }, + }, + // The region's one exit: a guarded region must be single-exit, so + // the two counts join here before the next lead. + { id: 'counted_lead', type: 'assignment', label: 'Counted' }, ], edges: [ { id: 'b1', source: 'create_campaign_member', target: 'check_lead_enrolled', type: 'default' }, - // Already enrolled → no edge → next lead, counted as neither. + // Already enrolled → `b3`, counted as already a member. // `leadEnrolment` is the callee's output map, so its field read // carries both `has()` guards like any other variable field read // (test/flow-variable-conditions.test.ts). { id: 'b2', source: 'check_lead_enrolled', target: 'count_enrolled_lead', type: 'conditional', condition: P`has(vars.leadEnrolment) && has(vars.leadEnrolment.enrolled) && vars.leadEnrolment.enrolled == true`, label: 'Enrolled' }, + // A subflow that came back without refusing and without enrolling + // found the lead already on the campaign (#2371 row 1): `b2`'s + // exact negation, so the two partition. + { id: 'b3', source: 'check_lead_enrolled', target: 'count_already_lead', type: 'conditional', condition: P`!(has(vars.leadEnrolment) && has(vars.leadEnrolment.enrolled) && vars.leadEnrolment.enrolled == true)`, label: 'Already a member' }, + { id: 'b4', source: 'count_enrolled_lead', target: 'counted_lead', type: 'default' }, + { id: 'b5', source: 'count_already_lead', target: 'counted_lead', type: 'default' }, ], // A refused lead is caught here and counted. }, { refusedCount: cel`refusedCount + 1` }), @@ -290,6 +331,19 @@ export const CampaignEnrollmentFlow: Flow = { outputVariable: 'contactList', }, }, + { + id: 'query_contact_segment', type: 'get_record', label: 'Find Contacts in Segment', + config: { + objectName: 'crm_contact', + filter: { department: cel`contactDepartment` }, + limit: 1000, + outputVariable: 'contactSegment', + }, + }, + { + id: 'count_skipped_contacts', type: 'assignment', label: 'Count Skipped', + config: { assignments: { skippedCount: cel`size(contactSegment) - size(contactList)` } }, + }, { id: 'loop_contacts', type: 'loop', label: 'Process Each Contact', config: { @@ -313,10 +367,23 @@ export const CampaignEnrollmentFlow: Flow = { id: 'count_enrolled_contact', type: 'assignment', label: 'Count Enrolled', config: { assignments: { enrolledCount: cel`enrolledCount + 1` } }, }, + { + id: 'count_already_contact', type: 'assignment', label: 'Count Already a Member', + config: { assignments: { alreadyCount: cel`alreadyCount + 1` } }, + }, + // The region's one exit: a guarded region must be single-exit, so + // the two counts join here before the next contact. + { id: 'counted_contact', type: 'assignment', label: 'Counted' }, ], edges: [ { id: 'c1', source: 'create_contact_member', target: 'check_contact_enrolled', type: 'default' }, { id: 'c2', source: 'check_contact_enrolled', target: 'count_enrolled_contact', type: 'conditional', condition: P`has(vars.contactEnrolment) && has(vars.contactEnrolment.enrolled) && vars.contactEnrolment.enrolled == true`, label: 'Enrolled' }, + // A subflow that came back without refusing and without enrolling + // found the contact already on the campaign (#2371 row 1): `c2`'s + // exact negation, so the two partition. + { id: 'c3', source: 'check_contact_enrolled', target: 'count_already_contact', type: 'conditional', condition: P`!(has(vars.contactEnrolment) && has(vars.contactEnrolment.enrolled) && vars.contactEnrolment.enrolled == true)`, label: 'Already a member' }, + { id: 'c4', source: 'count_enrolled_contact', target: 'counted_contact', type: 'default' }, + { id: 'c5', source: 'count_already_contact', target: 'counted_contact', type: 'default' }, ], }, { refusedCount: cel`refusedCount + 1` }), }, @@ -330,7 +397,22 @@ export const CampaignEnrollmentFlow: Flow = { id: 'end_refused', type: 'end', label: 'Enrollment Refused', config: { outcome: 'refused', - message: 'Enrollment refused for {{refusedCount}} of the people selected: {{enrolledCount}} enrolled, {{refusedCount}} refused. A refused person was not added to the campaign; the flow run log records why for each one.', + message: 'Enrollment refused for {{refusedCount}} of the people selected: {{enrolledCount}} added, {{alreadyCount}} already members, {{skippedCount}} skipped because they opted out of email or have no email address, {{refusedCount}} refused. A refused person was not added to the campaign; the flow run log records why for each one.', + }, + }, + // THE RESULT SCREEN (#2371 row 1, the #2297 shape). The run used to end on + // the static toast above, which said the members were enrolled whether or + // not anyone was. It now pauses, after every write, on the three counts + // and a link back to the campaign. The link is what makes it pause for + // every caller (a `record_link` takes no input); Submit only completes + // the run. Translated as `screens.result` in the packs. + { + id: 'result', type: 'screen', label: 'Enrollment Result', + config: { + description: '{{enrolledCount}} added to the campaign, {{alreadyCount}} already members, {{skippedCount}} skipped because they opted out of email or have no email address.', + fields: [ + { name: 'campaignLink', label: 'Campaign', type: 'record_link', reference: 'crm_campaign', defaultValue: cel`recordId` }, + ], }, }, { id: 'end', type: 'end', label: 'End' }, @@ -368,7 +450,9 @@ export const CampaignEnrollmentFlow: Flow = { { id: 'e4', source: 'check_campaign_open', target: 'query_leads', type: 'conditional', condition: P`has(vars.campaignRecord) && has(vars.campaignRecord.status) && (vars.campaignRecord.status == "planning" || vars.campaignRecord.status == "in_progress") && vars.memberSource != "contacts"`, label: 'Open · Leads' }, - { id: 'e5', source: 'query_leads', target: 'loop_leads', type: 'default' }, + { id: 'e5', source: 'query_leads', target: 'query_lead_segment', type: 'default' }, + { id: 'e13', source: 'query_lead_segment', target: 'count_skipped_leads', type: 'default' }, + { id: 'e14', source: 'count_skipped_leads', target: 'loop_leads', type: 'default' }, { id: 'e6', source: 'loop_leads', target: 'check_refused', type: 'default' }, // The contact branch. `memberSource` carries no `has()` guard on either // edge, deliberately: its `flow.variables` declaration carries @@ -392,7 +476,9 @@ export const CampaignEnrollmentFlow: Flow = { // them; and Studio's Problems panel flags a decision with no default edge // however its guards partition. { id: 'e12', source: 'check_campaign_open', target: 'refuse_not_open', type: 'default', isDefault: true, label: 'Not open' }, - { id: 'e8', source: 'query_contacts', target: 'loop_contacts', type: 'default' }, + { id: 'e8', source: 'query_contacts', target: 'query_contact_segment', type: 'default' }, + { id: 'e15', source: 'query_contact_segment', target: 'count_skipped_contacts', type: 'default' }, + { id: 'e16', source: 'count_skipped_contacts', target: 'loop_contacts', type: 'default' }, { id: 'e9', source: 'loop_contacts', target: 'check_refused', type: 'default' }, // One conditional and its default (#2227), the same form as // `check_campaign_open`'s `e12`. This decision declares no @@ -410,6 +496,7 @@ export const CampaignEnrollmentFlow: Flow = { // variable can hold where an ordering test aborts on a null // (test/flow-variable-conditions.test.ts). { id: 'e10', source: 'check_refused', target: 'end_refused', type: 'conditional', condition: P`vars.refusedCount != 0`, label: 'Refused' }, - { id: 'e11', source: 'check_refused', target: 'end', type: 'default', isDefault: true, label: 'Clean' }, + { id: 'e11', source: 'check_refused', target: 'result', type: 'default', isDefault: true, label: 'Clean' }, + { id: 'e17', source: 'result', target: 'end', type: 'default' }, ], }; diff --git a/src/marketing/flows/campaign-member-enroll.flow.ts b/src/marketing/flows/campaign-member-enroll.flow.ts index 0bfeb94fa..271498b35 100644 --- a/src/marketing/flows/campaign-member-enroll.flow.ts +++ b/src/marketing/flows/campaign-member-enroll.flow.ts @@ -109,6 +109,26 @@ const MEMBERSHIP_FIELDS = { /** `enrolled` starts false; only the insert's own out-edge sets it. */ const ENROLLED = { name: 'enrolled', type: 'boolean', isInput: false, isOutput: true, defaultValue: false }; +/** + * What Add to Campaign's success toast says (#2371 row 1): the campaign, and + * how many were added and how many were already on it, the same two counts + * Enroll Members reports. On 18.2.0 the toast read "Leads added to campaign!" + * whether the run added the person or found them already a member. An + * autolaunched flow's outputs reach the action's `successMessage` as + * `result.output.` (measured), so the counts are outputs here and the + * sentence is the action's, translated in the packs. Exactly one of the two + * counts ends at 1 on a run that is not refused. + */ +const RESULT_OUTPUTS = [ + { name: 'campaignName', type: 'text', isInput: false, isOutput: true }, + { name: 'addedCount', type: 'number', isInput: false, isOutput: true, defaultValue: 0 }, + { name: 'alreadyCount', type: 'number', isInput: false, isOutput: true, defaultValue: 0 }, +]; + +/** The two ends of a run that is not refused: added, or already on the campaign. */ +const ADDED = { enrolled: true, addedCount: 1, campaignName: cel`campaignRecord.name` }; +const ALREADY = { alreadyCount: 1, campaignName: cel`campaignRecord.name` }; + /** * The campaign gate, read as the caller. `crm_campaign` is `public_read`, so * a caller who may enroll can read the campaign they picked. @@ -159,6 +179,7 @@ export const CampaignLeadMemberEnrollFlow: Flow = { { name: 'campaignId', type: 'text', isInput: true, isOutput: false }, { name: 'leadId', type: 'text', isInput: true, isOutput: false }, ENROLLED, + ...RESULT_OUTPUTS, ], nodes: [ @@ -200,7 +221,8 @@ export const CampaignLeadMemberEnrollFlow: Flow = { fields: { crm_campaign: cel`campaignId`, crm_lead: cel`leadId`, ...MEMBERSHIP_FIELDS }, }, }, - { id: 'mark_enrolled', type: 'assignment', label: 'Enrolled', config: { assignments: { enrolled: true } } }, + { id: 'mark_enrolled', type: 'assignment', label: 'Enrolled', config: { assignments: ADDED } }, + { id: 'mark_already', type: 'assignment', label: 'Already a Member', config: { assignments: ALREADY } }, { id: 'end', type: 'end', label: 'End' }, ], @@ -219,6 +241,10 @@ export const CampaignLeadMemberEnrollFlow: Flow = { { id: 'e9', source: 'check_not_enrolled', target: 'create_campaign_member', type: 'conditional', condition: P`existingMember == null`, label: 'Enroll' }, { id: 'e10', source: 'create_campaign_member', target: 'mark_enrolled', type: 'default' }, { id: 'e11', source: 'mark_enrolled', target: 'end', type: 'default' }, + // The other half of `e9`: until #2371 row 1 an existing member ended the + // run at the decision, with nothing for the toast to count. + { id: 'e12', source: 'check_not_enrolled', target: 'mark_already', type: 'conditional', condition: P`existingMember != null`, label: 'Already a member' }, + { id: 'e13', source: 'mark_already', target: 'end', type: 'default' }, ], }; @@ -236,6 +262,7 @@ export const CampaignContactMemberEnrollFlow: Flow = { { name: 'campaignId', type: 'text', isInput: true, isOutput: false }, { name: 'contactId', type: 'text', isInput: true, isOutput: false }, ENROLLED, + ...RESULT_OUTPUTS, ], nodes: [ @@ -274,7 +301,8 @@ export const CampaignContactMemberEnrollFlow: Flow = { fields: { crm_campaign: cel`campaignId`, crm_contact: cel`contactId`, ...MEMBERSHIP_FIELDS }, }, }, - { id: 'mark_contact_enrolled', type: 'assignment', label: 'Enrolled', config: { assignments: { enrolled: true } } }, + { id: 'mark_contact_enrolled', type: 'assignment', label: 'Enrolled', config: { assignments: ADDED } }, + { id: 'mark_contact_already', type: 'assignment', label: 'Already a Member', config: { assignments: ALREADY } }, { id: 'end', type: 'end', label: 'End' }, ], @@ -290,5 +318,7 @@ export const CampaignContactMemberEnrollFlow: Flow = { { id: 'e9', source: 'check_contact_not_enrolled', target: 'create_contact_member', type: 'conditional', condition: P`existingContactMember == null`, label: 'Enroll' }, { id: 'e10', source: 'create_contact_member', target: 'mark_contact_enrolled', type: 'default' }, { id: 'e11', source: 'mark_contact_enrolled', target: 'end', type: 'default' }, + { id: 'e12', source: 'check_contact_not_enrolled', target: 'mark_contact_already', type: 'conditional', condition: P`existingContactMember != null`, label: 'Already a member' }, + { id: 'e13', source: 'mark_contact_already', target: 'end', type: 'default' }, ], }; diff --git a/src/revenue/docs/crm_revenue_quotes.md b/src/revenue/docs/crm_revenue_quotes.md index 0afa332d0..501586275 100644 --- a/src/revenue/docs/crm_revenue_quotes.md +++ b/src/revenue/docs/crm_revenue_quotes.md @@ -27,10 +27,14 @@ Generate a quote from an opportunity. The quote screen asks for: The system computes the math for you: subtotal = the opportunity amount, discount amount = subtotal × discount %, and **total = amount × (1 − discount%)**. +When you submit, the dialog shows **Quote Created**: the quote is saved as a +**Draft**, and its name is a link that opens it. **Submit** on that screen just +closes the dialog; the quote is already there either way. + ## Quote status A quote usually moves along these arrows. A move off them still saves, and the -app only logs a warning. **Accepted** and **Expired** are where a quote ends; a +save comes back with a warning that spells out the usual path. **Accepted** and **Expired** are where a quote ends; a rejected quote can go back to **Draft** to be reworked. ```mermaid @@ -75,6 +79,13 @@ An **Accepted**, **Rejected** or **Expired** quote is settled. It shows no still be changed. A **Rejected** quote is reworked by clicking **Revise**: it goes back to **Draft**, where its fields and lines can be edited again. +## When a quote is accepted + +Accepting a quote wins its opportunity and drafts a **contract** from it. A +moment later you get a notice, **Contract drafted**, with the contract number +and a **View** link to it. The contract's term and contract type are starting +values, so check them before you activate it. + ## Quote auto-expiration (automatic) Where the deployment has switched scheduled work on, a daily **01:00** job marks diff --git a/src/revenue/docs/crm_revenue_quotes.zh.md b/src/revenue/docs/crm_revenue_quotes.zh.md index 63ad3da7c..8357d2a1c 100644 --- a/src/revenue/docs/crm_revenue_quotes.zh.md +++ b/src/revenue/docs/crm_revenue_quotes.zh.md @@ -17,9 +17,11 @@ description: 从商机生成报价单、总额如何计算、报价单状态, 计算由系统完成:小计 = 商机金额,折扣金额 = 小计 × 折扣 %,**总金额 = 金额 × (1 − 折扣%)**。 +提交后,对话框会显示**报价单已创建**:报价单已保存为**草稿**,名称就是打开它的链接。这个界面上的**提交**只是关闭对话框;无论是否点击,报价单都已经创建好了。 + ## 报价单状态 -报价单通常沿下图流转。偏离箭头的变更照样会保存,应用只记一条警告。**已接受**和**已过期**是报价单的终点;被拒绝的报价单可以退回**草稿**重新修改。 +报价单通常沿下图流转。偏离箭头的变更照样会保存,保存结果会附带一条警告,写明通常的流转路径。**已接受**和**已过期**是报价单的终点;被拒绝的报价单可以退回**草稿**重新修改。 ```mermaid stateDiagram-v2 @@ -51,6 +53,10 @@ stateDiagram-v2 **已接受**、**已拒绝**或**已过期**的报价单已经结束。它不显示**编辑**,它的报价明细也不显示**新建**,只有**内部备注**还能修改。**已拒绝**的报价单要重新修改,请点击**修订**:它会退回**草稿**,在草稿上字段和明细都可以再次编辑。 +## 报价单被接受时 + +接受报价单会让对应商机赢单,并据此起草一份**合同**。稍后你会收到一条**合同已起草**的通知,写明合同编号,并带有打开合同的**查看**链接。合同的期限和合同类型只是初始值,激活前请先核对。 + ## 报价单自动过期(自动) 在开启了计划任务的部署上,每天 **01:00** 的任务会把已过到期日期、仍未结束的报价单标记为**已过期**。默认安装启动时这个任务未绑定,过期的报价单会保持原状态,直到有人修改它。如果交易仍在进行,请重新出一份报价单。 diff --git a/src/revenue/flows/quote-generation.flow.ts b/src/revenue/flows/quote-generation.flow.ts index c994020ec..aaf972b66 100644 --- a/src/revenue/flows/quote-generation.flow.ts +++ b/src/revenue/flows/quote-generation.flow.ts @@ -225,6 +225,26 @@ export const QuoteGenerationFlow: Flow = { actionUrl: '/crm_quote/{{quoteId.id}}', }, }, + // THE RESULT SCREEN (#2371 row 6, the #2297 shape). Generate Quote used + // to close on a toast and leave the rep on the opportunity, with the new + // quote reachable only through the related list. The run now pauses, + // after every write, on a screen naming the quote with a link to it. + // Submit only completes the run; a rep who closes the dialog instead + // loses nothing. A `record_link` field takes no input, so the screen + // pauses every caller: a REST or AI-tool resume of `screen_1` gets the + // run back `paused` here with the quote's id on the link, and one more + // resume with no values completes it. + // + // Translated as `screens.result` in the packs. + { + id: 'result', type: 'screen', label: 'Quote Created', + config: { + description: 'Quote "{{ quoteName }}" was created as a draft. Open it to review the price and send it on.', + fields: [ + { name: 'quoteLink', label: 'Quote', type: 'record_link', reference: 'crm_quote', defaultValue: cel`quoteId.id` }, + ], + }, + }, { id: 'end', type: 'end', label: 'End' }, ], @@ -278,6 +298,7 @@ export const QuoteGenerationFlow: Flow = { { id: 'e4a', source: 'check_stage', target: 'update_opportunity', type: 'conditional', condition: expression(ADVANCE, 'cel'), label: 'Advance' }, { id: 'e4b', source: 'check_stage', target: 'notify_owner', type: 'conditional', condition: expression(`!(${ADVANCE})`, 'cel'), label: 'Keep stage' }, { id: 'e5', source: 'update_opportunity', target: 'notify_owner', type: 'default' }, - { id: 'e6', source: 'notify_owner', target: 'end', type: 'default' }, + { id: 'e6', source: 'notify_owner', target: 'result', type: 'default' }, + { id: 'e12', source: 'result', target: 'end', type: 'default' }, ], }; diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 76a4b9693..d08dce710 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -432,7 +432,16 @@ export const Quote = ObjectSchema.create({ name: 'quote_status_progression', type: 'state_machine', severity: 'warning', - message: 'Invalid quote status transition', + // The path in business words, by the status labels (#2371 row 7). It + // read "Invalid quote status transition", naming neither the move nor + // the moves allowed. A state-machine message takes no holes on 18.2.0, + // so it cannot name THIS move's two statuses; the response's + // `constraint` carries `from` / `to`, and the console shows only this + // sentence. So it states the whole graph below, as + // `contract_status_progression` does. Each locale pack restates it with + // its own option labels, held to them by `test/refusal-labels.test.ts`. + message: + "This status change is off the quote's usual path: Draft → In Review → Presented → Accepted. In Review can also go back to Draft or be Rejected, Presented can be Rejected, and a Rejected quote returns to Draft. Draft, In Review or Presented can become Expired. Accepted and Expired are final.", field: 'status', transitions: { // `→ expired` is legal from every UNSETTLED state: the quote_expiration diff --git a/src/sales/actions/contact.actions.ts b/src/sales/actions/contact.actions.ts index 7c2554444..c5cbe698b 100644 --- a/src/sales/actions/contact.actions.ts +++ b/src/sales/actions/contact.actions.ts @@ -110,7 +110,11 @@ export const AddContactToCampaignAction: Action = { // the `send_email` action uses. And only for a caller who may create the // member (see the header). visible: P`has(record.email_opt_out) && record.email_opt_out == false && current_user.can("crm_campaign_member", "create")`, - successMessage: 'Contacts added to campaign!', + // Says what the run did (#2371 row 1): the campaign, how many were added + // and how many were already on it. It read "Contacts added to campaign!" on a run + // that found the person already a member. The counts are the enrollment + // flow's outputs (`campaign-member-enroll.flow.ts`). + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} added, {{ result.output.alreadyCount }} already a member.', refreshAfter: true, }; diff --git a/src/sales/actions/lead.actions.ts b/src/sales/actions/lead.actions.ts index 04c904719..5249c0bab 100644 --- a/src/sales/actions/lead.actions.ts +++ b/src/sales/actions/lead.actions.ts @@ -217,6 +217,10 @@ export const CreateCampaignAction: Action = { required: true, } ], - successMessage: 'Leads added to campaign!', + // Says what the run did (#2371 row 1): the campaign, how many were added + // and how many were already on it. It read "Leads added to campaign!" on a run + // that found the person already a member. The counts are the enrollment + // flow's outputs (`campaign-member-enroll.flow.ts`). + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} added, {{ result.output.alreadyCount }} already a member.', refreshAfter: true, }; diff --git a/src/sales/actions/opportunity.actions.ts b/src/sales/actions/opportunity.actions.ts index 7391df7b9..1e50dba0f 100644 --- a/src/sales/actions/opportunity.actions.ts +++ b/src/sales/actions/opportunity.actions.ts @@ -45,8 +45,9 @@ export const CloneOpportunityAction: Action = { // whenever the clone instant is within an hour of a UTC day boundary. const horizon = new Date(); horizon.setUTCDate(horizon.getUTCDate() + 90); + const name = 'Copy of ' + (src.name ?? ('opportunity ' + id)); const inserted = await ctx.api.object('crm_opportunity').insert({ - name: 'Copy of ' + (src.name ?? ('opportunity ' + id)), + name, crm_account: src.crm_account, primary_contact: src.primary_contact ?? null, amount: src.amount ?? 1, @@ -66,13 +67,18 @@ export const CloneOpportunityAction: Action = { // \`owner_id\` for it — see the note in activity-actions.ts (#548). owner_id: ctx.user?.id ?? null, }); - return { id: inserted?.id ?? null }; + // \`name\` feeds the success toast below, so the rep knows the page + // they land on is the copy (#2371 row 4). + return { id: inserted?.id ?? null, name }; `, capabilities: ['api.read', 'api.write'], timeoutMs: 5000, }, locations: ['record_header', 'record_more'], - successMessage: 'Opportunity cloned successfully!', + // Names the copy: the runner navigates to it straight after, and the + // record page looks like the source but for the "Copy of" prefix and the + // stage (#2371 row 4). `result` is the body's return value. + successMessage: 'Created "{{ result.name }}". You are now on the copy.', // Open the clone once it exists: the body returns `{ id }`, and the // console's ActionRunner resolves `${result.id}` against that result and // navigates in the same tab (`openIn` defaults to 'self'). No diff --git a/src/sales/email-templates/crm.email-template.ts b/src/sales/email-templates/crm.email-template.ts index 426331f6a..7bed71e4a 100644 --- a/src/sales/email-templates/crm.email-template.ts +++ b/src/sales/email-templates/crm.email-template.ts @@ -10,7 +10,7 @@ import { esES } from './es-ES'; /** * Sales — every `sys_email_template` row this package's `notify` nodes name. * - * Four locale packs crossed with sixteen template names: the rows a + * Four locale packs crossed with seventeen template names: the rows a * recipient's own `sys_user.locale` is resolved against, per recipient, after * fan-out. Assembled here the way `translations/crm.translation.ts` assembles * the UI locale packs — one file per language, one file that collects them. diff --git a/src/sales/email-templates/en.ts b/src/sales/email-templates/en.ts index 3401ac6b8..272d6442d 100644 --- a/src/sales/email-templates/en.ts +++ b/src/sales/email-templates/en.ts @@ -106,4 +106,11 @@ export const en: EmailTemplatePack = { subject: 'Urgent task: {{subject}}', bodyHtml: '

An urgent task "{{subject}}" was assigned to you and needs attention.

', }, + // Sent by `contract_drafted_notice` when accepting a quote drafts its + // contract (#2371 row 6). + 'crm.contract_drafted': { + label: 'Contract Drafted', + subject: 'Contract drafted: {{contract_number}}', + bodyHtml: '

Accepting the quote drafted contract {{contract_number}}. Its term and contract type are starting values: check them before you activate it.

', + }, }; diff --git a/src/sales/email-templates/es-ES.ts b/src/sales/email-templates/es-ES.ts index 224961342..57e3eafde 100644 --- a/src/sales/email-templates/es-ES.ts +++ b/src/sales/email-templates/es-ES.ts @@ -84,4 +84,11 @@ export const esES: EmailTemplatePack = { subject: 'Tarea urgente: {{subject}}', bodyHtml: '

Se te ha asignado una tarea urgente "{{subject}}" que requiere atención.

', }, + // Sent by `contract_drafted_notice` when accepting a quote drafts its + // contract (#2371 row 6). + 'crm.contract_drafted': { + label: 'Contrato redactado', + subject: 'Contrato redactado: {{contract_number}}', + bodyHtml: '

Al aceptar la cotización se redactó el contrato {{contract_number}}. Su plazo y su tipo de contrato son valores iniciales: revíselos antes de activarlo.

', + }, }; diff --git a/src/sales/email-templates/ja-JP.ts b/src/sales/email-templates/ja-JP.ts index cb0fc2f53..d4936724e 100644 --- a/src/sales/email-templates/ja-JP.ts +++ b/src/sales/email-templates/ja-JP.ts @@ -84,4 +84,11 @@ export const jaJP: EmailTemplatePack = { subject: '緊急の ToDo: {{subject}}', bodyHtml: '

緊急の ToDo「{{subject}}」が割り当てられました。対応をお願いします。

', }, + // Sent by `contract_drafted_notice` when accepting a quote drafts its + // contract (#2371 row 6). + 'crm.contract_drafted': { + label: '契約を起草しました', + subject: '契約を起草しました: {{contract_number}}', + bodyHtml: '

見積の承諾により契約 {{contract_number}} を起草しました。期間と契約種別は初期値です。有効化する前に確認してください。

', + }, }; diff --git a/src/sales/email-templates/zh-CN.ts b/src/sales/email-templates/zh-CN.ts index 6894ef036..239e4b705 100644 --- a/src/sales/email-templates/zh-CN.ts +++ b/src/sales/email-templates/zh-CN.ts @@ -84,4 +84,11 @@ export const zhCN: EmailTemplatePack = { subject: '紧急任务:{{subject}}', bodyHtml: '

一项紧急任务“{{subject}}”已分配给您,需要尽快处理。

', }, + // Sent by `contract_drafted_notice` when accepting a quote drafts its + // contract (#2371 row 6). + 'crm.contract_drafted': { + label: '合同已起草', + subject: '合同已起草:{{contract_number}}', + bodyHtml: '

接受报价单后已起草合同 {{contract_number}}。其中的期限和合同类型只是初始值,激活前请先核对。

', + }, }; diff --git a/src/sales/flows/contract-drafted-notice.flow.ts b/src/sales/flows/contract-drafted-notice.flow.ts new file mode 100644 index 000000000..48ef3ea28 --- /dev/null +++ b/src/sales/flows/contract-drafted-notice.flow.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +type Flow = Automation.Flow; + +/** + * The opening words of the provenance sentence `quote_accepted_contract_draft` + * writes on every contract it drafts (`src/revenue/objects/quote.hook.ts`): + * "Auto-drafted from accepted quote QTE-0006 - …", or "Auto-drafted from an + * accepted quote" when the quote has no label. `crm_contract` has no link to + * the quote, so this sentence is the only mark of a drafted contract. + */ +export const AUTO_DRAFTED_PREFIX = 'Auto-drafted from'; + +/** + * Contract drafted notice (#2371 row 6). + * + * Accepting a quote drafts its contract in an `async` hook, so the acceptance + * itself says nothing: measured on 18.2.0, the rep saw the quote's save toast + * and nothing about the contract, which they found only by looking. This flow + * fires on that contract's insert and tells the person who accepted the quote + * (the hook elevates but keeps the caller, so `created_by` names them) that a + * contract was drafted, with a link to it. An inbox notice is also the + * console's live toast, with a View link, on the page the rep is on. + * + * Inbox only: the acceptance is the rep's own act a moment ago, so an email + * would add a second notice and nothing new. + */ +export const ContractDraftedNoticeFlow: Flow = { + name: 'contract_drafted_notice', + label: 'Contract Drafted Notice', + description: 'When accepting a quote drafts its contract: tell the person who accepted it, with a link to the contract.', + type: 'record_change', + status: 'active', + // Elevated because the person it tells is not always one who may read the + // contract: a sales rep holds `crm_contract.allowCreate: false`, and the + // contract is the hook's elevated write, not theirs. The flow reads only the + // row that fired it and writes nothing but the notice; the person it tells + // is read off that row (`created_by`), never from the run's identity. + runAs: 'system', + variables: [], + nodes: [ + { + id: 'start', type: 'start', label: 'Start (contract drafted)', + config: { + objectName: 'crm_contract', + triggerType: 'record-after-create', + condition: P`has(record.description) && record.description != null + && record.description.startsWith(${AUTO_DRAFTED_PREFIX}) + && has(record.created_by) && record.created_by != null`, + }, + }, + { + id: 'notify_accepter', type: 'notify', label: 'Notify Who Accepted', + config: { + recipients: [cel`record.created_by`], + channels: ['inbox'], + severity: 'info', + topic: 'contract_drafted', + template: 'crm.contract_drafted', + templateData: { contract_number: cel`has(record.contract_number) ? record.contract_number : ""` }, + actionUrl: '/crm_contract/{{record.id}}', + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify_accepter', type: 'default' }, + { id: 'e2', source: 'notify_accepter', target: 'end', type: 'default' }, + ], +}; diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index 213063094..3c48c2e42 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -36,3 +36,4 @@ export { BillingHandoffClosedWonFlow } from './billing-handoff-closed-won.flow'; export { ContractRenewalFlow } from './contract-renewal.flow'; export { ContractExpirationFlow } from './contract-expiration.flow'; export { BillingHandoffContractActivatedFlow } from './billing-handoff-contract-activated.flow'; +export { ContractDraftedNoticeFlow } from './contract-drafted-notice.flow'; diff --git a/src/sales/flows/opportunity-approval.flow.ts b/src/sales/flows/opportunity-approval.flow.ts index 8a7609529..6d18c187a 100644 --- a/src/sales/flows/opportunity-approval.flow.ts +++ b/src/sales/flows/opportunity-approval.flow.ts @@ -36,6 +36,16 @@ type Flow = Automation.Flow; * handed back to the rep unlocked, and their next save sends it for approval * again (#2334). Until then `opportunity_lifecycle` refuses its close-won. */ +/** + * Whether the deal's owner is the user whose save fired the run, and so the + * submitter the platform tells the outcome (#2371 row 5). Each term is + * guarded, so the pair partitions every run. + */ +const OWNER_SUBMITTED = P`has(vars.oppRecord) && has(vars.oppRecord.owner_id) && vars.oppRecord.owner_id != null + && has(record.updated_by) && record.updated_by != null && record.updated_by == vars.oppRecord.owner_id`; +const OWNER_DID_NOT_SUBMIT = P`!has(vars.oppRecord) || !has(vars.oppRecord.owner_id) || vars.oppRecord.owner_id == null + || !has(record.updated_by) || record.updated_by == null || record.updated_by != vars.oppRecord.owner_id`; + export const OpportunityApprovalFlow: Flow = { name: 'opportunity_approval', label: 'Large Deal Approval', @@ -207,12 +217,23 @@ export const OpportunityApprovalFlow: Flow = { }, }, { + // ── One notice per decision (#2371 row 5) ── + // The platform already tells the SUBMITTER the outcome in the inbox + // ("Request approved — Your Opportunity … was approved.", with the + // approver's comment): `ApprovalService.notifyDecisionOutcome`, + // `@objectstack/plugin-approvals` 18.2.0, topic `approval.approved` / + // `approval.rejected`, inbox only. This node's inbox copy was the same + // news a second time for the usual submitter, the deal's own rep. So + // the owner's email stays here (the platform sends none), and the + // owner's inbox notice is sent only when the platform's does not reach + // them: when someone else's save sent the deal for approval + // (`check_owner_submitted_*` below). id: 'notify_approved', type: 'notify', - label: 'Notify Owner — Approved', + label: 'Email Owner — Approved', config: { recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], - channels: ['inbox', 'email'], + channels: ['email'], topic: 'opportunity_approved', template: 'crm.opportunity_approved', templateData: { name: cel`oppRecord.name` }, @@ -228,12 +249,52 @@ export const OpportunityApprovalFlow: Flow = { // changing no value. Measured on 18.0.0-next.3, pinned in // `test/approval-decision-attribution.test.ts`. { + // Email only, as `notify_approved` (#2371 row 5). id: 'notify_rejected', type: 'notify', + label: 'Email Owner — Rejected', + config: { + recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], + channels: ['email'], + severity: 'warning', + topic: 'opportunity_rejected', + template: 'crm.opportunity_rejected', + templateData: { name: cel`oppRecord.name` }, + actionUrl: '/crm_opportunity/{{record.id}}', + }, + }, + + // ── The owner's inbox notice, when the platform's misses them ── + // The platform's notice goes to the request's submitter, the user whose + // save fired this run (`runTriggerUser`). That save stamped + // `record.updated_by` (on an insert, `record.created_by` too), so an + // owner who is not that user — a manager raised the rep's amount, or a + // system write with no user started the run — hears it here instead. + // TOTALITY: an unreadable writer or owner reads as "not the owner", so + // the inbox notice is sent: a duplicate is the old behaviour, a missed + // notice is not. + { id: 'check_owner_submitted_approved', type: 'decision', label: 'Owner Submitted?' }, + { + id: 'notify_approved_inbox', + type: 'notify', + label: 'Notify Owner — Approved', + config: { + recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], + channels: ['inbox'], + topic: 'opportunity_approved', + template: 'crm.opportunity_approved', + templateData: { name: cel`oppRecord.name` }, + actionUrl: '/crm_opportunity/{{record.id}}', + }, + }, + { id: 'check_owner_submitted_rejected', type: 'decision', label: 'Owner Submitted?' }, + { + id: 'notify_rejected_inbox', + type: 'notify', label: 'Notify Owner — Rejected', config: { recipients: [cel`has(oppRecord.owner_id) ? oppRecord.owner_id : null`], - channels: ['inbox', 'email'], + channels: ['inbox'], severity: 'warning', topic: 'opportunity_rejected', template: 'crm.opportunity_rejected', @@ -306,8 +367,21 @@ export const OpportunityApprovalFlow: Flow = { // Terminal branches { id: 'e9', source: 'mark_approved', target: 'notify_approved', type: 'default' }, - { id: 'e10', source: 'notify_approved', target: 'end', type: 'default' }, - { id: 'e12', source: 'notify_rejected', target: 'end', type: 'default' }, + { id: 'e10', source: 'notify_approved', target: 'check_owner_submitted_approved', type: 'default' }, + { id: 'e12', source: 'notify_rejected', target: 'check_owner_submitted_rejected', type: 'default' }, + + // The owner's inbox notice (#2371 row 5): partitioned in opposite + // polarity, as `e5`/`e6` are. + { id: 'e13', source: 'check_owner_submitted_approved', target: 'end', type: 'conditional', + label: 'Owner submitted', condition: OWNER_SUBMITTED }, + { id: 'e14', source: 'check_owner_submitted_approved', target: 'notify_approved_inbox', type: 'conditional', + label: 'Someone else submitted', condition: OWNER_DID_NOT_SUBMIT }, + { id: 'e15', source: 'notify_approved_inbox', target: 'end', type: 'default' }, + { id: 'e16', source: 'check_owner_submitted_rejected', target: 'end', type: 'conditional', + label: 'Owner submitted', condition: OWNER_SUBMITTED }, + { id: 'e17', source: 'check_owner_submitted_rejected', target: 'notify_rejected_inbox', type: 'conditional', + label: 'Someone else submitted', condition: OWNER_DID_NOT_SUBMIT }, + { id: 'e18', source: 'notify_rejected_inbox', target: 'end', type: 'default' }, ], }; diff --git a/src/sales/objects/forecast.object.ts b/src/sales/objects/forecast.object.ts index 6bdf350ef..008b358c3 100644 --- a/src/sales/objects/forecast.object.ts +++ b/src/sales/objects/forecast.object.ts @@ -346,21 +346,31 @@ export const Forecast = ObjectSchema.create({ // that writes a JS `Date` object is the one shape `string()` has no overload // for; the engine then rejects that write naming the rule rather than // admitting it, and no writer in this app does that. + // + // ONE START, ONE MESSAGE (#2371 row 8). The two start rules PARTITION by + // `period`: this one judges every forecast that is not quarterly, the next + // one judges the quarterly ones, each in full. Before, this rule judged + // every row, so a quarter starting 2026-08-15 broke both and the form + // showed the two sentences run together ("…Aug 2026.; A quarterly + // forecast…" — the platform joins a write's refusals into one line), the + // first of them advice about MONTHS on a quarterly row. An absent or null + // `period` stays on this side, as it was judged before. { name: 'period_start_first_of_period', type: 'script', severity: 'error', message: 'Period Start must be the first day of the period — e.g. 2026-08-01 for Aug 2026.', - condition: P`has(record.period_start) && record.period_start != null && !matches(string(record.period_start), "^[0-9]{4}-[0-9]{2}-01")`, + condition: P`(!has(record.period) || record.period != "quarter") && has(record.period_start) && record.period_start != null && !matches(string(record.period_start), "^[0-9]{4}-[0-9]{2}-01")`, }, { name: 'quarter_starts_on_quarter_boundary', type: 'script', severity: 'error', - // The month rule above already rejects any non-1st day, so this one only - // ever adds the quarter-month half: 2026-08-01 is a valid month start and + // The quarterly half of the partition: any start that is not the first + // day of a quarter, a mid-month day included, since the month rule above + // no longer judges a quarterly row. 2026-08-01 is a valid month start and // not a valid quarter start. - message: 'A quarterly forecast must start on a quarter boundary — January 1, April 1, July 1 or October 1.', + message: "A quarterly forecast's Period Start must be on a quarter boundary — January 1, April 1, July 1 or October 1.", condition: P`has(record.period) && record.period == "quarter" && has(record.period_start) && record.period_start != null && !matches(string(record.period_start), "^[0-9]{4}-(01|04|07|10)-01")`, }, // …and the OTHER end of the same window. The two rules above pin the START; diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index 6d5c9581b..071708eb3 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -706,9 +706,15 @@ export const Opportunity = ObjectSchema.create({ // `→ proposal` is legal from every pre-proposal stage: the // quote_generation flow fast-forwards the deal to `proposal` when a // quote is generated, which can happen at any open stage. - prospecting: ['qualification', 'proposal', 'closed_lost'], - qualification: ['needs_analysis', 'proposal', 'closed_lost'], - needs_analysis: ['proposal', 'closed_lost'], + // + // `→ closed_won` is legal from every open stage too (#2371 row 3): a + // rep who wins a deal early used to get a "Saved — with warnings" + // toast on top of the save toast for a move the business accepts. + // `→ closed_lost` was already legal from every open stage; the + // warning still catches skipping forward within the open stages. + prospecting: ['qualification', 'proposal', 'closed_won', 'closed_lost'], + qualification: ['needs_analysis', 'proposal', 'closed_won', 'closed_lost'], + needs_analysis: ['proposal', 'closed_won', 'closed_lost'], // `proposal → closed_won` is legal: the quote_on_accepted hook closes // the linked deal directly from the CPQ path (quote_generation parks // the opportunity at `proposal`; an accepted quote wins it without a diff --git a/src/sales/pages/opportunity_detail.page.ts b/src/sales/pages/opportunity_detail.page.ts index 492537c52..82865cade 100644 --- a/src/sales/pages/opportunity_detail.page.ts +++ b/src/sales/pages/opportunity_detail.page.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec'; import { Page } from '@objectstack/spec/ui'; +import { HIGH_VALUE_DEAL_AMOUNT } from '../objects/_thresholds'; /** * Opportunity Detail Record Page @@ -101,6 +103,87 @@ export const OpportunityDetailPage: Page = { ], }, }, + // ── A deal waiting on approval says so (#2371 row 2) ── + // The save that sends a deal for approval (`opportunity_approval`, + // started by the amount reaching the large-deal line) shows only the + // ordinary save toast, and the page gains Recall / Reply buttons with + // no word on why. No hook or flow on 18.2.0 can add a message to that + // save's toast, so the page carries the message for as long as the + // request waits: who decides, that the deal is locked, and what + // Recall does. Two banners, because the approver differs with the + // amount (the tiers in `opportunity-approval.flow.ts`); the copy is + // inline locale maps for the reason `lead_detail.page.ts` gives. + // The `has()` guard and the equality are the shape that note pins: + // this renderer fails soft to SHOWN on an unevaluable predicate. + { + type: 'record:alert', + id: 'opp_approval_pending_manager', + label: 'Waiting on Sales Manager Approval', + properties: { + severity: 'info', + visible: P`has(record.approval_status) && record.approval_status == "pending" + && (!has(record.amount) || record.amount == null || record.amount <= ${HIGH_VALUE_DEAL_AMOUNT})`, + title: { + en: 'Sent for approval to a Sales Manager', + 'zh-CN': '已提交销售经理审批', + 'ja-JP': '営業マネージャーの承認待ちです', + 'es-ES': 'Enviada a aprobación de un Gerente de Ventas', + }, + body: { + en: + 'This deal reached the large-deal amount, so saving it sent it for approval. ' + + 'It is locked until a Sales Manager decides. Recall, above the title, ' + + 'withdraws the request and unlocks the deal; your next save sends it again.', + 'zh-CN': + '该商机金额达到大额门槛,保存时已自动提交审批。在销售经理做出决定前,商机处于锁定状态。' + + '点击标题上方的「撤回」可撤回审批请求并解锁商机;下一次保存会重新提交审批。', + 'ja-JP': + 'この商談は大型案件の金額に達したため、保存時に承認へ回されました。営業マネージャーが' + + '判断するまでロックされます。タイトル上の「取り下げ」で申請を取り下げてロックを解除できます。' + + '次に保存すると再び承認へ回されます。', + 'es-ES': + 'Esta oportunidad alcanzó el importe de gran operación, así que al guardarla se envió ' + + 'a aprobación. Queda bloqueada hasta que un Gerente de Ventas decida. Retirar, encima ' + + 'del título, retira la solicitud y la desbloquea; el siguiente guardado la vuelve a enviar.', + }, + }, + }, + { + type: 'record:alert', + id: 'opp_approval_pending_director', + label: 'Waiting on Sales Manager and Sales Director Approval', + properties: { + severity: 'info', + visible: P`has(record.approval_status) && record.approval_status == "pending" + && has(record.amount) && record.amount != null && record.amount > ${HIGH_VALUE_DEAL_AMOUNT}`, + title: { + en: 'Sent for approval to a Sales Manager, then a Sales Director', + 'zh-CN': '已提交审批:先由销售经理审批,再由销售总监审批', + 'ja-JP': '営業マネージャー、続いて営業ディレクターの承認待ちです', + 'es-ES': 'Enviada a aprobación de un Gerente de Ventas y después de un Director de Ventas', + }, + body: { + en: + 'This deal is over $500,000, so saving it sent it for two approvals: a Sales ' + + 'Manager first, then a Sales Director. It is locked until both decide. Recall, ' + + 'above the title, withdraws the request and unlocks the deal; your next save ' + + 'sends it again.', + 'zh-CN': + '该商机金额超过 $500,000,保存时已自动提交两级审批:先由销售经理审批,再由销售总监审批。' + + '两级都做出决定前,商机处于锁定状态。点击标题上方的「撤回」可撤回审批请求并解锁商机;' + + '下一次保存会重新提交审批。', + 'ja-JP': + 'この商談は $500,000 を超えるため、保存時に 2 段階の承認へ回されました。まず営業' + + 'マネージャー、次に営業ディレクターです。両方が判断するまでロックされます。タイトル上の' + + '「取り下げ」で申請を取り下げてロックを解除できます。次に保存すると再び承認へ回されます。', + 'es-ES': + 'Esta oportunidad supera los $500,000, así que al guardarla se envió a dos aprobaciones: ' + + 'primero un Gerente de Ventas y después un Director de Ventas. Queda bloqueada hasta ' + + 'que ambos decidan. Retirar, encima del título, retira la solicitud y la desbloquea; ' + + 'el siguiente guardado la vuelve a enviar.', + }, + }, + }, ], }, { diff --git a/src/sales/translations/en/app.ts b/src/sales/translations/en/app.ts index b63bb9e0f..514e61835 100644 --- a/src/sales/translations/en/app.ts +++ b/src/sales/translations/en/app.ts @@ -195,6 +195,8 @@ export const appSurface: Omit = { components: { opp_highlights: { label: 'Key Information' }, opp_stage_path: { label: 'Opportunity Stage Path' }, + opp_approval_pending_manager: { label: 'Waiting on Sales Manager Approval' }, + opp_approval_pending_director: { label: 'Waiting on Sales Manager and Sales Director Approval' }, }, }, sales_home_page: { diff --git a/src/sales/translations/en/objects.customer.ts b/src/sales/translations/en/objects.customer.ts index 09491eb90..71fa60b24 100644 --- a/src/sales/translations/en/objects.customer.ts +++ b/src/sales/translations/en/objects.customer.ts @@ -208,7 +208,7 @@ export const customer: Record = { }, add_contact_to_campaign: { label: 'Add to Campaign', - successMessage: 'Contacts added to campaign!', + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} added, {{ result.output.alreadyCount }} already a member.', params: { campaignId: { label: 'Campaign' }, }, diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 09a5b7025..43586a76a 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -180,7 +180,7 @@ export const pipeline: Record = { }, create_campaign: { label: 'Add to Campaign', - successMessage: 'Leads added to campaign!', + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} added, {{ result.output.alreadyCount }} already a member.', params: { campaignId: { label: 'Campaign' }, }, @@ -351,7 +351,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: 'Clone Opportunity', - successMessage: 'Opportunity cloned successfully!', + successMessage: 'Created "{{ result.name }}". You are now on the copy.', }, mass_update_stage: { label: 'Update Stage', diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 3d67d587f..029ce859c 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -554,6 +554,8 @@ export const appSurface: Omit = { components: { opp_highlights: { label: 'Información Clave' }, opp_stage_path: { label: 'Progreso de Etapa de la Oportunidad' }, + opp_approval_pending_manager: { label: 'Pendiente de aprobación del Gerente de Ventas' }, + opp_approval_pending_director: { label: 'Pendiente de aprobación del Gerente y del Director de Ventas' }, opp_details: { label: 'Detalles de la Oportunidad' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, // `pages.PAGE.components.TABS.items.VALUE.label`). @@ -681,11 +683,11 @@ export const appSurface: Omit = { }, campaign_enrollment: { label: 'Inscribir miembros en la campaña', - successMessage: 'Miembros elegibles inscritos en la campaña.', + successMessage: 'Inscripción en la campaña terminada.', refusals: { end_refused: { message: - 'Inscripción rechazada para {{refusedCount}} de las personas seleccionadas: {{enrolledCount}} inscritas, {{refusedCount}} rechazadas. Una persona rechazada no se añadió a la campaña; el registro de la ejecución del flujo indica el motivo de cada una.', + 'Inscripción rechazada para {{refusedCount}} de las personas seleccionadas: {{enrolledCount}} agregadas, {{alreadyCount}} ya eran miembros, {{skippedCount}} omitidas porque se dieron de baja del correo o no tienen dirección de correo, {{refusedCount}} rechazadas. Una persona rechazada no se añadió a la campaña; el registro de la ejecución del flujo indica el motivo de cada una.', }, refuse_not_open: { message: @@ -704,6 +706,11 @@ export const appSurface: Omit = { }, }, }, + result: { + title: 'Resultado de la inscripción', + description: '{{enrolledCount}} agregadas a la campaña, {{alreadyCount}} ya eran miembros, {{skippedCount}} omitidas porque se dieron de baja del correo o no tienen dirección de correo.', + fields: { campaignLink: { label: 'Campaña' } }, + }, }, }, escalate_case: { @@ -819,6 +826,13 @@ export const appSurface: Omit = { discount: { label: `Descuento % (≤ ${QUOTE_DISCOUNT_CEILING})`, placeholder: `0-${QUOTE_DISCOUNT_CEILING}` }, }, }, + result: { + title: 'Cotización creada', + description: 'La cotización «{{ quoteName }}» se creó como borrador. Ábrala para revisar el precio y continuar.', + fields: { + quoteLink: { label: 'Cotización' }, + }, + }, }, }, }, diff --git a/src/sales/translations/es-ES/objects.commerce.ts b/src/sales/translations/es-ES/objects.commerce.ts index 38a1f15bd..fa376ad19 100644 --- a/src/sales/translations/es-ES/objects.commerce.ts +++ b/src/sales/translations/es-ES/objects.commerce.ts @@ -27,7 +27,7 @@ export const commerce: Record = { message: 'La fecha de vencimiento debe ser posterior a la de la cotización', }, quote_status_progression: { - message: 'Transición de estado de cotización no válida', + message: 'Este cambio de estado se sale del recorrido habitual de la cotización: Borrador → En Revisión → Presentada → Aceptada. En Revisión también puede volver a Borrador o pasar a Rechazada, una Presentada puede pasar a Rechazada y una Rechazada vuelve a Borrador. Borrador, En Revisión o Presentada pueden pasar a Expirada. Aceptada y Expirada son definitivos.', }, }, label: 'Cotización', diff --git a/src/sales/translations/es-ES/objects.customer.ts b/src/sales/translations/es-ES/objects.customer.ts index f41195d3d..d3d931229 100644 --- a/src/sales/translations/es-ES/objects.customer.ts +++ b/src/sales/translations/es-ES/objects.customer.ts @@ -254,7 +254,7 @@ export const customer: Record = { }, add_contact_to_campaign: { label: 'Añadir a Campaña', - successMessage: '¡Contactos añadidos a la campaña!', + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} agregado(s), {{ result.output.alreadyCount }} ya era(n) miembro(s).', params: { campaignId: { label: 'Campaña' }, }, diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 537d7856b..5ecfd8445 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -273,7 +273,7 @@ export const pipeline: Record = { }, create_campaign: { label: 'Agregar a Campaña', - successMessage: '¡Prospecto agregado a la campaña!', + successMessage: '{{ result.output.campaignName }}: {{ result.output.addedCount }} agregado(s), {{ result.output.alreadyCount }} ya era(n) miembro(s).', params: { campaignId: { label: 'Campaña' }, }, @@ -475,7 +475,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: 'Clonar Oportunidad', - successMessage: '¡Oportunidad clonada con éxito!', + successMessage: 'Se creó «{{ result.name }}». Ahora está en la copia.', }, mass_update_stage: { label: 'Actualizar Etapa', @@ -536,7 +536,7 @@ export const pipeline: Record = { message: 'El inicio del periodo debe ser el primer día del periodo: p. ej. 2026-08-01 para agosto de 2026.', }, quarter_starts_on_quarter_boundary: { - message: 'Una previsión trimestral debe comenzar en un límite de trimestre: 1 de enero, 1 de abril, 1 de julio o 1 de octubre.', + message: 'En una previsión trimestral, el Inicio del periodo debe caer en un límite de trimestre: 1 de enero, 1 de abril, 1 de julio o 1 de octubre.', }, snapshot_amounts_non_negative: { message: 'Los importes de la instantánea no pueden ser negativos.', diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 65fe76547..9a06cf136 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -556,6 +556,8 @@ export const appSurface: Omit = { components: { opp_highlights: { label: '重要情報' }, opp_stage_path: { label: '商談ステージの進捗' }, + opp_approval_pending_manager: { label: '営業マネージャーの承認待ち' }, + opp_approval_pending_director: { label: '営業マネージャーと営業ディレクターの承認待ち' }, opp_details: { label: '商談詳細情報' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, // `pages.PAGE.components.TABS.items.VALUE.label`). @@ -683,11 +685,11 @@ export const appSurface: Omit = { }, campaign_enrollment: { label: 'キャンペーンにメンバーを登録', - successMessage: '対象メンバーをキャンペーンに登録しました。', + successMessage: 'キャンペーンへのメンバー登録が完了しました。', refusals: { end_refused: { message: - '選択された人のうち {{refusedCount}} 人の登録が拒否されました:登録 {{enrolledCount}} 人、拒否 {{refusedCount}} 人。拒否された人はキャンペーンに追加されていません。理由はフローの実行ログに一人ずつ記録されています。', + '選択された人のうち {{refusedCount}} 人の登録が拒否されました:新規追加 {{enrolledCount}} 人、既にメンバー {{alreadyCount}} 人、メール配信停止またはメールアドレスなしでスキップ {{skippedCount}} 人、拒否 {{refusedCount}} 人。拒否された人はキャンペーンに追加されていません。理由はフローの実行ログに一人ずつ記録されています。', }, refuse_not_open: { message: @@ -706,6 +708,11 @@ export const appSurface: Omit = { }, }, }, + result: { + title: '登録結果', + description: 'キャンペーンに新規追加 {{enrolledCount}} 人、既にメンバー {{alreadyCount}} 人、メール配信停止またはメールアドレスなしでスキップ {{skippedCount}} 人。', + fields: { campaignLink: { label: 'キャンペーン' } }, + }, }, }, escalate_case: { @@ -821,6 +828,13 @@ export const appSurface: Omit = { discount: { label: `割引率 % (≤ ${QUOTE_DISCOUNT_CEILING})`, placeholder: `0-${QUOTE_DISCOUNT_CEILING}` }, }, }, + result: { + title: '見積を作成しました', + description: '見積「{{ quoteName }}」を下書きとして作成しました。開いて価格を確認し、次の手続きへ進めてください。', + fields: { + quoteLink: { label: '見積' }, + }, + }, }, }, }, diff --git a/src/sales/translations/ja-JP/objects.commerce.ts b/src/sales/translations/ja-JP/objects.commerce.ts index 405724902..c1ae76f9b 100644 --- a/src/sales/translations/ja-JP/objects.commerce.ts +++ b/src/sales/translations/ja-JP/objects.commerce.ts @@ -27,7 +27,7 @@ export const commerce: Record = { message: '有効期限は見積日より後である必要があります', }, quote_status_progression: { - message: '無効な見積ステータス遷移です', + message: 'このステータス変更は見積の通常の流れから外れています:下書き → レビュー中 → 提示済み → 承認済み。レビュー中は下書きに戻すことも却下にすることもでき、提示済みは却下にでき、却下された見積は下書きに戻ります。下書き・レビュー中・提示済みは期限切れになることがあります。承認済みと期限切れは最終状態です。', }, }, label: '見積', diff --git a/src/sales/translations/ja-JP/objects.customer.ts b/src/sales/translations/ja-JP/objects.customer.ts index 2c424f42d..34388bf80 100644 --- a/src/sales/translations/ja-JP/objects.customer.ts +++ b/src/sales/translations/ja-JP/objects.customer.ts @@ -240,7 +240,7 @@ export const customer: Record = { }, add_contact_to_campaign: { label: 'キャンペーンに追加', - successMessage: '取引先責任者をキャンペーンに追加しました!', + successMessage: '{{ result.output.campaignName }}: 追加 {{ result.output.addedCount }} 件、既にメンバー {{ result.output.alreadyCount }} 件。', params: { campaignId: { label: 'キャンペーン' }, }, diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 4c7a0167b..6f4d1dbe0 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -273,7 +273,7 @@ export const pipeline: Record = { }, create_campaign: { label: 'キャンペーンに追加', - successMessage: 'キャンペーンに追加しました!', + successMessage: '{{ result.output.campaignName }}: 追加 {{ result.output.addedCount }} 件、既にメンバー {{ result.output.alreadyCount }} 件。', params: { campaignId: { label: 'キャンペーン' }, }, @@ -448,7 +448,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: '商談を複製', - successMessage: '商談を複製しました!', + successMessage: '「{{ result.name }}」を作成しました。表示中のページはその複製です。', }, mass_update_stage: { label: 'ステージ更新', @@ -504,7 +504,7 @@ export const pipeline: Record = { message: '期間開始はその期間の初日である必要があります。例:2026 年 8 月なら 2026-08-01。', }, quarter_starts_on_quarter_boundary: { - message: '四半期フォーキャストは四半期の境界(1 月 1 日、4 月 1 日、7 月 1 日、10 月 1 日)から開始する必要があります。', + message: '四半期フォーキャストの期間開始は四半期の境界(1 月 1 日、4 月 1 日、7 月 1 日、10 月 1 日)である必要があります。', }, snapshot_amounts_non_negative: { message: 'スナップショット金額は負の値にできません。', diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index b378d0a43..3449dc6ff 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -561,6 +561,8 @@ export const appSurface: Omit = { components: { opp_highlights: { label: '关键信息' }, opp_stage_path: { label: '商机阶段进度' }, + opp_approval_pending_manager: { label: '等待销售经理审批' }, + opp_approval_pending_director: { label: '等待销售经理和销售总监审批' }, opp_details: { label: '商机明细' }, // `page:tabs` item labels, keyed by each item's `value` (spec 18.1.0, // `pages.PAGE.components.TABS.items.VALUE.label`). @@ -688,11 +690,11 @@ export const appSurface: Omit = { }, campaign_enrollment: { label: '将成员加入营销活动', - successMessage: '符合条件的成员已加入营销活动。', + successMessage: '营销活动成员加入已完成。', refusals: { end_refused: { message: - '有 {{refusedCount}} 人被拒绝加入:已加入 {{enrolledCount}} 人,被拒绝 {{refusedCount}} 人。被拒绝的人没有加入营销活动;流程运行日志记录了每个人被拒绝的原因。', + '有 {{refusedCount}} 人被拒绝加入:新加入 {{enrolledCount}} 人,已是成员 {{alreadyCount}} 人,因退订邮件或没有邮箱地址而跳过 {{skippedCount}} 人,被拒绝 {{refusedCount}} 人。被拒绝的人没有加入营销活动;流程运行日志记录了每个人被拒绝的原因。', }, refuse_not_open: { message: @@ -711,6 +713,11 @@ export const appSurface: Omit = { }, }, }, + result: { + title: '加入结果', + description: '新加入营销活动 {{enrolledCount}} 人,已是成员 {{alreadyCount}} 人,因退订邮件或没有邮箱地址而跳过 {{skippedCount}} 人。', + fields: { campaignLink: { label: '营销活动' } }, + }, }, }, escalate_case: { @@ -826,6 +833,13 @@ export const appSurface: Omit = { discount: { label: `折扣 % (≤ ${QUOTE_DISCOUNT_CEILING})`, placeholder: `0-${QUOTE_DISCOUNT_CEILING}` }, }, }, + result: { + title: '报价单已创建', + description: '报价单「{{ quoteName }}」已作为草稿创建。打开它核对价格,再继续推进。', + fields: { + quoteLink: { label: '报价单' }, + }, + }, }, }, }, diff --git a/src/sales/translations/zh-CN/objects.commerce.ts b/src/sales/translations/zh-CN/objects.commerce.ts index 2d214e778..7ab65c26f 100644 --- a/src/sales/translations/zh-CN/objects.commerce.ts +++ b/src/sales/translations/zh-CN/objects.commerce.ts @@ -27,7 +27,7 @@ export const commerce: Record = { message: '失效日期必须晚于报价日期', }, quote_status_progression: { - message: '无效的报价单状态流转', + message: '这次状态变更不在报价单的常规路径上:草稿 → 审核中 → 已提交 → 已接受。审核中也可以退回草稿或变为已拒绝,已提交可以变为已拒绝,已拒绝的报价单回到草稿。草稿、审核中或已提交可以变为已过期。已接受和已过期是最终状态。', }, }, label: '报价单', diff --git a/src/sales/translations/zh-CN/objects.customer.ts b/src/sales/translations/zh-CN/objects.customer.ts index 449d555ed..68c827c5a 100644 --- a/src/sales/translations/zh-CN/objects.customer.ts +++ b/src/sales/translations/zh-CN/objects.customer.ts @@ -221,7 +221,7 @@ export const customer: Record = { }, add_contact_to_campaign: { label: '加入营销活动', - successMessage: '联系人已加入营销活动!', + successMessage: '{{ result.output.campaignName }}:新加入 {{ result.output.addedCount }} 人,已是成员 {{ result.output.alreadyCount }} 人。', params: { campaignId: { label: '营销活动' }, }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index f371dfebd..3ffb50add 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -266,7 +266,7 @@ export const pipeline: Record = { }, create_campaign: { label: '加入营销活动', - successMessage: '已将线索加入营销活动!', + successMessage: '{{ result.output.campaignName }}:新加入 {{ result.output.addedCount }} 人,已是成员 {{ result.output.alreadyCount }} 人。', params: { campaignId: { label: '营销活动' }, }, @@ -438,7 +438,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: '克隆商机', - successMessage: '商机克隆成功!', + successMessage: '已创建「{{ result.name }}」,当前页面就是这份副本。', }, generate_quote: { label: '生成报价单', @@ -498,7 +498,7 @@ export const pipeline: Record = { message: '周期开始必须是该周期的第一天——例如 2026 年 8 月对应 2026-08-01。', }, quarter_starts_on_quarter_boundary: { - message: '季度预测必须从季度边界开始——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。', + message: '季度预测的「周期起始」必须在季度边界上——1 月 1 日、4 月 1 日、7 月 1 日或 10 月 1 日。', }, snapshot_amounts_non_negative: { message: '快照金额不能为负数。', diff --git a/test/approval-one-notice.test.ts b/test/approval-one-notice.test.ts new file mode 100644 index 000000000..8e0263233 --- /dev/null +++ b/test/approval-one-notice.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { OpportunityApprovalFlow } from '../src/sales/flows/opportunity-approval.flow'; +import { hotcrmStack, signUpPerson, settleNotificationDeliveries, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * One notice per approval decision (#2371 row 5). + * + * Measured on 18.2.0 before this change, in the console: a rep whose own deal + * was approved got two inbox notices about it, the platform's "Request + * approved" (`approval.approved`, written by + * `ApprovalService.notifyDecisionOutcome` in `@objectstack/plugin-approvals` + * 18.2.0 to the request's submitter, inbox only) and this flow's "Deal + * approved" (`opportunity_approved`, inbox and email to the owner). + * + * The flow now emails the owner every time, since the platform sends no email, + * and adds its inbox notice only when the owner is not the submitter the + * platform tells: here, a deal another user raised over the threshold. + * Put `inbox` back on `notify_approved` / `notify_rejected` and the first two + * cases go red; drop `notify_*_inbox` and the third does. + */ + +/** + * The `topic` deliveries on `channel` to one user about one deal. The flow's + * notices link the deal; the platform's link the deal's approval request. + */ +const deliveries = async (s: VerifyStack, userId: string, channel: string, topic: string, recordId: string): Promise => { + const requests = (await s.rows('sys_approval_request', { record_id: recordId })).map((r) => String(r.id)); + return (await s.rows('sys_notification_delivery', { recipient_id: userId, channel, topic })).filter((d) => { + const url = String(d.payload?.actionUrl ?? ''); + return url === `/crm_opportunity/${recordId}` || requests.some((id) => url.endsWith(`request=${id}`)); + }); +}; + +describe('the approval flow notifies the owner once per decision (#2371 row 5)', () => { + let verify: VerifyStack; + let rep: Person; + let manager: Person; + let admin: Person; + let accountId = ''; + let k = 0; + + beforeAll(async () => { + verify = await hotcrmStack(); + const adminToken = await verify.signIn(); + admin = { token: adminToken, id: String((await verify.contextFor(adminToken)).userId) } as Person; + rep = await signUpPerson(verify, 'rep@approval-one-notice.test', { + name: 'One Notice Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + manager = await signUpPerson(verify, 'manager@approval-one-notice.test', { + name: 'One Notice Manager', positions: ['sales_manager'], permissionSets: ['sales_manager'], + }); + const [account] = await verify.seed('crm_account', [{ name: 'One Notice Co', owner_id: rep.id, approval_status: 'approved' }]); + accountId = String(account!.id); + }, 180_000); + + /** A rep's deal raised over the threshold by `by`: the request is `by`'s. */ + const raised = async (by: Person): Promise => { + const [deal] = await verify.seed('crm_opportunity', [{ + name: `One Notice Deal ${++k}`, stage: 'proposal', amount: 42_000, close_date: '2030-06-30', + crm_account: accountId, owner_id: rep.id, + }]); + const id = String(deal!.id); + await settleNotificationDeliveries(verify, async () => { + await verify.hooks.run('crm_opportunity', 'update', { id, amount: 150_000 }, { as: by.token }); + }); + return id; + }; + + const decide = async (id: string, verb: 'approve' | 'reject'): Promise => { + let open: Rec[] = []; + await vi.waitFor(async () => { + open = (await verify.rows('sys_approval_request', { record_id: id })).filter((r) => r.status === 'pending'); + expect(open, 'the deal has no single open approval request').toHaveLength(1); + }, { timeout: 15_000, interval: 100 }); + await settleNotificationDeliveries(verify, async () => { + const res = await verify.apiAs(manager.token, 'POST', `/approvals/requests/${open[0]!.id}/${verb}`, { comment: `${verb} by test.` }); + expect(res.status, await res.clone().text()).toBe(200); + }); + }; + + it('emails the owner on both outcomes, and notifies the inbox only from a decision node', () => { + const node = (id: string): Rec => OpportunityApprovalFlow.nodes.find((n) => n.id === id)! as Rec; + expect(node('notify_approved').config.channels).toEqual(['email']); + expect(node('notify_rejected').config.channels).toEqual(['email']); + expect(node('notify_approved_inbox').config.channels).toEqual(['inbox']); + expect(node('notify_rejected_inbox').config.channels).toEqual(['inbox']); + }); + + it.each([ + ['approve', 'approval.approved', 'opportunity_approved'], + ['reject', 'approval.rejected', 'opportunity_rejected'], + ] as const)('the rep raised their own deal, %s: one inbox notice (the platform\'s) and the email', async (verb, platformTopic, flowTopic) => { + const id = await raised(rep); + await decide(id, verb); + expect(await deliveries(verify, rep.id, 'inbox', platformTopic, id), 'the platform\'s notice').toHaveLength(1); + expect(await deliveries(verify, rep.id, 'inbox', flowTopic, id), 'the flow\'s second inbox notice').toEqual([]); + expect(await deliveries(verify, rep.id, 'email', flowTopic, id), 'the flow\'s email').toHaveLength(1); + }); + + it('someone else raised the rep\'s deal: the rep still gets the inbox notice, from the flow', async () => { + const id = await raised(admin); + await decide(id, 'approve'); + expect(await deliveries(verify, rep.id, 'inbox', 'approval.approved', id), 'the platform tells the submitter, not the rep').toEqual([]); + expect(await deliveries(verify, admin.id, 'inbox', 'approval.approved', id), 'the submitter\'s notice').toHaveLength(1); + expect(await deliveries(verify, rep.id, 'inbox', 'opportunity_approved', id), 'the owner\'s notice').toHaveLength(1); + expect(await deliveries(verify, rep.id, 'email', 'opportunity_approved', id)).toHaveLength(1); + }); +}); diff --git a/test/approval-recall-no-rejected-notice.test.ts b/test/approval-recall-no-rejected-notice.test.ts index 23975504f..4444297bc 100644 --- a/test/approval-recall-no-rejected-notice.test.ts +++ b/test/approval-recall-no-rejected-notice.test.ts @@ -62,6 +62,15 @@ const act = async (s: VerifyStack, id: string, who: Person, verb: Verb): Promise const noticesAbout = async (s: VerifyStack, owner: Person, topic: string, object: string, id: string): Promise => (await notificationsTo(s, owner.id, topic)).filter((n) => n.payload?.actionUrl === `/${object}/${id}`); +/** + * The `topic` emails the record's owner was sent about this one record. The + * deal flow's rejection reaches the rep's inbox from the platform since + * #2371 row 5, and from the flow by email only (`test/approval-one-notice.test.ts`). + */ +const emailsAbout = async (s: VerifyStack, owner: Person, topic: string, object: string, id: string): Promise => + (await s.rows('sys_notification_delivery', { recipient_id: owner.id, channel: 'email', topic })) + .filter((n) => n.payload?.actionUrl === `/${object}/${id}`); + /** The run behind the record, finished, and whether its `notify_rejected` node ran. */ const finishedRun = async (s: VerifyStack, flow: string, id: string): Promise<{ notifiedRejected: boolean }> => { const runs = await flowRuns(s, flow, id); @@ -103,7 +112,7 @@ describe('opportunity_approval: a recall ends the run without the rejected notic const id = await dealInApproval(120_000); await act(verify, id, who.rep, 'recall'); expect((await verify.rows('sys_approval_request', { record_id: id })).map((r) => r.status)).toEqual(['recalled']); - expect(await noticesAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toEqual([]); + expect(await emailsAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toEqual([]); expect(await finishedRun(verify, 'opportunity_approval', id)).toEqual({ notifiedRejected: false }); }); @@ -112,14 +121,14 @@ describe('opportunity_approval: a recall ends the run without the rejected notic await act(verify, id, who.manager, 'approve'); await act(verify, id, who.rep, 'recall'); expect((await verify.rows('sys_approval_request', { record_id: id })).map((r) => r.status).sort()).toEqual(['approved', 'recalled']); - expect(await noticesAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toEqual([]); + expect(await emailsAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toEqual([]); expect(await finishedRun(verify, 'opportunity_approval', id)).toEqual({ notifiedRejected: false }); }); it('a Sales Manager\'s rejection still notifies the owner, once', async () => { const id = await dealInApproval(120_000); await act(verify, id, who.manager, 'reject'); - expect(await noticesAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toHaveLength(1); + expect(await emailsAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toHaveLength(1); expect(await finishedRun(verify, 'opportunity_approval', id)).toEqual({ notifiedRejected: true }); }); @@ -127,7 +136,7 @@ describe('opportunity_approval: a recall ends the run without the rejected notic const id = await dealInApproval(600_000); await act(verify, id, who.manager, 'approve'); await act(verify, id, director, 'reject'); - expect(await noticesAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toHaveLength(1); + expect(await emailsAbout(verify, who.rep, 'opportunity_rejected', 'crm_opportunity', id)).toHaveLength(1); expect(await finishedRun(verify, 'opportunity_approval', id)).toEqual({ notifiedRejected: true }); }); }); diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index 9da3395f8..4746100d1 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -253,6 +253,7 @@ const ROW_LABEL: Record> = { }, contract_expiration: { 'zh-Hans': '合同自动到期', 'zh-Hant': '合約自動到期' }, quote_expiration: { 'zh-Hans': '报价自动过期', 'zh-Hant': '報價自動過期' }, + contract_drafted_notice: { 'zh-Hans': '合同起草通知', 'zh-Hant': '合約起草通知' }, campaign_completion: { 'zh-Hans': '营销活动自动完成', 'zh-Hant': '行銷活動自動完成' }, forecast_snapshot: { 'zh-Hans': '预测快照', 'zh-Hant': '預測快照' }, opportunity_stagnation: { 'zh-Hans': '停滞商机提醒', 'zh-Hant': '停滯商機提醒' }, diff --git a/test/campaign-enrollment-doors.test.ts b/test/campaign-enrollment-doors.test.ts index 0f93886cc..25259c4aa 100644 --- a/test/campaign-enrollment-doors.test.ts +++ b/test/campaign-enrollment-doors.test.ts @@ -30,7 +30,7 @@ type Rec = Record; * * Q2 letter a: when anyone was refused, Enroll Members ends with outcome * `refused` and a message giving both counts; a clean run ends `completed` - * with the flow's static `successMessage`. + * with the flow's static `successMessage`, after the result screen (#2371 row 1). * * Every door is driven the way its caller drives it, through * `@objectstack/verify` over the shipped app, and the stored rows are read @@ -122,11 +122,14 @@ async function enrollMembers(as: Person, campaignId: string, memberSource: 'lead return (await verify.flows.resume(run, { memberSource, leadStatus: 'new', contactDepartment: 'engineering' }, { as: as.token })) as Rec; } -/** The counts the refused ending reports, read off its message. */ -const countsIn = (message: unknown): { enrolled: number; refused: number } => { - const m = /: (\d+) enrolled, (\d+) refused\./.exec(String(message)); +/** + * The counts the refused ending reports, read off its message. Since #2371 + * row 1 it names all four: added, already members, skipped, refused. + */ +const countsIn = (message: unknown): { enrolled: number; already: number; skipped: number; refused: number } => { + const m = /: (\d+) added, (\d+) already members, (\d+) skipped because they opted out of email or have no email address, (\d+) refused\./.exec(String(message)); expect(m, `no counts in the refusal message: ${String(message)}`).toBeTruthy(); - return { enrolled: Number(m![1]), refused: Number(m![2]) }; + return { enrolled: Number(m![1]), already: Number(m![2]), skipped: Number(m![3]), refused: Number(m![4]) }; }; describe('a profile without allowCreate is refused at every enrollment door (#2167)', () => { @@ -181,7 +184,10 @@ describe('a profile with allowCreate enrolls through every door, as itself (#216 it.each(ALLOWED)('%s: Add to Campaign on a lead enrolls it, and a second run skips it', async (persona) => { const w = await world(who[persona]!); const first = await addLead(who[persona]!, w.leadA.id, w.campaign.id) as Rec; - expect(first).toMatchObject({ success: true, output: { enrolled: true } }); + // The success toast's counts (#2371 row 1): one added, none already on it. + expect(first).toMatchObject({ + success: true, output: { enrolled: true, addedCount: 1, alreadyCount: 0, campaignName: w.campaign.name }, + }); const rows = await membersOf(w.campaign.id); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ crm_lead: w.leadA.id, status: 'sent' }); @@ -191,32 +197,47 @@ describe('a profile with allowCreate enrolls through every door, as itself (#216 expect(rows[0]!.created_by).toBe(who[persona]!.id); const again = await addLead(who[persona]!, w.leadA.id, w.campaign.id) as Rec; - expect(again).toMatchObject({ success: true, output: { enrolled: false } }); + // A re-run says so instead of "added" (#2371 row 1). + expect(again).toMatchObject({ + success: true, output: { enrolled: false, addedCount: 0, alreadyCount: 1, campaignName: w.campaign.name }, + }); expect(await membersOf(w.campaign.id), 'a re-run double-enrolled the lead').toHaveLength(1); }); it.each(ALLOWED)('%s: Add to Campaign on a contact enrolls it with no lead, and a second run skips it', async (persona) => { const w = await world(who[persona]!); const first = await addContact(who[persona]!, w.contact.id, w.campaign.id) as Rec; - expect(first).toMatchObject({ success: true, output: { enrolled: true } }); + expect(first).toMatchObject({ + success: true, output: { enrolled: true, addedCount: 1, alreadyCount: 0, campaignName: w.campaign.name }, + }); const rows = await membersOf(w.campaign.id); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ crm_contact: w.contact.id, status: 'sent' }); expect(rows[0]!.crm_lead ?? null, 'a contact member must not also claim a lead').toBeNull(); const again = await addContact(who[persona]!, w.contact.id, w.campaign.id) as Rec; - expect(again).toMatchObject({ success: true, output: { enrolled: false } }); + expect(again).toMatchObject({ + success: true, output: { enrolled: false, addedCount: 0, alreadyCount: 1, campaignName: w.campaign.name }, + }); expect(await membersOf(w.campaign.id)).toHaveLength(1); }); - it.each(ALLOWED)('%s: a clean Enroll Members run completes with the static success message', async (persona) => { + it.each(ALLOWED)('%s: a clean Enroll Members run ends on its result screen, then completes', async (persona) => { const w = await world(who[persona]!); - const result = await enrollMembers(who[persona]!, w.campaign.id); + const paused = await enrollMembers(who[persona]!, w.campaign.id); + // The counts are on the result screen (#2371 row 1), with a link back to + // the campaign; Submit completes the run with a neutral sentence. + expect(paused.status, 'a clean run did not stop on its result screen').toBe('paused'); + const screen = (paused.screen ?? paused.output?.screen) as Rec; + expect(screen?.nodeId).toBe('result'); + expect(String(screen.description)).toMatch(/^\d+ added to the campaign, \d+ already members, \d+ skipped because they opted out of email or have no email address\.$/); + expect((screen.fields as Rec[]).find((f) => f.name === 'campaignLink')?.defaultValue).toBe(w.campaign.id); + const result = (await verify.flows.resume(paused as never, {}, { as: who[persona]!.token })) as Rec; expect(result.success).toBe(true); expect(result.status, 'a clean run is a terminal success, not a refusal').not.toBe('refused'); expect(result.refusalMessage).toBeUndefined(); expect(result.successMessage).toBe(CampaignEnrollmentFlow.successMessage); - expect(result.successMessage).toBe('Eligible members enrolled in campaign.'); + expect(result.successMessage).toBe('Campaign enrollment finished.'); const enrolled = (await membersOf(w.campaign.id)).map((m) => m.crm_lead); expect(enrolled).toEqual(expect.arrayContaining([w.leadA.id, w.leadB.id])); }, ENROLL_BUDGET_MS); @@ -289,9 +310,11 @@ describe('a partly refused Enroll Members run reports both counts (#2167, Q2 let expect(result!).toMatchObject({ success: true, status: 'refused' }); const rows = await membersOf(w.campaign.id); - expect(countsIn(result!.refusalMessage)).toEqual({ enrolled: rows.length, refused: 1 }); + const counts = countsIn(result!.refusalMessage); + expect({ enrolled: counts.enrolled, already: counts.already, refused: counts.refused }).toEqual({ enrolled: rows.length, already: 0, refused: 1 }); expect(result!.refusalMessage).toBe( - `Enrollment refused for 1 of the people selected: ${rows.length} enrolled, 1 refused. ` + `Enrollment refused for 1 of the people selected: ${rows.length} added, 0 already members, ` + + `${counts.skipped} skipped because they opted out of email or have no email address, 1 refused. ` + 'A refused person was not added to the campaign; the flow run log records why for each one.', ); const leads = rows.map((m) => m.crm_lead); diff --git a/test/campaign-enrollment-gates.test.ts b/test/campaign-enrollment-gates.test.ts index 2823649fe..9b68fa1ed 100644 --- a/test/campaign-enrollment-gates.test.ts +++ b/test/campaign-enrollment-gates.test.ts @@ -144,15 +144,28 @@ describe('Add to Campaign refuses a person who opted out of email (#2336 D1)', ( * removed: the run ended `refused`, the opted-out leads counted as refused). */ describe('Enroll Members counts an opted-out person as skipped, not refused (#2336)', () => { - it('a segment holding an opted-out lead ends clean, with the static success message', async () => { + it('a segment holding an opted-out lead ends clean, and the result screen counts the skip', async () => { const campaign = await campaignOf('in_progress'); const status = 'contacted'; const optedIn = await leadOf({ status }); const optedOut = await leadOf({ status, email_opt_out: true }); const run = await verify.flows.run('campaign_enrollment', { recordId: campaign.id }, { as: marketer.token }); - const result = await verify.flows.resume(run, { memberSource: 'leads', leadStatus: status, contactDepartment: 'engineering' }, { as: marketer.token }) as Rec; - expect(result.status, 'a skipped person ended the run refused').not.toBe('refused'); - expect(result.successMessage).toBe('Eligible members enrolled in campaign.'); + const paused = await verify.flows.resume(run, { memberSource: 'leads', leadStatus: status, contactDepartment: 'engineering' }, { as: marketer.token }) as Rec; + expect(paused.status, 'a skipped person ended the run refused').not.toBe('refused'); + // The result screen names the three counts (#2371 row 1). Every lead in + // the segment is either added, already a member or skipped: the counts + // add up to the segment, and the opted-out lead is among the skipped. + const screen = (paused.screen ?? paused.output?.screen) as Rec; + expect(screen?.nodeId, 'the run did not stop on its result screen').toBe('result'); + const m = /^(\d+) added to the campaign, (\d+) already members, (\d+) skipped/.exec(String(screen.description)); + expect(m, `no counts on the result screen: ${String(screen.description)}`).toBeTruthy(); + const [added, already, skipped] = [Number(m![1]), Number(m![2]), Number(m![3])]; + const segment = (await verify.rows('crm_lead', { status })).filter((l) => !l.is_converted); + expect(added + already + skipped, 'the counts do not add up to the segment').toBe(segment.length); + expect(skipped, 'the opted-out lead was not counted as skipped').toBeGreaterThanOrEqual(1); + expect(added).toBe((await membersOf(campaign.id)).length); + const result = await verify.flows.resume(paused as never, {}, { as: marketer.token }) as Rec; + expect(result.successMessage).toBe('Campaign enrollment finished.'); const enrolled = (await membersOf(campaign.id)).map((m) => m.crm_lead); expect(enrolled).toContain(optedIn.id); expect(enrolled).not.toContain(optedOut.id); diff --git a/test/campaign-enrollment-result-copy.test.ts b/test/campaign-enrollment-result-copy.test.ts new file mode 100644 index 000000000..f65374a4b --- /dev/null +++ b/test/campaign-enrollment-result-copy.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { templateEngine } from '@objectstack/formula'; +import { CampaignEnrollmentFlow } from '../src/marketing/flows/campaign-enrollment.flow'; +import { CreateCampaignAction } from '../src/sales/actions/lead.actions'; +import { AddContactToCampaignAction } from '../src/sales/actions/contact.actions'; +import { localePacks } from './helpers/composed-stack'; + +type Rec = Record; + +/** + * The enrollment results name their counts in every locale (#2371 row 1). + * + * Enroll Members ends on a result screen naming three counts (added, already + * members, skipped), and its refusal names those three and the refused; Add to + * Campaign's toast names the campaign and two counts (added, already a member). + * The runtime cases are in `test/campaign-enrollment-doors.test.ts` and + * `test/campaign-enrollment-gates.test.ts`. This file holds every locale to the + * same counts, through the platform's own renderer: a pack that dropped a + * count would win over the source and hide it again. + */ + +const render = (source: string, vars: Rec): string => { + const r = templateEngine.evaluate({ dialect: 'template', source }, { extra: vars } as Rec); + if (!r.ok) throw new Error(`does not render: ${r.error.message}`); + return String(r.value); +}; + +const nonEnglish = localePacks.filter(([locale]) => locale !== 'en'); +const COUNTS = { enrolledCount: 7, alreadyCount: 5, skippedCount: 3, refusedCount: 2 }; + +describe('Enroll Members names its counts in every locale (#2371 row 1)', () => { + const result = CampaignEnrollmentFlow.nodes.find((n) => n.id === 'result') as Rec; + const refused = CampaignEnrollmentFlow.nodes.find((n) => n.id === 'end_refused') as Rec; + const screens: Array<[string, string]> = [ + ['en (authored)', String(result.config.description)], + ...nonEnglish.map(([l, p]) => [l, String(p.flows?.campaign_enrollment?.screens?.result?.description ?? '')] as [string, string]), + ]; + const refusals: Array<[string, string]> = [ + ['en (authored)', String(refused.config.message)], + ...nonEnglish.map(([l, p]) => [l, String(p.flows?.campaign_enrollment?.refusals?.end_refused?.message ?? '')] as [string, string]), + ]; + + it.each(screens)('%s: the result screen says added, already members and skipped', (_l, source) => { + const text = render(source, COUNTS); + for (const n of [7, 5, 3]) expect(text, source).toContain(String(n)); + }); + + it.each(refusals)('%s: the refusal says added, already members, skipped and refused', (_l, source) => { + const text = render(source, COUNTS); + for (const n of [7, 5, 3, 2]) expect(text, source).toContain(String(n)); + }); + + it.each(nonEnglish)('%s: the result screen has a title and a link label', (_l, pack) => { + const screen = pack.flows?.campaign_enrollment?.screens?.result; + expect(String(screen?.title ?? '').length).toBeGreaterThan(0); + expect(String(screen?.fields?.campaignLink?.label ?? '').length).toBeGreaterThan(0); + }); +}); + +describe('Add to Campaign names the campaign and its counts in every locale (#2371 row 1)', () => { + const sources: Array<[string, string]> = [ + ['lead, authored', String(CreateCampaignAction.successMessage)], + ['contact, authored', String(AddContactToCampaignAction.successMessage)], + ...localePacks.flatMap(([l, p]) => [ + [`${l} lead`, String(p.o?.crm_lead?._actions?.create_campaign?.successMessage ?? p.objects?.crm_lead?._actions?.create_campaign?.successMessage ?? '')], + [`${l} contact`, String(p.o?.crm_contact?._actions?.add_contact_to_campaign?.successMessage ?? p.objects?.crm_contact?._actions?.add_contact_to_campaign?.successMessage ?? '')], + ] as Array<[string, string]>), + ]; + + it.each(sources)('%s', (_l, source) => { + const text = render(source, { result: { output: { campaignName: 'Spring Launch', addedCount: 0, alreadyCount: 1 } } }); + expect(text, source).toContain('Spring Launch'); + expect(text, source).toMatch(/0[^0-9]/); + expect(text, source).toMatch(/1[^0-9]/); + expect(source, 'still says the people were added whatever happened').not.toMatch(/added to campaign!|加入营销活动!/); + }); +}); diff --git a/test/clone-opportunity-opens-clone.test.ts b/test/clone-opportunity-opens-clone.test.ts index 425a0ae3b..913fbb3cf 100644 --- a/test/clone-opportunity-opens-clone.test.ts +++ b/test/clone-opportunity-opens-clone.test.ts @@ -6,6 +6,10 @@ import { ActionSchema } from '@objectstack/spec/ui'; import stack from './helpers/composed-stack'; import { CloneOpportunityAction } from '../src/sales/actions/opportunity.actions'; import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; +import { pipeline as en } from '../src/sales/translations/en/objects.pipeline'; +import { pipeline as zh } from '../src/sales/translations/zh-CN/objects.pipeline'; +import { pipeline as es } from '../src/sales/translations/es-ES/objects.pipeline'; +import { pipeline as ja } from '../src/sales/translations/ja-JP/objects.pipeline'; type Rec = Record; @@ -81,8 +85,32 @@ describe('the result the template reads names the new opportunity', () => { expect(result.id).not.toBe(source!.id); const [copy] = await verify.rows('crm_opportunity', { id: result.id }); expect(copy?.name).toBe('Copy of Harbor Fit-out'); + // `{{ result.name }}` in the success toast reads this (#2371 row 4). + expect(result.name, 'no name for {{ result.name }} to render').toBe(copy?.name); expect(copy?.crm_account).toBe(account!.id); expect(copy?.amount).toBe(31_000); expect(copy?.stage).toBe('prospecting'); }); }); + +/** + * The toast names the copy (#2371 row 4). The runner navigates to the clone + * straight after the toast, and the clone's page looks like the source but + * for the "Copy of" prefix and the stage, so "Opportunity cloned + * successfully!" left a rep unsure which deal they were on. Every locale's + * message renders `{{ result.name }}`; a pack without it would win over the + * source and drop the name again. + */ +describe('the clone toast names the copy (#2371 row 4)', () => { + const packs: Array<[string, Rec]> = [['en', en], ['zh-CN', zh], ['es-ES', es], ['ja-JP', ja]]; + + it('declares a success message that renders the copy\'s name', () => { + expect(CloneOpportunityAction.successMessage).toContain('{{ result.name }}'); + expect(CloneOpportunityAction.successMessage).not.toMatch(/cloned successfully/i); + }); + + it.each(packs)('%s pack renders the copy\'s name too', (_locale, pack) => { + const msg = (pack as Rec).crm_opportunity?._actions?.clone_opportunity?.successMessage; + expect(msg).toContain('{{ result.name }}'); + }); +}); diff --git a/test/contract-drafted-notice.test.ts b/test/contract-drafted-notice.test.ts new file mode 100644 index 000000000..8bd7d3cc1 --- /dev/null +++ b/test/contract-drafted-notice.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { VerifyStack } from '@objectstack/verify'; +import { AUTO_DRAFTED_PREFIX, ContractDraftedNoticeFlow } from '../src/sales/flows/contract-drafted-notice.flow'; +import { REPO_ROOT } from './helpers/repo-root'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * Accepting a quote says that it drafted a contract, with a link (#2371 row 6). + * + * Measured on 18.2.0 before this change: `quote_accepted_contract_draft` is an + * `async` hook, so the acceptance showed only the quote's own save toast and + * the drafted contract was found only by looking. `contract_drafted_notice` + * now fires on that contract's insert and sends the person who accepted the + * quote an inbox notice linking it, which the console also shows as a live + * toast. Delete the flow, or let its trigger miss the hook's provenance + * sentence, and this goes red. + */ + +const today = (): string => new Date().toISOString().slice(0, 10); + +let verify: VerifyStack; +let rep: Person; +beforeAll(async () => { + verify = await hotcrmStack(); + rep = await signUpPerson(verify, 'rep@contract-drafted-notice.test', { + name: 'Drafted Notice Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); +}, 120_000); + +describe('the trigger reads the sentence the hook writes', () => { + it('both provenance sentences of quote_accepted_contract_draft start with the prefix', () => { + const hook = readFileSync(join(REPO_ROOT, 'src/revenue/objects/quote.hook.ts'), 'utf8'); + expect(hook).toContain(`\`${AUTO_DRAFTED_PREFIX} accepted quote \${quoteLabel}\``); + expect(hook).toContain(`'${AUTO_DRAFTED_PREFIX} an accepted quote'`); + }); +}); + +describe('the rep who accepts a quote is told its contract was drafted (#2371 row 6)', () => { + it('one inbox notice, linking the drafted contract by its number', async () => { + // The flow under test, by the name the runtime-coverage guard reads. + expect(ContractDraftedNoticeFlow.name).toBe('contract_drafted_notice'); + const create = async (object: string, doc: Rec) => + String((await verify.hooks.run(object, 'insert', doc, { as: rep.token })).id); + const account = await create('crm_account', { name: 'Drafted Notice Co' }); + const contact = await create('crm_contact', { + first_name: 'Dana', last_name: 'Drafted', email: 'dana@drafted-notice.test', crm_account: account, + }); + const opportunity = await create('crm_opportunity', { + name: 'Drafted Notice deal', crm_account: account, stage: 'proposal', amount: 1_000, close_date: '2030-06-30', + }); + const id = await create('crm_quote', { + name: 'Drafted Notice quote', crm_account: account, crm_contact: contact, crm_opportunity: opportunity, + quote_date: today(), expiration_date: '2030-12-31', + }); + for (const status of ['in_review', 'presented', 'accepted']) { + await verify.hooks.run('crm_quote', 'update', { id, status }, { as: rep.token }); + } + + const notices = await vi.waitFor(async () => { + const rows = await verify.rows('sys_notification_delivery', { recipient_id: rep.id, topic: 'contract_drafted' }); + expect(rows, 'no contract_drafted notice reached the rep').toHaveLength(1); + return rows; + }, { timeout: 15_000, interval: 100 }); + const [contract] = await verify.rows('crm_contract', { crm_opportunity: opportunity }); + expect(contract, 'no contract was drafted').toBeTruthy(); + expect(notices[0]!.channel).toBe('inbox'); + expect(notices[0]!.payload?.actionUrl).toBe(`/crm_contract/${contract!.id}`); + expect(notices[0]!.payload?.templateData?.contract_number).toBe(contract!.contract_number); + expect(String(contract!.contract_number).length).toBeGreaterThan(0); + }); + + it('a contract written by hand sends no notice', async () => { + const before = (await verify.rows('sys_notification_delivery', { topic: 'contract_drafted' })).length; + const [account] = await verify.seed('crm_account', [{ name: 'Hand Contract Co', owner_id: rep.id }]); + const [contact] = await verify.seed('crm_contact', [{ first_name: 'Hal', last_name: 'Hand', email: 'hal@drafted-notice.test', crm_account: account!.id, owner_id: rep.id }]); + const admin = await verify.signIn(); + await verify.hooks.run('crm_contract', 'insert', { + crm_account: account!.id, crm_contact: contact!.id, status: 'draft', start_date: '2030-01-01', end_date: '2031-01-01', + contract_term_months: 12, contract_type: 'service', contract_value: 5_000, description: 'Signed on paper.', + }, { as: admin }); + await new Promise((r) => setTimeout(r, 500)); + expect((await verify.rows('sys_notification_delivery', { topic: 'contract_drafted' })).length).toBe(before); + }); +}); diff --git a/test/docs-pipeline-kanban-section.test.ts b/test/docs-pipeline-kanban-section.test.ts index 44724e94a..a382e9ba1 100644 --- a/test/docs-pipeline-kanban-section.test.ts +++ b/test/docs-pipeline-kanban-section.test.ts @@ -284,9 +284,11 @@ describe('the source facts those three claims now rest on (#996)', () => { expect(STAGE_MACHINE.field).toBe('stage'); }); - it('leaves Prospecting → Closed Won outside the table, which is why it is the example', () => { + it('leaves Prospecting → Negotiation outside the table, which is why it is the example', () => { + // Prospecting → Closed Won was the example until #2371 row 3 put the early + // win on the path; a forward skip within the open stages still warns. expect((STAGE_MACHINE.transitions as Record).prospecting).not.toContain( - 'closed_won', + 'negotiation', ); }); }); diff --git a/test/docs-readme-token-figures.test.ts b/test/docs-readme-token-figures.test.ts index 27f025062..94b4ca586 100644 --- a/test/docs-readme-token-figures.test.ts +++ b/test/docs-readme-token-figures.test.ts @@ -64,8 +64,8 @@ import { REPO_ROOT } from './helpers/repo-root'; * a size the app does not have — the ratchet owns the growth budget, alone, and * a raise there sits on the maintainer floor where it always did. * - * business semantics banner ~64k -> band 60,800–67,200 · ceiling 88,500 - * interaction layer banner ~29k -> band 27,550–30,450 · ceiling 46,500 + * business semantics banner ~68k -> band 64,600–71,400 · ceiling 88,500 + * interaction layer banner ~31k -> band 29,450–32,550 · ceiling 46,500 * * ⚠️ The ceiling column is still worked out per row, and still pinned to * `CEILINGS` below — that is what stops the README quoting a ceiling the gate no diff --git a/test/flow-campaign-enrollment.test.ts b/test/flow-campaign-enrollment.test.ts index be364802c..649eac648 100644 --- a/test/flow-campaign-enrollment.test.ts +++ b/test/flow-campaign-enrollment.test.ts @@ -307,13 +307,18 @@ describe('campaign_enrollment — a campaign that is not open, or no longer exis async function ending(recordId: string, screen: Rec) { const run = await verify.flows.run('campaign_enrollment', { recordId }, { as: marketer.token }); expect(run.status, 'campaign_enrollment did not pause on its screen').toBe('paused'); - const result = (await verify.flows.resume(run, screen, { as: marketer.token })) as Rec; + let result = (await verify.flows.resume(run, screen, { as: marketer.token })) as Rec; + // A clean run pauses on its result screen (#2371 row 1); Submit on it + // completes the run, which is the ending read below. + const resultScreen = result.status === 'paused' ? (result.screen ?? result.output?.screen)?.nodeId : undefined; + if (resultScreen) result = (await verify.flows.resume(result as never, {}, { as: marketer.token })) as Rec; const ran = new Set(((result.summary?.nodes ?? []) as Rec[]).filter((n) => n.runs > 0).map((n) => String(n.nodeId))); return { success: result.success, // Set on a refusal only: the clean ending answers `success` and the // flow's `successMessage`, with no `status`. status: result.status, + resultScreen, refusalMessage: result.refusalMessage, successMessage: result.successMessage, ran: { query_leads: ran.has('query_leads'), query_contacts: ran.has('query_contacts') }, @@ -322,7 +327,7 @@ describe('campaign_enrollment — a campaign that is not open, or no longer exis } const REFUSED = { - success: true, status: 'refused', successMessage: undefined, + success: true, status: 'refused', resultScreen: undefined, successMessage: undefined, ran: { query_leads: false, query_contacts: false }, members: 0, }; @@ -353,7 +358,7 @@ describe('campaign_enrollment — a campaign that is not open, or no longer exis const w = await world({ status }); const out = await ending(w.campaign.id, { ...SCREEN, memberSource }); expect({ ...out, members: out.members > 0 }).toEqual({ - success: true, status: undefined, refusalMessage: undefined, + success: true, status: undefined, resultScreen: 'result', refusalMessage: undefined, successMessage: CampaignEnrollmentFlow.successMessage, ran, members: true, }); }, WORLD_BUDGET_MS); @@ -382,7 +387,8 @@ describe('campaign_enrollment — `check_refused` takes its default edge on a cl .map((e) => ({ id: e.id, target: e.target, isDefault: e.isDefault === true, conditional: e.condition !== undefined })); expect(out).toEqual([ { id: 'e10', target: 'end_refused', isDefault: false, conditional: true }, - { id: 'e11', target: 'end', isDefault: true, conditional: false }, + // The clean side ends on the result screen since #2371 row 1. + { id: 'e11', target: 'result', isDefault: true, conditional: false }, ]); }); }); diff --git a/test/flow-run-summary.test.ts b/test/flow-run-summary.test.ts index cd5625ff5..da4fca1d8 100644 --- a/test/flow-run-summary.test.ts +++ b/test/flow-run-summary.test.ts @@ -161,9 +161,14 @@ describe('flow run summary — the three flows the silent-no-op incident covered expect(paused.status, 'the screen flow did not pause at its screen').toBe('paused'); expect((paused as Rec).summary, 'a paused run must not claim a summary — it has not finished').toBeUndefined(); - const s = summaryOf(await verify.flows.resume(paused, { + // The criteria screen, then the result screen (#2371 row 1): the run is + // terminal, with its summary, only once the result screen is submitted. + const onResult = await verify.flows.resume(paused, { memberSource: 'leads', leadStatus: 'new', contactDepartment: 'engineering', - }, { as: admin })); + }, { as: admin }); + expect(onResult.status, 'the run did not pause on its result screen').toBe('paused'); + expect((onResult as Rec).summary, 'a run paused on its result screen must not claim a summary').toBeUndefined(); + const s = summaryOf(await verify.flows.resume(onResult, {}, { as: admin })); const members = await verify.rows('crm_campaign_member', { crm_campaign: campaign!.id }); expect(members.length, 'the enrolment wrote no members').toBeGreaterThanOrEqual(2); expect(s.acted, 'the summary does not count the members the enrolment wrote').toBe(members.length); diff --git a/test/forecast-period-boundary.test.ts b/test/forecast-period-boundary.test.ts index 329545bf6..4d4cdc6b6 100644 --- a/test/forecast-period-boundary.test.ts +++ b/test/forecast-period-boundary.test.ts @@ -323,7 +323,9 @@ describe('the write is REFUSED on a real SQLite database too', () => { viaHook(desk, 'insert', { ...BASE, period: 'quarter', period_start: '2026-08-15' }), ); expect(env.code).toBe('VALIDATION_FAILED'); - expect(env.message).toMatch(/first day of the period/i); + // A quarterly row is judged by the quarter rule alone (#2371 row 8). + expect(env.message).toMatch(/quarter boundary/i); + expect(env.message).not.toMatch(/first day of the period/i); const row = await viaHook(desk, 'insert', { ...BASE, period: 'quarter', period_start: '2026-07-01' }); const stored = await storedForecast(desk, row.id); diff --git a/test/forecast-period-end-boundary.test.ts b/test/forecast-period-end-boundary.test.ts index 237a9e4d3..5afda298c 100644 --- a/test/forecast-period-end-boundary.test.ts +++ b/test/forecast-period-end-boundary.test.ts @@ -466,7 +466,10 @@ describe('the window rule stands down while a start rule fires (#2060 item 7c)', 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] }, + // One start, one message (#2371 row 8): the start rules partition by + // `period`, so a quarterly row is told about QUARTERS only. It drew both + // sentences, run together by the platform's "; " join, before. + { period: 'quarter', period_start: '2026-10-15', why: 'mid-month and mid-quarter, no Period End', only: [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 }) => { diff --git a/test/opportunity-approval-pending-alert.test.ts b/test/opportunity-approval-pending-alert.test.ts new file mode 100644 index 000000000..729a5691d --- /dev/null +++ b/test/opportunity-approval-pending-alert.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; +import { OpportunityDetailPage } from '../src/sales/pages/opportunity_detail.page'; +import { HIGH_VALUE_DEAL_AMOUNT, LARGE_DEAL_AMOUNT } from '../src/sales/objects/_thresholds'; +import { packFor, type AnyRec } from './helpers/composed-stack'; + +/** + * A deal waiting on approval says so (#2371 row 2). + * + * Measured on 18.2.0: the save that sends a deal for approval shows only the + * ordinary "Opportunity … updated" toast, and the page gains Recall / Reply + * buttons above the title with no word on why. No hook or flow can add text + * to that toast, so the deal page carries the message while the request + * waits: who decides (by the amount tiers of `opportunity_approval`), that the + * deal is locked, and what Recall does. Delete either banner, or break its + * predicate, and this goes red. + */ + +type Rec = Record; + +const components: AnyRec[] = (OpportunityDetailPage.regions ?? []).flatMap((r) => (r.components ?? []) as AnyRec[]); +const alert = (id: string): AnyRec => { + const found = components.find((c) => c.id === id); + if (!found) throw new Error(`opportunity_detail_page has no component ${id}`); + return found; +}; +const MANAGER = alert('opp_approval_pending_manager'); +const DIRECTOR = alert('opp_approval_pending_director'); + +const shown = (component: AnyRec, record: Rec): unknown => + ExpressionEngine.evaluate({ dialect: 'cel', source: component.properties.visible.source }, { record }); + +const SHAPES: Array<[string, Rec, 'manager' | 'director' | null]> = [ + ['pending at the large-deal line', { approval_status: 'pending', amount: LARGE_DEAL_AMOUNT }, 'manager'], + ['pending at exactly the high-value line', { approval_status: 'pending', amount: HIGH_VALUE_DEAL_AMOUNT }, 'manager'], + ['pending over the high-value line', { approval_status: 'pending', amount: HIGH_VALUE_DEAL_AMOUNT + 1 }, 'director'], + ['pending with no amount column', { approval_status: 'pending' }, 'manager'], + ['pending with a null amount', { approval_status: 'pending', amount: null }, 'manager'], + ['approved', { approval_status: 'approved', amount: 600_000 }, null], + ['rejected', { approval_status: 'rejected', amount: 150_000 }, null], + ['not required', { approval_status: 'not_required', amount: 50_000 }, null], + ['a null status', { approval_status: null, amount: 150_000 }, null], + ['no status column', { amount: 150_000 }, null], +]; + +describe('the deal page says a deal is waiting on approval (#2371 row 2)', () => { + it('declares two info banners in the header, gated by a CEL predicate', () => { + for (const c of [MANAGER, DIRECTOR]) { + expect(c.type).toBe('record:alert'); + expect(c.properties.severity).toBe('info'); + expect(c.properties.visible.dialect).toBe('cel'); + } + }); + + it.each(SHAPES)('%s: shows the %s banner', (_label, record, which) => { + expect(shown(MANAGER, record)).toEqual({ ok: true, value: which === 'manager' }); + expect(shown(DIRECTOR, record)).toEqual({ ok: true, value: which === 'director' }); + }); + + // The console's own Recall button label, per locale (`approvals.recall` in + // the `@objectstack/console` 18.2.0 locale bundles), and the position labels + // the packs publish. + const RECALL: Record = { en: 'Recall', 'zh-CN': '撤回', 'ja-JP': '取り下げ', 'es-ES': 'Retirar' }; + const position = (locale: string, name: string): string => + locale === 'en' ? ({ sales_manager: 'Sales Manager', sales_director: 'Sales Director' } as Rec)[name] as string + : String(packFor(locale)?.positions?.[name]?.label); + + it.each(Object.keys(RECALL))('%s: names the approver, the lock and what Recall does', (locale) => { + const manager = position(locale, 'sales_manager'); + const director = position(locale, 'sales_director'); + expect(manager, `${locale} pack has no sales_manager label`).not.toBe('undefined'); + for (const c of [MANAGER, DIRECTOR]) { + const text = `${c.properties.title[locale]} ${c.properties.body[locale]}`; + expect(text, c.id).toContain(manager); + expect(text, c.id).toContain(RECALL[locale]); + } + expect(`${DIRECTOR.properties.title[locale]} ${DIRECTOR.properties.body[locale]}`).toContain(director); + expect(DIRECTOR.properties.body[locale]).toContain(`$${HIGH_VALUE_DEAL_AMOUNT.toLocaleString('en-US')}`); + }); +}); diff --git a/test/opportunity-stage-moves.test.ts b/test/opportunity-stage-moves.test.ts index 53945536f..659c815cf 100644 --- a/test/opportunity-stage-moves.test.ts +++ b/test/opportunity-stage-moves.test.ts @@ -129,6 +129,32 @@ describe('an off-path move on an open deal saves (#2234)', () => { ); }); +/** + * An early win is on the path (#2371 row 3). A rep closing a Prospecting, + * Qualification or Needs Analysis deal as won used to get "Saved — with + * warnings: Invalid opportunity stage transition" on top of the save toast, + * for a move the business accepts. Every open stage may now go to Closed Won, + * as every open stage could already go to Closed Lost; take `closed_won` back + * out of a row and the warning returns, and this goes red. + */ +const EARLY_WIN = ['prospecting', 'qualification', 'needs_analysis'] as const; + +describe('an early win saves without a warning (#2371 row 3)', () => { + it('lets every open stage close as won or lost', () => { + for (const from of ['prospecting', 'qualification', 'needs_analysis', 'proposal', 'negotiation']) { + expect(rule.transitions[from], from).toEqual(expect.arrayContaining(['closed_won', 'closed_lost'])); + } + }); + + it.each(EARLY_WIN)('the rep: %s → closed_won saves with no warning', async (from) => { + const deal = await dealAt(from); + const res = await move(people.rep, deal.id, 'closed_won'); + expect(res.status, res.text).toBe(200); + expect(res.body.warnings ?? []).toEqual([]); + expect((await stored(deal.id)).stage).toBe('closed_won'); + }); +}); + describe('a closed deal keeps its stage (#2234)', () => { it.each(PEOPLE.flatMap(([label, key]) => OUT_OF_CLOSED.map(([from, to]) => [label, from, to, key] as const)))( '%s: %s → %s is refused by the closed-deal freeze', diff --git a/test/quote-generation-result-screen.test.ts b/test/quote-generation-result-screen.test.ts new file mode 100644 index 000000000..09eb0026b --- /dev/null +++ b/test/quote-generation-result-screen.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { templateEngine } from '@objectstack/formula'; +import type { VerifyStack } from '@objectstack/verify'; +import { QuoteGenerationFlow } from '../src/revenue/flows/quote-generation.flow'; +import { localePacks } from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * Generate Quote ends on a result screen that links the quote (#2371 row 6, + * the #2297 shape). + * + * Measured on 18.2.0 before this change: the flow closed on the toast + * "Quote created from opportunity!" and left the rep on the opportunity; the + * new quote was reachable only through the related list. Now the run pauses, + * after every write, on the `result` screen: a sentence naming the quote and + * one `record_link` field to it. Take the screen out and the runtime case + * goes red, since the run then completes on the first resume. + */ + +const nodes = QuoteGenerationFlow.nodes as Rec[]; +const edges = QuoteGenerationFlow.edges as Rec[]; +const result = nodes.find((n) => n.id === 'result')!; + +describe('the quote result screen is the end of the run (#2371 row 6)', () => { + it('every write is behind it, and it leads only to the end', () => { + expect(result?.type).toBe('screen'); + const upstream = new Set(['result']); + let grew = true; + while (grew) { + grew = false; + for (const e of edges) { + if (upstream.has(e.target) && !upstream.has(e.source)) { upstream.add(e.source); grew = true; } + } + } + const writers = nodes + .filter((n) => ['create_record', 'update_record', 'delete_record', 'subflow', 'notify'].includes(n.type)) + .map((n) => n.id); + expect(writers.filter((id) => !upstream.has(id)), 'a write runs after the result screen').toEqual([]); + expect(edges.filter((e) => e.source === 'result').map((e) => e.target)).toEqual(['end']); + }); + + it('links the quote the run created', () => { + expect((result.config.fields as Rec[]).map((f) => [f.name, f.type, f.reference, f.defaultValue?.source])) + .toEqual([['quoteLink', 'record_link', 'crm_quote', 'quoteId.id']]); + }); +}); + +describe('the quote result screen reads right in every locale (#2371 row 6)', () => { + const NAME = 'Northwind Renewal 2027'; + const translated = localePacks + .filter(([, pack]) => pack?.flows?.quote_generation?.screens?.result !== undefined) + .map(([locale, pack]) => [locale, pack.flows.quote_generation.screens.result] as [string, Rec]); + + it('every non-English pack translates the title, the sentence and the link', () => { + const shipped = localePacks.map(([locale]) => locale).filter((l) => l !== 'en').sort(); + expect(translated.map(([locale]) => locale).sort()).toEqual(shipped); + for (const [locale, screen] of translated) { + expect(String(screen.title ?? '').length, `${locale}: no title`).toBeGreaterThan(0); + expect(String(screen.fields?.quoteLink?.label ?? '').length, `${locale}: no link label`).toBeGreaterThan(0); + } + }); + + it.each([['en (authored)', String(result.config.description)], ...translated.map(([l, s]) => [l, String(s.description ?? '')])])( + '%s: names the quote', + (_locale, source) => { + const r = templateEngine.evaluate({ dialect: 'template', source: source as string }, { extra: { quoteName: NAME } } as Rec); + if (!r.ok) throw new Error(`does not render: ${r.error.message}`); + expect(String(r.value)).toContain(NAME); + }, + ); +}); + +describe('as a sales rep, Generate Quote ends on the quote (#2371 row 6)', () => { + let verify: VerifyStack; + let rep: Person; + beforeAll(async () => { + verify = await hotcrmStack(); + rep = await signUpPerson(verify, 'rep@quote-generation-result-screen.test', { + name: 'Quote Result Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + }, 120_000); + + const screenOf = (run: Rec): Rec | undefined => run.screen ?? run.output?.screen; + + it('pauses on the result screen with a link to the new quote; Submit completes and changes nothing', async () => { + const [account] = await verify.seed('crm_account', [{ name: 'Quote Result Co', owner_id: rep.id, approval_status: 'approved' }]); + const [deal] = await verify.seed('crm_opportunity', [{ + name: 'Quote Result Deal', amount: 40_000, stage: 'qualification', close_date: '2030-06-30', + crm_account: account!.id, owner_id: rep.id, + }]); + const started = await verify.flows.run('quote_generation', { recordId: deal!.id }, { as: rep.token }) as Rec; + expect(screenOf(started)?.nodeId).toBe('screen_1'); + const paused = await verify.flows.resume(started as never, { quoteName: 'Q-RESULT', expirationDays: 30, discount: 0 }, { as: rep.token }) as Rec; + const screen = screenOf(paused); + expect(screen?.nodeId, 'the run did not end on the result screen').toBe('result'); + + const quotes = await verify.rows('crm_quote', { crm_opportunity: deal!.id }); + expect(quotes.map((q) => q.name), 'the quote is written before the result screen').toEqual(['Q-RESULT']); + const link = (screen!.fields as Rec[]).find((f) => f.name === 'quoteLink')!; + expect([link.type, link.reference, link.defaultValue]).toEqual(['record_link', 'crm_quote', quotes[0]!.id]); + expect(String(screen!.description)).toContain('"Q-RESULT"'); + + const done = await verify.flows.resume(paused as never, {}, { as: rep.token }) as Rec; + expect(screenOf(done), 'the run paused again after the result screen').toBeUndefined(); + expect((await verify.rows('crm_quote', { crm_opportunity: deal!.id })).map((q) => q.id)).toEqual([quotes[0]!.id]); + }); +}); diff --git a/test/refusal-labels.test.ts b/test/refusal-labels.test.ts index 2b2d6080b..51124edcd 100644 --- a/test/refusal-labels.test.ts +++ b/test/refusal-labels.test.ts @@ -7,6 +7,9 @@ import { Opportunity } from '../src/sales/objects/opportunity.object'; import { Contract } from '../src/sales/objects/contract.object'; import { Quote } from '../src/revenue/objects/quote.object'; import { Lead } from '../src/sales/objects/lead.object'; +import { commerce as zhCommerce } from '../src/sales/translations/zh-CN/objects.commerce'; +import { commerce as esCommerce } from '../src/sales/translations/es-ES/objects.commerce'; +import { commerce as jaCommerce } from '../src/sales/translations/ja-JP/objects.commerce'; /** * Six freeze refusals name fields and states by the labels the user sees @@ -116,3 +119,36 @@ describe('the large-deal close-won refusal uses the labels the user sees (#2350) expect(source).toMatch(new RegExp(`\\[\\s*['"]approval_status['"],\\s*['"]${fieldLabel(Opportunity, 'approval_status')}['"]\\s*\\]`)); }); }); + +/** + * The quote's status warning states the path by the status labels (#2371 row + * 7). It read "Invalid quote status transition", which named neither the move + * nor the moves allowed. A state-machine message takes no holes on 18.2.0, so + * it names every status of the graph instead; each locale pack restates it in + * its own option labels. A status renamed on the object or in a pack without + * the sentence turns this red. + */ +describe('the quote status warning names the statuses by their labels (#2371 row 7)', () => { + const rule = (Quote.validations as AnyRec[]).find((v) => v.name === 'quote_status_progression')!; + const statuses = (Quote.fields.status.options as AnyRec[]).map((o) => String(o.value)); + + it('is still the warning-severity machine over status, with every status in its table', () => { + expect({ type: rule.type, field: rule.field, severity: rule.severity }) + .toEqual({ type: 'state_machine', field: 'status', severity: 'warning' }); + expect(Object.keys(rule.transitions).sort()).toEqual([...statuses].sort()); + }); + + it('names every status by its label in the authored sentence', () => { + for (const value of statuses) expect(rule.message, value).toContain(optionLabel(Quote, 'status', value)); + expect(rule.message).not.toMatch(/invalid|transition|in_review/i); + }); + + it.each([['zh-CN', zhCommerce], ['es-ES', esCommerce], ['ja-JP', jaCommerce]] as const)( + '%s: the pack sentence names every status by the pack\'s own option label', + (_locale, pack) => { + const quote = (pack as AnyRec).crm_quote; + const message = String(quote._validations.quote_status_progression.message); + for (const value of statuses) expect(message, value).toContain(quote.fields.status.options[value]); + }, + ); +}); diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index d8b0ebd71..e3748c7bf 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -125,6 +125,10 @@ const RUNTIME_TEST_FILES = [ // flow door over every campaign status, the system-write and no-move // controls, the moving edits, and the lowered body. 'campaign-member-direct-create-gates.test.ts', + // #2371 row 6 — `contract_drafted_notice`: a quote accepted on the booted + // app drafts a contract and the accepting rep gets one inbox notice; a + // contract written by hand gets none. + 'contract-drafted-notice.test.ts', ]; /**