From f3f3c63435242481591d8653dab71cc46f420366 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:03:25 +0000 Subject: [PATCH 01/11] fix(forecast,quote): one start refusal per forecast, and the quote status warning names the path by label (#2371 rows 7, 8) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- .../docs/administration/state-machines.mdx | 2 +- .../administration/state-machines.zh-Hans.mdx | 2 +- .../administration/state-machines.zh-Hant.mdx | 2 +- content/docs/sales/forecasting.mdx | 2 +- content/docs/sales/forecasting.zh-Hans.mdx | 2 +- content/docs/sales/forecasting.zh-Hant.mdx | 2 +- src/revenue/objects/quote.object.ts | 11 +++++- src/sales/objects/forecast.object.ts | 18 +++++++--- .../translations/es-ES/objects.commerce.ts | 2 +- .../translations/es-ES/objects.pipeline.ts | 2 +- .../translations/ja-JP/objects.commerce.ts | 2 +- .../translations/ja-JP/objects.pipeline.ts | 2 +- .../translations/zh-CN/objects.commerce.ts | 2 +- .../translations/zh-CN/objects.pipeline.ts | 2 +- test/forecast-period-boundary.test.ts | 4 ++- test/forecast-period-end-boundary.test.ts | 5 ++- test/refusal-labels.test.ts | 36 +++++++++++++++++++ 17 files changed, 79 insertions(+), 19 deletions(-) diff --git a/content/docs/administration/state-machines.mdx b/content/docs/administration/state-machines.mdx index 79caf7c74..045bebf20 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. diff --git a/content/docs/administration/state-machines.zh-Hans.mdx b/content/docs/administration/state-machines.zh-Hans.mdx index b364de571..df59b077b 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*),不是受控的生命周期;营销活动成员、活动、知识文章和任务的状态字段同理。给它们中任何一个加转换表,只会在正常编辑时报警告,教用户忽略警告。 diff --git a/content/docs/administration/state-machines.zh-Hant.mdx b/content/docs/administration/state-machines.zh-Hant.mdx index 63fe6a49d..2a85ce5dd 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*),不是受控的生命週期;行銷活動成員、活動、知識文章和任務的狀態欄位同理。給它們中任何一個加轉換表,只會在正常編輯時報警告,教使用者忽略警告。 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/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 7d677dd1b..dc7ca2952 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -404,7 +404,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/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/translations/es-ES/objects.commerce.ts b/src/sales/translations/es-ES/objects.commerce.ts index b7ae42410..d6f17abe5 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.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 537d7856b..02c884a66 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -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/objects.commerce.ts b/src/sales/translations/ja-JP/objects.commerce.ts index 8d025686f..9880fc5ed 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.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 4c7a0167b..118a48a42 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -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/objects.commerce.ts b/src/sales/translations/zh-CN/objects.commerce.ts index e80670519..475c7fcf9 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.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index f371dfebd..93e9e9156 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -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/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/refusal-labels.test.ts b/test/refusal-labels.test.ts index 0e3c0e81d..69360a065 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 @@ -93,3 +96,36 @@ describe('freeze refusals use the labels the user sees (#2338 items 14 and 15)', for (const [value, label] of stages) expect(label, value).toBe(optionLabel(Opportunity, 'stage', value)); }); }); + +/** + * 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]); + }, + ); +}); From c04d439e9f5be7327e5e075681ea68df27453944 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:06:55 +0000 Subject: [PATCH 02/11] fix(sales): an early win saves without the stage warning (#2371 row 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a Prospecting, Qualification or Needs Analysis deal as won came back as "Saved — with warnings: Invalid opportunity stage transition" on top of the save toast. Closed Won is now on the path from every open stage, as Closed Lost already was; a forward skip within the open stages still warns. The docs' example of an off-path move becomes Prospecting → Negotiation, and the in-app stage diagram draws the new arrows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- .../docs/administration/state-machines.mdx | 15 +++++------ .../administration/state-machines.zh-Hans.mdx | 15 +++++------ .../administration/state-machines.zh-Hant.mdx | 15 +++++------ content/docs/sales/pipeline-management.mdx | 2 +- .../sales/pipeline-management.zh-Hans.mdx | 2 +- .../sales/pipeline-management.zh-Hant.mdx | 2 +- src/docs/crm_sales_opportunities.md | 9 +++++-- src/docs/crm_sales_opportunities.zh.md | 5 +++- src/sales/objects/opportunity.object.ts | 12 ++++++--- test/docs-pipeline-kanban-section.test.ts | 6 +++-- test/opportunity-stage-moves.test.ts | 26 +++++++++++++++++++ 11 files changed, 71 insertions(+), 38 deletions(-) diff --git a/content/docs/administration/state-machines.mdx b/content/docs/administration/state-machines.mdx index 045bebf20..0124ead63 100644 --- a/content/docs/administration/state-machines.mdx +++ b/content/docs/administration/state-machines.mdx @@ -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 df59b077b..290870331 100644 --- a/content/docs/administration/state-machines.zh-Hans.mdx +++ b/content/docs/administration/state-machines.zh-Hans.mdx @@ -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 2a85ce5dd..6fffdf285 100644 --- a/content/docs/administration/state-machines.zh-Hant.mdx +++ b/content/docs/administration/state-machines.zh-Hant.mdx @@ -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/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/src/docs/crm_sales_opportunities.md b/src/docs/crm_sales_opportunities.md index 733172bf0..aaf4716d2 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 diff --git a/src/docs/crm_sales_opportunities.zh.md b/src/docs/crm_sales_opportunities.zh.md index 00736b1f9..8dd6cfd69 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 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/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/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', From bfa321759a32a320ada895585ef3d6d81bd7d34d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:08:38 +0000 Subject: [PATCH 03/11] fix(sales): the clone toast names the copy (#2371 row 4) Clone Opportunity said "Opportunity cloned successfully!" and then opened the copy, which looks like the source but for the name prefix and the stage. The body now returns the copy's name with its id, and the success message renders it in every locale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- src/sales/actions/opportunity.actions.ts | 12 ++++++-- src/sales/translations/en/objects.pipeline.ts | 2 +- .../translations/es-ES/objects.pipeline.ts | 2 +- .../translations/ja-JP/objects.pipeline.ts | 2 +- .../translations/zh-CN/objects.pipeline.ts | 2 +- test/clone-opportunity-opens-clone.test.ts | 28 +++++++++++++++++++ 6 files changed, 41 insertions(+), 7 deletions(-) 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/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 09a5b7025..ca7fcade5 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -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/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 02c884a66..721ad5123 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -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', diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 118a48a42..e4feaca44 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -448,7 +448,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: '商談を複製', - successMessage: '商談を複製しました!', + successMessage: '「{{ result.name }}」を作成しました。表示中のページはその複製です。', }, mass_update_stage: { label: 'ステージ更新', diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 93e9e9156..b1f6b13f1 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -438,7 +438,7 @@ export const pipeline: Record = { ...activityActions, clone_opportunity: { label: '克隆商机', - successMessage: '商机克隆成功!', + successMessage: '已创建「{{ result.name }}」,当前页面就是这份副本。', }, generate_quote: { label: '生成报价单', 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 }}'); + }); +}); From 377989a4dbd953a36b9e6a8f37788c87e93a3da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:15:02 +0000 Subject: [PATCH 04/11] fix(sales): one notice per deal approval decision (#2371 row 5) The platform tells the request's submitter the outcome in the inbox (approval.approved / approval.rejected, with the approver's comment), and opportunity_approval told the owner again, in the inbox and by email. The flow now emails the owner every time, since the platform sends no email, and adds its inbox notice only when the owner did not submit the request, so the platform's notice went to someone else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- content/docs/revenue/approvals.mdx | 4 +- content/docs/revenue/approvals.zh-Hans.mdx | 4 +- content/docs/revenue/approvals.zh-Hant.mdx | 4 +- src/docs/crm_sales_opportunities.md | 8 +- src/docs/crm_sales_opportunities.zh.md | 2 +- src/sales/flows/opportunity-approval.flow.ts | 84 ++++++++++++- test/approval-one-notice.test.ts | 113 ++++++++++++++++++ ...approval-recall-no-rejected-notice.test.ts | 17 ++- 8 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 test/approval-one-notice.test.ts 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/src/docs/crm_sales_opportunities.md b/src/docs/crm_sales_opportunities.md index 8568d30ee..ff62b2815 100644 --- a/src/docs/crm_sales_opportunities.md +++ b/src/docs/crm_sales_opportunities.md @@ -83,8 +83,12 @@ 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 +**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**. diff --git a/src/docs/crm_sales_opportunities.zh.md b/src/docs/crm_sales_opportunities.zh.md index 67f5a0cea..fcc5c610d 100644 --- a/src/docs/crm_sales_opportunities.zh.md +++ b/src/docs/crm_sales_opportunities.zh.md @@ -57,7 +57,7 @@ stateDiagram-v2 | **$100,000 或以上** | 销售经理审核 | | **> $500,000** | 销售经理**和**销售总监签批 | -审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。全部通过后,它会被标记为**已批准**(并记录日期),你会收到通知。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 +审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。全部通过后,它会被标记为**已批准**(并记录日期)。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。无论结果如何,你都会在应用内收到一次通知(**请求已批准**或**请求已驳回**,附审批人的意见),并收到一封邮件。如果是别人的保存让你的商机进入审批,这条应用内通知会发给那个人,你则会在应用内收到**商机已批准**或**商机未批准**。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 **金额 $100,000 或以上的商机,只有审批通过后才能成交。** 除非其**审批状态**为**已批准**,否则把它改为**成交**会被拒绝——无论请求在审批中、已被驳回、已被撤回,还是从未送审(**无需审批**,例如导入的商机,或在同一次保存里把金额提到线上并同时成交)。先在不成交的情况下保存商机以送审,审批通过后再成交。接受报价会把对应商机标记为成交,所以同一规则也会拦下报价的接受。丢单不需要审批。 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/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 }); }); }); From 3df05bf5c876fd4d364daf8e44f0d0a6279abfb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:18:38 +0000 Subject: [PATCH 05/11] feat(sales): a deal waiting on approval says so on its page (#2371 row 2) The save that sends a deal for approval showed only the save toast, and the page gained Recall / Reply buttons with no word on why. No hook or flow on 18.2.0 can add text to that toast, so the deal page now carries an info banner while the request waits: which position decides (Sales Manager, or Sales Manager then Sales Director over $500,000), that the deal is locked, and what Recall does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- src/docs/crm_sales_opportunities.md | 5 +- src/docs/crm_sales_opportunities.zh.md | 2 +- src/sales/pages/opportunity_detail.page.ts | 83 +++++++++++++++++++ src/sales/translations/en/app.ts | 2 + src/sales/translations/es-ES/app.ts | 2 + src/sales/translations/ja-JP/app.ts | 2 + src/sales/translations/zh-CN/app.ts | 2 + ...opportunity-approval-pending-alert.test.ts | 82 ++++++++++++++++++ 8 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 test/opportunity-approval-pending-alert.test.ts diff --git a/src/docs/crm_sales_opportunities.md b/src/docs/crm_sales_opportunities.md index ff62b2815..1e554b21e 100644 --- a/src/docs/crm_sales_opportunities.md +++ b/src/docs/crm_sales_opportunities.md @@ -82,7 +82,10 @@ 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 +**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 diff --git a/src/docs/crm_sales_opportunities.zh.md b/src/docs/crm_sales_opportunities.zh.md index fcc5c610d..65266a899 100644 --- a/src/docs/crm_sales_opportunities.zh.md +++ b/src/docs/crm_sales_opportunities.zh.md @@ -57,7 +57,7 @@ stateDiagram-v2 | **$100,000 或以上** | 销售经理审核 | | **> $500,000** | 销售经理**和**销售总监签批 | -审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。全部通过后,它会被标记为**已批准**(并记录日期)。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。无论结果如何,你都会在应用内收到一次通知(**请求已批准**或**请求已驳回**,附审批人的意见),并收到一封邮件。如果是别人的保存让你的商机进入审批,这条应用内通知会发给那个人,你则会在应用内收到**商机已批准**或**商机未批准**。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 +审批进行期间,商机被**锁定**,其**审批状态**显示实时进展。阶段进度条下方的蓝色提示会说明商机已提交审批、由谁审批(销售经理;金额超过 $500,000 时先由销售经理、再由销售总监审批),以及标题上方的**撤回**会撤回这次请求。全部通过后,它会被标记为**已批准**(并记录日期)。被驳回时,它会被标记为**已驳回**——商机解锁;修改后保存即可重新送审。无论结果如何,你都会在应用内收到一次通知(**请求已批准**或**请求已驳回**,附审批人的意见),并收到一封邮件。如果是别人的保存让你的商机进入审批,这条应用内通知会发给那个人,你则会在应用内收到**商机已批准**或**商机未批准**。提交人也可以**撤回**一个待审批的请求来解锁商机;下一次保存会重新送审。审批人在**我的工作 › 待我审批**中处理。 **金额 $100,000 或以上的商机,只有审批通过后才能成交。** 除非其**审批状态**为**已批准**,否则把它改为**成交**会被拒绝——无论请求在审批中、已被驳回、已被撤回,还是从未送审(**无需审批**,例如导入的商机,或在同一次保存里把金额提到线上并同时成交)。先在不成交的情况下保存商机以送审,审批通过后再成交。接受报价会把对应商机标记为成交,所以同一规则也会拦下报价的接受。丢单不需要审批。 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/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 3d67d587f..6670031a8 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`). diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 65fe76547..b778bb2b1 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`). diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index b378d0a43..257e09a6a 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`). diff --git a/test/opportunity-approval-pending-alert.test.ts b/test/opportunity-approval-pending-alert.test.ts new file mode 100644 index 000000000..118400217 --- /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')}`); + }); +}); From d938c2dafd16e41ed9af0e22c03f474ba500039b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:22:07 +0000 Subject: [PATCH 06/11] feat(revenue): Generate Quote ends on a screen that links the quote (#2371 row 6) The flow closed on a toast and left the rep on the opportunity, with the new quote reachable only through the related list. It now pauses, after every write, on a result screen naming the quote with a record_link to it, the shape lead conversion took in #2297. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- src/revenue/docs/crm_revenue_quotes.md | 6 +- src/revenue/docs/crm_revenue_quotes.zh.md | 4 +- src/revenue/flows/quote-generation.flow.ts | 23 +++- src/sales/translations/es-ES/app.ts | 7 ++ src/sales/translations/ja-JP/app.ts | 7 ++ src/sales/translations/zh-CN/app.ts | 7 ++ ...opportunity-approval-pending-alert.test.ts | 2 +- test/quote-generation-result-screen.test.ts | 111 ++++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 test/quote-generation-result-screen.test.ts diff --git a/src/revenue/docs/crm_revenue_quotes.md b/src/revenue/docs/crm_revenue_quotes.md index dc83e7250..e822a564d 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 diff --git a/src/revenue/docs/crm_revenue_quotes.zh.md b/src/revenue/docs/crm_revenue_quotes.zh.md index 5bfce0036..4d5aac293 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 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/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 6670031a8..dd6d85288 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -821,6 +821,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/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index b778bb2b1..690e54f59 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -823,6 +823,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/app.ts b/src/sales/translations/zh-CN/app.ts index 257e09a6a..883a0ee45 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -828,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/test/opportunity-approval-pending-alert.test.ts b/test/opportunity-approval-pending-alert.test.ts index 118400217..729a5691d 100644 --- a/test/opportunity-approval-pending-alert.test.ts +++ b/test/opportunity-approval-pending-alert.test.ts @@ -20,7 +20,7 @@ import { packFor, type AnyRec } from './helpers/composed-stack'; type Rec = Record; -const components: AnyRec[] = OpportunityDetailPage.regions.flatMap((r) => (r.components ?? []) as AnyRec[]); +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}`); 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]); + }); +}); From 7361de329f4a87360f60235f707bc07f681b4837 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:34:33 +0000 Subject: [PATCH 07/11] feat(sales): accepting a quote says it drafted a contract, with a link (#2371 row 6) quote_accepted_contract_draft is an async hook, so the acceptance showed only the quote's save toast and the drafted contract was found only by looking. A new record-change flow, contract_drafted_notice, fires on that contract's insert (the hook's provenance sentence marks it) and sends the person who accepted the quote an inbox notice linking the contract. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- README.md | 4 +- content/docs/administration/automation.mdx | 3 +- .../administration/automation.zh-Hans.mdx | 3 +- .../administration/automation.zh-Hant.mdx | 3 +- docs/STATUS.md | 2 +- objectstack.composition.ts | 3 + .../email-templates/crm.email-template.ts | 2 +- src/sales/email-templates/en.ts | 7 ++ src/sales/email-templates/es-ES.ts | 7 ++ src/sales/email-templates/ja-JP.ts | 7 ++ src/sales/email-templates/zh-CN.ts | 7 ++ .../flows/contract-drafted-notice.flow.ts | 72 +++++++++++++++ src/sales/flows/index.ts | 1 + test/automation-docs-coverage.test.ts | 1 + test/contract-drafted-notice.test.ts | 88 +++++++++++++++++++ 15 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/sales/flows/contract-drafted-notice.flow.ts create mode 100644 test/contract-drafted-notice.test.ts diff --git a/README.md b/README.md index 2902ab7d5..49cfb05cf 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ **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, **32 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, **32 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. @@ -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: **32 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: **32 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/docs/STATUS.md b/docs/STATUS.md index 8179d634d..03b3ffd59 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 32 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/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/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/contract-drafted-notice.test.ts b/test/contract-drafted-notice.test.ts new file mode 100644 index 000000000..ac1840813 --- /dev/null +++ b/test/contract-drafted-notice.test.ts @@ -0,0 +1,88 @@ +// 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 } 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 () => { + 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); + }); +}); From 70bbf94e06be83dc557e75f24c8ca5fe7275c811 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:36:01 +0000 Subject: [PATCH 08/11] docs(revenue): the quotes manual says what accepting a quote tells you (#2371 row 6) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- src/revenue/docs/crm_revenue_quotes.md | 7 +++++++ src/revenue/docs/crm_revenue_quotes.zh.md | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/revenue/docs/crm_revenue_quotes.md b/src/revenue/docs/crm_revenue_quotes.md index e822a564d..1d40b2022 100644 --- a/src/revenue/docs/crm_revenue_quotes.md +++ b/src/revenue/docs/crm_revenue_quotes.md @@ -57,6 +57,13 @@ stateDiagram-v2 rejected --> draft ``` +## 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 4d5aac293..973251a9e 100644 --- a/src/revenue/docs/crm_revenue_quotes.zh.md +++ b/src/revenue/docs/crm_revenue_quotes.zh.md @@ -43,6 +43,10 @@ stateDiagram-v2 rejected --> draft ``` +## 报价单被接受时 + +接受报价单会让对应商机赢单,并据此起草一份**合同**。稍后你会收到一条**合同已起草**的通知,写明合同编号,并带有打开合同的**查看**链接。合同的期限和合同类型只是初始值,激活前请先核对。 + ## 报价单自动过期(自动) 在开启了计划任务的部署上,每天 **01:00** 的任务会把已过到期日期、仍未结束的报价单标记为**已过期**。默认安装启动时这个任务未绑定,过期的报价单会保持原状态,直到有人修改它。如果交易仍在进行,请重新出一份报价单。 From a55c90191440f4b0cd82f5969d567741d8daa364 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:49:04 +0000 Subject: [PATCH 09/11] feat(marketing): enrollment results name added, already members and skipped (#2371 row 1) Enroll Members said the members were enrolled whether or not anyone was, and a single-row Add to Campaign said "Leads added to campaign!" on a run that found the person already a member. - campaign_enrollment counts who was already a member (the subflow came back without enrolling) and who the eligibility filters skipped (the segment's size less the eligible list), and ends on a result screen with the three counts and a link back to the campaign. The refusal names all four counts. The flow's success sentence is neutral, since a screen flow's successMessage cannot read the run's variables on 18.2.0. - The two enroll flows output the campaign name and an added / already count, and Add to Campaign's toast renders them in every locale. The bulk toolbar keeps the platform's "Succeeded N / N", which counts the rows the action ran on; that is reported as a platform finding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- content/docs/marketing/campaign-members.mdx | 4 +- .../marketing/campaign-members.zh-Hans.mdx | 4 +- .../marketing/campaign-members.zh-Hant.mdx | 4 +- .../docs/crm_marketing_automation.md | 20 ++-- .../docs/crm_marketing_automation.zh.md | 6 +- .../flows/campaign-enrollment.flow.ts | 99 +++++++++++++++++-- .../flows/campaign-member-enroll.flow.ts | 34 ++++++- src/sales/actions/contact.actions.ts | 6 +- src/sales/actions/lead.actions.ts | 6 +- src/sales/translations/en/objects.customer.ts | 2 +- src/sales/translations/en/objects.pipeline.ts | 2 +- src/sales/translations/es-ES/app.ts | 9 +- .../translations/es-ES/objects.customer.ts | 2 +- .../translations/es-ES/objects.pipeline.ts | 2 +- src/sales/translations/ja-JP/app.ts | 9 +- .../translations/ja-JP/objects.customer.ts | 2 +- .../translations/ja-JP/objects.pipeline.ts | 2 +- src/sales/translations/zh-CN/app.ts | 9 +- .../translations/zh-CN/objects.customer.ts | 2 +- .../translations/zh-CN/objects.pipeline.ts | 2 +- test/campaign-enrollment-doors.test.ts | 51 +++++++--- test/campaign-enrollment-gates.test.ts | 21 +++- test/campaign-enrollment-result-copy.test.ts | 79 +++++++++++++++ test/flow-campaign-enrollment.test.ts | 14 ++- test/flow-run-summary.test.ts | 9 +- 25 files changed, 338 insertions(+), 62 deletions(-) create mode 100644 test/campaign-enrollment-result-copy.test.ts 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/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/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/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/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 ca7fcade5..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' }, }, diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index dd6d85288..029ce859c 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -683,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: @@ -706,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: { 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 721ad5123..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' }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 690e54f59..9a06cf136 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -685,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: @@ -708,6 +708,11 @@ export const appSurface: Omit = { }, }, }, + result: { + title: '登録結果', + description: 'キャンペーンに新規追加 {{enrolledCount}} 人、既にメンバー {{alreadyCount}} 人、メール配信停止またはメールアドレスなしでスキップ {{skippedCount}} 人。', + fields: { campaignLink: { label: 'キャンペーン' } }, + }, }, }, escalate_case: { 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 e4feaca44..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: 'キャンペーン' }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 883a0ee45..3449dc6ff 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -690,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: @@ -713,6 +713,11 @@ export const appSurface: Omit = { }, }, }, + result: { + title: '加入结果', + description: '新加入营销活动 {{enrolledCount}} 人,已是成员 {{alreadyCount}} 人,因退订邮件或没有邮箱地址而跳过 {{skippedCount}} 人。', + fields: { campaignLink: { label: '营销活动' } }, + }, }, }, escalate_case: { 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 b1f6b13f1..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: '营销活动' }, }, 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/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); From b27aece84d56ad15aa59495c64be9d5de9e801dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:53:32 +0000 Subject: [PATCH 10/11] docs(manual): who sees a private deal; deleting a person removes their memberships (#2371 rows 9, 10) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- src/docs/crm_sales_opportunities.md | 9 +++++++++ src/docs/crm_sales_opportunities.zh.md | 6 ++++++ src/marketing/docs/crm_marketing_campaigns.md | 5 +++++ src/marketing/docs/crm_marketing_campaigns.zh.md | 2 ++ 4 files changed, 22 insertions(+) diff --git a/src/docs/crm_sales_opportunities.md b/src/docs/crm_sales_opportunities.md index 1e554b21e..9b3fd464f 100644 --- a/src/docs/crm_sales_opportunities.md +++ b/src/docs/crm_sales_opportunities.md @@ -110,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 65266a899..f2cb16221 100644 --- a/src/docs/crm_sales_opportunities.zh.md +++ b/src/docs/crm_sales_opportunities.zh.md @@ -65,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_campaigns.md b/src/marketing/docs/crm_marketing_campaigns.md index 003abbf3f..7ec66a744 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 b8b51e04d..8634f20e2 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 不跟踪打开、点击或退信。它能观察到的是一个人作出回应、一条线索完成转化,它的成员状态也就只有这些。 ## 各个数字统计的是什么 From 6612101bf9a50e4025566d6a0a994e48da337897 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:54:13 +0000 Subject: [PATCH 11/11] chore: changeset for #2371, and restate the README token banner to the measured sales figures Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAkrQzE8i7K8MQR3hR2 --- .changeset/2371-ux-feedback-round.md | 16 ++++++++++++++++ README.md | 2 +- test/docs-readme-token-figures.test.ts | 4 ++-- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .changeset/2371-ux-feedback-round.md 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 49cfb05cf..db2bcb863 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - **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) 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