diff --git a/.changeset/2366-record-actions-follow-state.md b/.changeset/2366-record-actions-follow-state.md new file mode 100644 index 000000000..72cc0de48 --- /dev/null +++ b/.changeset/2366-record-actions-follow-state.md @@ -0,0 +1,16 @@ +--- +"hotcrm": minor +--- + +Clause-②: no +Metadata-protocol: none + +**Record pages offer only the actions a record can still take, and quotes and cases get buttons for their status moves (#2366).** Who may do what is unchanged. + +- **Quotes:** each status move made by hand has a button, shown only on the statuses it leaves from: **Submit for Review**, **Mark Presented**, **Accept** (asks first), **Reject** and **Revise** (back to Draft). An Accepted, Rejected or Expired quote shows no **Edit**, and its line items show no **New**. A Rejected quote is now frozen like an Accepted one until **Revise** takes it back to Draft: only Internal Notes can change, and its lines take no change. A new quote can no longer be created as Accepted, Rejected or Expired: the New form does not offer them, and the save is refused. +- **Cases:** a Resolved or Closed case has a **Reopen Case** button, offered to whoever may edit cases. It moves the case back to In Progress. The SLA Due Date is kept and a recorded breach stays recorded; the Service Manual says so. +- **Leads:** a converted lead shows no **Edit** and cannot be edited inline. The lock behind it is unchanged. +- **Campaigns:** Number Sent, Responses, Leads, Converted Leads, Opportunities, Won Opportunities and Actual Revenue are read-only, and so are a member's Response Date and Has Responded. The system keeps all of them, and a value typed through the API is now dropped instead of standing until the next recompute. +- **Status colours:** the status options of quotes, campaigns, campaign members and knowledge articles declare whether each one is open, won or lost, so a record's path draws each one from that declaration instead of guessing from the value's name. + +The in-app Sales, Service and Marketing manuals describe each change in English and Chinese, and the documentation site's Quotes page says how a rejected quote is revised. diff --git a/README.md b/README.md index 2902ab7d5..54ca1802e 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, **38 actions**, 33 flows, 6 permission sets, 12 positions and 11 sharing rules, and 6 AI skills — typed [ObjectStack](https://github.com/objectstack-ai/objectstack) metadata under `src/`, checked by `pnpm validate`. - **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: **38 actions**, 33 flows, **5 dashboards**, 10 reports, 9 datasets, 6 AI skills, 4 language bundles (en, zh-CN, es-ES, ja-JP), 6 permission sets, 12 positions and 11 sharing rules. `pnpm build` prints most of these counts on every build. > **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/sales/quotes.mdx b/content/docs/sales/quotes.mdx index b734c03c8..f0251d187 100644 --- a/content/docs/sales/quotes.mdx +++ b/content/docs/sales/quotes.mdx @@ -119,7 +119,7 @@ To write the covering email inside HotCRM, use **Send Email** on the customer's There are no accept or reject buttons either. The customer's decision is recorded the same way: **Edit** the quote, set **Status**, click **Update**. - **Accepted** — the save that makes a quote *Accepted* is what drafts the contract and close-wins the deal, as described in [What happens automatically](#what-happens-automatically). While an approval holds the deal, that save is refused instead (see [Built-in rules](#built-in-rules)). From then on the quote is frozen: a change to any field except **Internal Notes** is refused, Status included. The one change it still takes is a link being emptied: when the contact or opportunity it names is deleted, the quote stays and that field is cleared. Its line items are frozen with it: a line can no longer be added, changed or removed, and the same holds for an *Expired* quote. Deleting the whole quote is not affected, and its lines go with it. -- **Rejected** — nothing else happens. No contract is drafted and the opportunity keeps its stage, so you can quote again: set the rejected quote back to *Draft*, revise it and present it again, or click **Generate Quote** on the opportunity for a fresh one. +- **Rejected** — nothing else happens. No contract is drafted and the opportunity keeps its stage, so you can quote again: click **Revise** to set the rejected quote back to *Draft*, revise it and present it again, or click **Generate Quote** on the opportunity for a fresh one. Until it is back at *Draft*, a rejected quote is frozen like an accepted one: only **Internal Notes** can change, and its line items take no change. The row of statuses across the top of the quote shows where it stands. It is a display, not a set of buttons. diff --git a/content/docs/sales/quotes.zh-Hans.mdx b/content/docs/sales/quotes.zh-Hans.mdx index d6512218e..6e82db44e 100644 --- a/content/docs/sales/quotes.zh-Hans.mdx +++ b/content/docs/sales/quotes.zh-Hans.mdx @@ -116,7 +116,7 @@ HotCRM 不会替你发送报价。报价记录上没有发送按钮,应用既 接受和拒绝也都没有按钮。客户的决定用同样的方式记录:**编辑**报价,修改**状态**,点击**更新**。 - **已接受**——把报价改为*已接受*的那次保存,就是起草合同并把商机推进为成交的那个动作,详见上文的[自动发生的事](#自动发生的事)。商机被审批卡住时,这次保存会被拒绝(见[内置规则](#内置规则))。此后这份报价被冻结:除**内部备注**以外,任何字段的修改都会被拒绝,状态也不例外。唯一还能落地的改动是清空一条链接:它指名的联系人或商机被删除时,报价保留,那一栏被清空。它的行项目也随之冻结:不能再新增、修改或删除任何一行,*已过期*的报价同样如此。删除整份报价不受影响,它的行项目会随之一起删除。 -- **已拒绝**——不会引发任何其他变化。不会起草合同,商机保持原阶段,所以你可以重新报价:把这份被拒绝的报价改回*草稿*,修改之后再次提交;或者在商机上点击**生成报价单**,另生成一份新的。 +- **已拒绝**——不会引发任何其他变化。不会起草合同,商机保持原阶段,所以你可以重新报价:点击**修订**把这份被拒绝的报价改回*草稿*,修改之后再次提交;或者在商机上点击**生成报价单**,另生成一份新的。在退回*草稿*之前,被拒绝的报价和已接受的报价一样被冻结:只有**内部备注**还能修改,报价明细也不能改动。 报价顶部那一排状态只显示这份报价当前所处的位置,它是一个显示,不是一组按钮。 diff --git a/content/docs/sales/quotes.zh-Hant.mdx b/content/docs/sales/quotes.zh-Hant.mdx index 71be9e977..cce5a4b9e 100644 --- a/content/docs/sales/quotes.zh-Hant.mdx +++ b/content/docs/sales/quotes.zh-Hant.mdx @@ -118,7 +118,7 @@ HotCRM 不會替你發送報價。報價記錄上沒有發送按鈕,應用既 接受和拒絕也都沒有按鈕。客戶的決定用同樣的方式記錄:在報價上點擊 **Edit**,修改**狀態**,點擊 **Update**。 - **已接受**——把報價改為*已接受*的那次儲存,就是起草合約並把商機推進為成交的那個動作,詳見上文的[自動發生的事](#自動發生的事)。商機被審批卡住時,這次儲存會被拒絕(見[內建規則](#內建規則))。此後這份報價被凍結:除**內部備註**以外,任何欄位的修改都會被拒絕,狀態也不例外。唯一還能落地的改動是清空一條連結:它指名的聯絡人或商機被刪除時,報價保留,那一欄被清空。它的行項目也隨之凍結:不能再新增、修改或刪除任何一行,*已過期*的報價同樣如此。刪除整份報價不受影響,它的行項目會隨之一起刪除。 -- **已拒絕**——不會引發任何其他變化。不會起草合約,商機保持原階段,所以你可以重新報價:把這份被拒絕的報價改回*草稿*,修改之後再次提交;或者在商機上點擊**生成報價單**,另生成一份新的。 +- **已拒絕**——不會引發任何其他變化。不會起草合約,商機保持原階段,所以你可以重新報價:點擊**修訂**把這份被拒絕的報價改回*草稿*,修改之後再次提交;或者在商機上點擊**生成報價單**,另生成一份新的。在退回*草稿*之前,被拒絕的報價和已接受的報價一樣被凍結:只有**內部備註**還能修改,報價明細也不能改動。 報價頂部那一排狀態只顯示這份報價目前所處的位置,它是一個顯示,不是一組按鈕。 diff --git a/docs/STATUS.md b/docs/STATUS.md index 8179d634d..e35d8b8e0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -20,7 +20,7 @@ loader registers: ```text HotCRM v6.0.0 Data: 20 Objects 375 Fields -UI: 1 Apps 14 Views 6 Pages 5 Dashboards 10 Reports 32 Actions +UI: 1 Apps 14 Views 6 Pages 5 Dashboards 10 Reports 38 Actions Logic: 33 Flows Security: 12 Positions 7 Permissions ``` @@ -31,7 +31,7 @@ Validation command: pnpm validate ``` -> **`32 Actions` is the REGISTRATION count, not a count of distinct action +> **`38 Actions` is the REGISTRATION count, not a count of distinct action > definitions.** One action bound to five objects registers five times, so the > source tree's `*.actions.ts` files and this figure answer different > questions. The registration count is the calibre this repo states to readers diff --git a/src/docs/crm_sales_leads.md b/src/docs/crm_sales_leads.md index 689a09b79..07bf28145 100644 --- a/src/docs/crm_sales_leads.md +++ b/src/docs/crm_sales_leads.md @@ -77,4 +77,8 @@ opportunity (and for its name and amount). On confirm, the system creates: - optionally an **Opportunity** with the name/amount you entered. You don't re-key anything — company and contact details carry over from the lead. + +A converted lead is locked: its page shows no **Edit**, and its fields cannot be +changed inline. Its **Conversion** section links to the account, contact and +opportunity it became; keep working there. What happens to the deal next is in [Opportunities](./crm_sales_opportunities.md). diff --git a/src/docs/crm_sales_leads.zh.md b/src/docs/crm_sales_leads.zh.md index 2d3f6f0e8..83d656c7b 100644 --- a/src/docs/crm_sales_leads.zh.md +++ b/src/docs/crm_sales_leads.zh.md @@ -54,3 +54,5 @@ stateDiagram-v2 - 可选的、使用你输入的名称和金额的**商机**。 无需重复录入——公司和联系人信息都从线索带过来。商机接下来会经历什么,见[商机](./crm_sales_opportunities.md)。 + +已转化的线索会被锁定:它的页面不显示**编辑**,字段也不能在页面上直接修改。它的**转化**分组链接到它生成的客户、联系人和商机,请在那里继续工作。 diff --git a/src/marketing/docs/crm_marketing_campaigns.md b/src/marketing/docs/crm_marketing_campaigns.md index 003abbf3f..c6bd3d4b4 100644 --- a/src/marketing/docs/crm_marketing_campaigns.md +++ b/src/marketing/docs/crm_marketing_campaigns.md @@ -59,6 +59,9 @@ answering and a lead converting, and those are the statuses it has. The numbers sit on the campaign itself, and they are kept current as members are added, answer or leave, as their leads convert, and as deals name the campaign. A running campaign shows real figures; you do not wait for it to end. +The system keeps them, so they are read-only on the campaign's form. On a +member, **Response Date** and **Has Responded** are read-only the same way: they +follow the member's status, which **Mark Responded** sets. - **Number Sent** — every member of the campaign, whatever their status. - **Number of Responses** — members who responded, whatever their status now: diff --git a/src/marketing/docs/crm_marketing_campaigns.zh.md b/src/marketing/docs/crm_marketing_campaigns.zh.md index b8b51e04d..ace4fa1e6 100644 --- a/src/marketing/docs/crm_marketing_campaigns.zh.md +++ b/src/marketing/docs/crm_marketing_campaigns.zh.md @@ -37,7 +37,7 @@ HotCRM 不跟踪打开、点击或退信。它能观察到的是一个人作出 ## 各个数字统计的是什么 -这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。 +这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。这些数字由系统维护,所以在营销活动的表单上是只读的。成员上的**响应时间**和**已响应**同样是只读的:它们跟随成员状态,而成员状态由**标记为已响应**设定。 - **发送数量**——活动的全部成员,不论状态。 - **响应数量**——作出过回应的成员,不论现在处于什么状态:**已响应**和**已转化**的成员都算,先回应、后来变为**已退订**的成员也仍算作一次响应。 diff --git a/src/marketing/objects/campaign.hook.ts b/src/marketing/objects/campaign.hook.ts index d3a9d388c..63397a2d0 100644 --- a/src/marketing/objects/campaign.hook.ts +++ b/src/marketing/objects/campaign.hook.ts @@ -125,6 +125,16 @@ const campaignValidation: Hook = { * which meant a campaign reported zeros for its entire useful life and became * accurate on the day everybody stopped looking at it. Completion is now simply * one of the transitions this catches. + * + * `runAs: 'system'` (#2366 row 4, AGENTS.md rule 9), as its three siblings + * have been since #2014 and for their reason: a rollup must not depend on who + * fired it — run as the marketer moving the status, it counted only the deals + * that marketer may see. It is also what lets its write land since the metric + * block became `readonly` (`campaign.object.ts`): a readonly field is stripped + * from a non-system write. Organization (rule 10): every read is keyed to this + * campaign's id (and the leads its members name), and the elevated `ctx.api` + * keeps the trigger's tenant. The campaign's own status write stays the + * caller's. */ const campaignMetricsRefresh: Hook = { name: 'campaign_metrics_refresh', @@ -133,6 +143,7 @@ const campaignMetricsRefresh: Hook = { priority: 800, async: true, onError: 'log', + runAs: 'system', description: 'Recompute the campaign metric block whenever the campaign status moves.', handler: async (ctx: HookContext) => { const { input } = ctx; diff --git a/src/marketing/objects/campaign.object.ts b/src/marketing/objects/campaign.object.ts index 35845dd2a..eda1e4449 100644 --- a/src/marketing/objects/campaign.object.ts +++ b/src/marketing/objects/campaign.object.ts @@ -126,11 +126,14 @@ export const Campaign = ObjectSchema.create({ status: Field.select({ group: 'basic', label: 'Status', + // `kind` (#2366 row 10, the same defect on the campaign's own path): + // Aborted is drawn as the unsuccessful end, not as a step after + // Completed. `test/status-option-kinds.test.ts`. options: [ - { label: 'Planning', value: 'planning', color: '#999999', default: true }, - { label: 'In Progress', value: 'in_progress', color: '#FFA500' }, - { label: 'Completed', value: 'completed', color: '#00AA00' }, - { label: 'Aborted', value: 'aborted', color: '#FF0000' }, + { label: 'Planning', value: 'planning', color: '#999999', default: true, kind: 'open' }, + { label: 'In Progress', value: 'in_progress', color: '#FFA500', kind: 'open' }, + { label: 'Completed', value: 'completed', color: '#00AA00', kind: 'won' }, + { label: 'Aborted', value: 'aborted', color: '#FF0000', kind: 'lost' }, ], required: true, storage: { notNull: true }, @@ -171,26 +174,25 @@ export const Campaign = ObjectSchema.create({ min: 0, }), - // ⛔ NOT `readonly` (here and on the num_* snapshot fields below), and the - // reason holds on the pinned engine rather than on the retired 16.x claim - // this note used to carry. The metric block — `CAMPAIGN_METRIC_FIELDS` in - // `campaign.hook.ts` — is written by FOUR refresh hooks, not by the - // long-retired `campaign_snapshot_metrics`: `campaign_metrics_refresh`, - // `campaign_attribution_refresh`, `campaign_lead_conversion_refresh` and - // `campaign_member_metrics_refresh`. Every one of them writes - // `api.object('crm_campaign').update(...)`, and none declares `Hook.runAs`, - // which defaults to `'inherit'` — so `ctx.api` is a `ScopedContext` over the - // ACTING USER's context, and the keys are CALLER-supplied to a - // non-`isSystem` UPDATE, exactly what `stripReadonlyFields` deletes. Their - // least-privileged trigger is an ordinary user edit — a rep moving a - // campaign's status, saving an opportunity, enrolling a member — so - // `readonly` here would freeze the whole block at its seeded values. - // Measured in `test/activity-recency.test.ts`, which runs this same - // `ctx.api` shape through the real engine under a plain user context. + // `readonly` (#2366 row 4), here and on the six `num_*` counts below: the + // metric block — `CAMPAIGN_METRIC_FIELDS` in `campaign.hook.ts` — is the + // four refresh hooks' to write, never a person's. On the form they were + // editable, and a 999 typed into Number Sent stayed until the next member + // write recomputed it (measured on 18.2.0, #2320 Marketing S3). + // + // The hooks still land their writes because all four declare + // `runAs: 'system'`: a `readonly` field is stripped from a non-system + // write (`FieldSchema.readonly`), and an elevated hook's `ctx.api` is a + // system write. Three were elevated by #2014 so a rollup does not depend on + // who fired it; `campaign_metrics_refresh` joined them with this change, + // for the same reason and because it would otherwise be stripped. Its note + // says so. `test/campaign-metric-fields-readonly.test.ts` pins both halves: + // a user's write of a count is dropped, and every refresh still lands. actual_revenue: Field.currency({ group: 'budget', label: 'Actual Revenue', min: 0, + readonly: true, }), // Metrics @@ -201,43 +203,49 @@ export const Campaign = ObjectSchema.create({ min: 0, }), - // NOT `readonly`: written by the same four refresh hooks, and for the same - // reason — see the note on `actual_revenue` above. + // `readonly`, written by the same four refresh hooks — see the note on + // `actual_revenue` above. // Definition: total members enrolled. num_sent: Field.number({ group: 'metrics', label: 'Number Sent', min: 0, + readonly: true, }), num_responses: Field.number({ group: 'metrics', label: 'Number of Responses', min: 0, + readonly: true, }), num_leads: Field.number({ group: 'metrics', label: 'Number of Leads', min: 0, + readonly: true, }), num_converted_leads: Field.number({ group: 'metrics', label: 'Converted Leads', min: 0, + readonly: true, }), num_opportunities: Field.number({ group: 'metrics', label: 'Opportunities Created', min: 0, + readonly: true, }), num_won_opportunities: Field.number({ group: 'metrics', label: 'Won Opportunities', min: 0, + readonly: true, }), // Calculated Metrics (Formula Fields) diff --git a/src/marketing/objects/campaign_member.object.ts b/src/marketing/objects/campaign_member.object.ts index 7ccebffdf..6aa944c28 100644 --- a/src/marketing/objects/campaign_member.object.ts +++ b/src/marketing/objects/campaign_member.object.ts @@ -254,20 +254,34 @@ export const CampaignMember = ObjectSchema.create({ storage: { notNull: true }, group: 'response', trackHistory: true, + // `kind` (#2366 row 10): Converted is the member's successful end and + // Unsubscribed its unsuccessful one, so the record path stops drawing + // Unsubscribed as a fourth step after Converted. + // `test/status-option-kinds.test.ts`. options: [ - { label: 'Sent', value: 'sent', default: true, color: '#A0A0A0' }, - { label: 'Responded', value: 'responded', color: '#00AA00' }, - { label: 'Converted', value: 'converted', color: '#7C3AED' }, - { label: 'Unsubscribed', value: 'unsubscribed', color: '#FF0000' }, + { label: 'Sent', value: 'sent', default: true, color: '#A0A0A0', kind: 'open' }, + { label: 'Responded', value: 'responded', color: '#00AA00', kind: 'open' }, + { label: 'Converted', value: 'converted', color: '#7C3AED', kind: 'won' }, + { label: 'Unsubscribed', value: 'unsubscribed', color: '#FF0000', kind: 'lost' }, ], }), - // Written by the `mark_responded` action, and by - // `campaign_member_lifecycle` when it back-fills a member whose response - // was recorded by flipping `status` directly. + // Stamped by `campaign_member_lifecycle` on the write that moves the + // member into Responded or Converted — whichever door set the status, the + // `mark_responded` action included — and cleared with the response on a + // reset to Sent. + // + // `readonly`, with `has_responded` below (#2366 row 4): both are the + // lifecycle hook's, derived from the status. The form offered them, and a + // hand-set flag disagreed with the status until the next status write + // (#2320 Marketing S7). A `readonly` value is stripped from a user's write + // before the hook runs, and the hook's own stamp on the same write is + // kept (the engine strips caller keys, not what a `before*` hook sets), so + // the derivation is unchanged. `test/campaign-metric-fields-readonly.test.ts`. response_date: Field.datetime({ label: 'Response Date', group: 'response', + readonly: true, }), // "Did this person ever respond", kept by `campaign_member_lifecycle` @@ -280,6 +294,7 @@ export const CampaignMember = ObjectSchema.create({ label: 'Has Responded', defaultValue: false, group: 'response', + readonly: true, }), }, diff --git a/src/revenue/actions/index.ts b/src/revenue/actions/index.ts new file mode 100644 index 000000000..9de15438c --- /dev/null +++ b/src/revenue/actions/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Revenue action barrel. + * + * The doctrine for this metadata type is written once, in + * `src/sales/actions/index.ts`; this file is the revenue package's half of the + * same file-by-file registration. + */ +export { + SubmitQuoteForReviewAction, MarkQuotePresentedAction, AcceptQuoteAction, RejectQuoteAction, ReviseQuoteAction, +} from './quote.actions'; diff --git a/src/revenue/actions/quote.actions.ts b/src/revenue/actions/quote.actions.ts new file mode 100644 index 000000000..4c8b255fb --- /dev/null +++ b/src/revenue/actions/quote.actions.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Action } from '@objectstack/spec/ui'; +import { P } from '@objectstack/spec'; + +/** + * Quote lifecycle actions (#2366 row 9). + * + * Before these, a quote's status moved only through Edit and the Status + * dropdown. Each action is one step of `quote_status_progression` + * (`quote.object.ts`) and is offered only on the statuses that step leaves + * from: + * + * | action | from | to | + * |----------------------|----------------------|-------------| + * | Submit for Review | Draft | In Review | + * | Mark Presented | In Review | Presented | + * | Accept | Presented | Accepted | + * | Reject | In Review, Presented | Rejected | + * | Revise | In Review, Rejected | Draft | + * + * Expired has no action: the `quote_expiration` sweep is its writer. Each + * `visible` is the machine's own row for that move, read the other way round, + * so a button never offers a move the machine would flag; + * `test/quote-lifecycle-actions.test.ts` derives every pair from the declared + * transitions and fails when the two disagree. The machine is unchanged. + * + * # A declarative update, as the caller + * + * `operation: 'update'` + `patch`, as `complete_task` (`task.actions.ts`, which + * gives the reasoning in full): the platform's action door writes the patch AS + * THE CALLER, so the quote's permissions, `quote_workflow`'s freeze and the + * acceptance gates (`requiredWhen` on the contact, #2014's held-deal refusal) + * judge it exactly as they judge the same status set by Edit. Each patch is the + * status alone: what follows from an acceptance — the frozen quote, the won + * deal, the drafted contract — is the hooks' (`quote.hook.ts`). + * + * `current_user.can("crm_quote", "edit")` keeps the buttons off a reader who + * may not edit quotes, by the rule `case.actions.ts` states: `visible` mirrors + * the grant. Every predicate is total: a page's first render holds no status, + * which offers nothing. + * + * # Wording + * + * Each success message names the quote by its title (`{{ record.display_title }}`, + * the action's own record). Accept alone asks first: it cannot be undone from + * the quote — the quote freezes, its opportunity closes as won and a contract + * is drafted. Every other move can be walked back by another action. + */ + +export const SubmitQuoteForReviewAction: Action = { + name: 'submit_quote_for_review', + label: 'Submit for Review', + objectName: 'crm_quote', + icon: 'send', + operation: 'update', + patch: { status: 'in_review' }, + locations: ['record_header', 'list_item'], + variant: 'primary', + visible: P`has(record.status) && record.status == "draft" && current_user.can("crm_quote", "edit")`, + successMessage: 'Quote {{ record.display_title }} submitted for review.', + refreshAfter: true, +}; + +export const MarkQuotePresentedAction: Action = { + name: 'mark_quote_presented', + label: 'Mark Presented', + objectName: 'crm_quote', + icon: 'presentation', + operation: 'update', + patch: { status: 'presented' }, + locations: ['record_header', 'list_item'], + variant: 'primary', + visible: P`has(record.status) && record.status == "in_review" && current_user.can("crm_quote", "edit")`, + successMessage: 'Quote {{ record.display_title }} marked as presented.', + refreshAfter: true, +}; + +export const AcceptQuoteAction: Action = { + name: 'accept_quote', + label: 'Accept', + objectName: 'crm_quote', + icon: 'check-circle', + operation: 'update', + patch: { status: 'accepted' }, + locations: ['record_header', 'list_item'], + variant: 'primary', + visible: P`has(record.status) && record.status == "presented" && current_user.can("crm_quote", "edit")`, + confirmText: 'Accept this quote? It is frozen, its opportunity is closed as won and a contract is drafted.', + successMessage: 'Quote {{ record.display_title }} accepted.', + refreshAfter: true, +}; + +export const RejectQuoteAction: Action = { + name: 'reject_quote', + label: 'Reject', + objectName: 'crm_quote', + icon: 'x-circle', + operation: 'update', + patch: { status: 'rejected' }, + locations: ['record_header', 'list_item'], + visible: P`has(record.status) && (record.status == "in_review" || record.status == "presented") && current_user.can("crm_quote", "edit")`, + successMessage: 'Quote {{ record.display_title }} rejected. Revise it to make a new offer.', + refreshAfter: true, +}; + +export const ReviseQuoteAction: Action = { + name: 'revise_quote', + label: 'Revise', + objectName: 'crm_quote', + icon: 'rotate-ccw', + operation: 'update', + patch: { status: 'draft' }, + locations: ['record_header', 'list_item'], + visible: P`has(record.status) && (record.status == "in_review" || record.status == "rejected") && current_user.can("crm_quote", "edit")`, + successMessage: 'Quote {{ record.display_title }} is back at Draft.', + refreshAfter: true, +}; diff --git a/src/revenue/docs/crm_revenue_quotes.md b/src/revenue/docs/crm_revenue_quotes.md index dc83e7250..0afa332d0 100644 --- a/src/revenue/docs/crm_revenue_quotes.md +++ b/src/revenue/docs/crm_revenue_quotes.md @@ -53,6 +53,28 @@ stateDiagram-v2 rejected --> draft ``` +### Moving a quote along + +Each arrow you take by hand has a button on the quote, shown only on the +statuses it leaves from: **Submit for Review** on a Draft, **Mark Presented** on +a quote In Review, **Accept** on a Presented quote, **Reject** on a quote In +Review or Presented, and **Revise** (back to Draft) on a quote In Review or +Rejected. **Accept** asks first, because it cannot be walked back from the +quote: it freezes the quote, closes its opportunity as won and drafts a +contract. The buttons are offered to whoever may edit the quote. The move to +**Expired** has no button; the daily job below makes it. + +A new quote starts as **Draft**, **In Review** or **Presented**. The New form +does not offer Accepted, Rejected or Expired, and a save that creates a quote at +one of them is refused. + +### Settled quotes + +An **Accepted**, **Rejected** or **Expired** quote is settled. It shows no +**Edit**, and its line items show no **New**. Only its **Internal Notes** can +still be changed. A **Rejected** quote is reworked by clicking **Revise**: it goes +back to **Draft**, where its fields and lines can be edited again. + ## 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 5bfce0036..63ad3da7c 100644 --- a/src/revenue/docs/crm_revenue_quotes.zh.md +++ b/src/revenue/docs/crm_revenue_quotes.zh.md @@ -41,6 +41,16 @@ stateDiagram-v2 rejected --> draft ``` +### 推进报价单 + +每条需要手动走的箭头在报价单上都有一个按钮,只在它出发的状态上显示:草稿上的**提交审核**,审核中的**标记为已提交**,已提交的**接受**,审核中或已提交的**拒绝**,以及审核中或已拒绝的**修订**(退回草稿)。**接受**会先确认,因为在报价单上无法撤回:它会冻结报价单、把商机关闭为赢单并起草一份合同。这些按钮只对可以编辑该报价单的人显示。转为**已过期**没有按钮,由下面的每日任务完成。 + +新建的报价单只能从**草稿**、**审核中**或**已提交**开始。新建表单不提供已接受、已拒绝和已过期,以这三种状态创建报价单的保存会被拒绝。 + +### 已结束的报价单 + +**已接受**、**已拒绝**或**已过期**的报价单已经结束。它不显示**编辑**,它的报价明细也不显示**新建**,只有**内部备注**还能修改。**已拒绝**的报价单要重新修改,请点击**修订**:它会退回**草稿**,在草稿上字段和明细都可以再次编辑。 + ## 报价单自动过期(自动) 在开启了计划任务的部署上,每天 **01:00** 的任务会把已过到期日期、仍未结束的报价单标记为**已过期**。默认安装启动时这个任务未绑定,过期的报价单会保持原状态,直到有人修改它。如果交易仍在进行,请重新出一份报价单。 diff --git a/src/revenue/index.ts b/src/revenue/index.ts index 95c816523..cfe53f4b1 100644 --- a/src/revenue/index.ts +++ b/src/revenue/index.ts @@ -2,6 +2,9 @@ import { defineStack, type ObjectStackDefinition, type ObjectStackDefinitionInput } from '@objectstack/spec'; +import { + SubmitQuoteForReviewAction, MarkQuotePresentedAction, AcceptQuoteAction, RejectQuoteAction, ReviseQuoteAction, +} from './actions/index.js'; import { CrmRevenueManual } from './books/index.js'; import { RevenueEmailTemplates } from './email-templates/index.js'; import { ProductDataset } from './datasets/index.js'; @@ -145,6 +148,10 @@ export const createHotCrmRevenueStack = ( objects: [OpportunityLineItem, PriceBook, PriceBookEntry, Product, Quote, QuoteLineItem], hooks: revenueHooks, views: [ProductViews, QuoteViews], + // The quote's lifecycle steps (#2366 row 9), authored against `crm_quote`. + actions: [ + SubmitQuoteForReviewAction, MarkQuotePresentedAction, AcceptQuoteAction, RejectQuoteAction, ReviseQuoteAction, + ], flows: revenueFlows, datasets: [ProductDataset], emailTemplates: RevenueEmailTemplates, diff --git a/src/revenue/objects/quote.hook.ts b/src/revenue/objects/quote.hook.ts index 1c1f0d312..bf7ac3185 100644 --- a/src/revenue/objects/quote.hook.ts +++ b/src/revenue/objects/quote.hook.ts @@ -6,11 +6,15 @@ import type { HookApi } from '../../sales/objects/_hook-api'; /** * Quote workflow hook. * - * - Freezes quotes once `accepted` or `expired` — against USER edits only: a - * write that is purely the engine clearing a link is let through. The - * freeze covers the quote's LINE ITEMS too (#2060, #2088): a user may not - * add a line to a settled quote, change one of its lines or remove one, - * while deleting the quote itself still takes its lines with it. + * - Freezes quotes once `accepted`, `rejected` or `expired` — against USER + * edits only: a write that is purely the engine clearing a link is let + * through. The freeze covers the quote's LINE ITEMS too (#2060, #2088): a + * user may not add a line to a settled quote, change one of its lines or + * remove one, while deleting the quote itself still takes its lines with it. + * A rejected quote still takes its status (#2366 row 2): the rep moves it + * back to Draft (`revise_quote`) and revises it there. + * - Refuses a user's create at a settled status (#2366 row 3): a quote starts + * as Draft, In Review or Presented. * - Refuses a user's acceptance while the linked opportunity is held by an * approval, because acceptance closes that deal as won (#2014). * - Derives `discount_amount` / `total_price` on a create carrying a @@ -40,7 +44,7 @@ const quoteValidation: Hook = { // delete fires it too and is let through below, unjudged. events: ['beforeInsert', 'beforeUpdate', 'beforeDelete'], priority: 200, - description: 'Freeze accepted/expired quotes and their lines; derive the header totals.', + description: 'Freeze settled quotes and their lines; refuse a settled create; derive the header totals.', handler: async (ctx: HookContext) => { // The refusal envelope. ⚠️ Mirrored from `src/sales/objects/_refusal.ts` because a lowered // body has no module scope and `extractHookBody` THROWS on an import; @@ -61,13 +65,13 @@ const quoteValidation: Hook = { err.userMessage = userMessage; return err; } - // The settled statuses by the labels the user sees, for the two freeze + // The settled statuses by the labels the user sees, for the freeze // refusals below (#2338 items 12 and 16): each keeps the status's API value // in its diagnostic and names it by its label in the `userMessage` the // console shows. The labels are written out because a lowered body cannot // read the object's metadata; `test/refusal-labels.test.ts` reads them off - // `crm_quote`. - const STATUS_LABELS: Record = { accepted: 'Accepted', expired: 'Expired' }; + // `crm_quote`. `rejected` joined the settled set with #2366 row 2. + const STATUS_LABELS: Record = { accepted: 'Accepted', rejected: 'Rejected', expired: 'Expired' }; const { event, input, previous } = ctx; // ─── A settled quote's LINES are frozen with it (#2060 item 1, B-09) ── @@ -108,16 +112,18 @@ const quoteValidation: Hook = { const q = changed && ctx.user?.id && typeof id === 'string' && id ? await (ctx.api as HookApi | undefined)?.object('crm_quote').findOne({ where: { id }, fields: ['quote_number', 'name', 'status'] }) : null; - if (q?.status === 'accepted' || q?.status === 'expired') { + if (q?.status === 'accepted' || q?.status === 'rejected' || q?.status === 'expired') { // Two sentences, one per reader (#2338 item 16), as the quote's own - // freeze below and the closed deal's line freeze (item 15) do. + // freeze below and the closed deal's line freeze (item 15) do. A + // rejected quote's lines are revised once it is back at Draft. const what = `Quote ${String(q.quote_number)} - ${String(q.name)} is`; const rest = 'so its line items can no longer be added, changed or removed.'; + const revise = q.status === 'rejected' ? ' Revise it back to Draft to change them.' : ''; throw refuse( - `${what} ${q.status}, ${rest}`, + `${what} ${q.status}, ${rest}${revise}`, 'RECORD_LOCKED', 409, - `${what} ${STATUS_LABELS[q.status]}, ${rest}`, + `${what} ${STATUS_LABELS[q.status]}, ${rest}${revise}`, ); } } @@ -129,6 +135,27 @@ const quoteValidation: Hook = { // forbidding it. Its lines go with it, through the leg above. if (event === 'beforeDelete') return; + // ─── A quote starts unsettled (#2366 row 3) ───────────────────────── + // + // Measured on 18.2.0 before this: a rep's New Quote form offered + // Accepted, Rejected and Expired, and a REST create at any of them + // answered 201 — an accepted quote nobody reviewed, presented or got an + // answer to. The form now withholds the three (`quote.object.ts`, the + // options' `visibleWhen`), and this is the guard behind it: a USER's create + // at a settled status is refused. A system write is let through, like the + // freeze below — the seed and a system import carry settled history. + // The `userMessage` names the statuses by label. + if (event === 'beforeInsert' && ctx.user?.id && typeof input.status === 'string' && STATUS_LABELS[input.status]) { + const named = typeof input.name === 'string' && input.name.trim() ? `Quote ${input.name.trim()}` : 'A new quote'; + const tail = 'A quote starts as Draft, In Review or Presented, and is accepted, rejected or expired from there.'; + throw refuse( + `${named} cannot be created as ${input.status}. ${tail}`, + 'VALIDATION_FAILED', + 400, + `${named} cannot be created as ${STATUS_LABELS[input.status]}. ${tail}`, + ); + } + // The quote as every quote surface titles it. `crm_quote.display_title` is // `quote_number - name`; compose the same pair from the two stored columns // rather than appending the record id. The title is stored since #2230, @@ -151,14 +178,20 @@ const quoteValidation: Hook = { // guarding them throws boot-time BodyRunner errors. Matches the system-write // convention used across the case/lead/opportunity hooks. if (event === 'beforeUpdate' && previous && ctx.user?.id) { - const frozen = previous.status === 'accepted' || previous.status === 'expired'; + const frozen = previous.status === 'accepted' || previous.status === 'rejected' || previous.status === 'expired'; if (frozen) { // `display_title` is the engine's, not the user's (#2230): the stored // title's expression is computed before this hook, so a write carries it // whenever the stored value differs from the computed one. That is only // ever a row stored before the title was (the backfill's re-save of an // unchanged `name`), because its inputs are frozen below. - const allowed = new Set(['internal_notes', 'display_title']); + // + // A REJECTED quote also takes its `status` (#2366 row 2): it is not + // terminal — the rep revises it by moving it back to Draft, and that + // move is the one way out. Which moves are legal stays the state + // machine's (`quote_status_progression`); this freeze judges only that + // nothing else changes on the same write. + const allowed = new Set(['internal_notes', 'display_title', ...(previous.status === 'rejected' ? ['status'] : [])]); // Framework-managed columns (ownership, audit timestamps) are re-stamped // by the 9.x runtime — never treat those system writes as edits to a // frozen quote, only user changes to business fields. @@ -243,11 +276,16 @@ const quoteValidation: Hook = { // reasoning is on the opportunity freeze; `STATUS_LABELS` is at the // top of the handler, shared with the line freeze. const what = `${subject()} is ${previous.status as string}`; + const rejected = previous.status === 'rejected'; throw refuse( - `${what}; only internal_notes may be edited. Attempted: ${violating.join(', ')}.`, + rejected + ? `${what}; only internal_notes and status may be edited — revise it back to draft first. Attempted: ${violating.join(', ')}.` + : `${what}; only internal_notes may be edited. Attempted: ${violating.join(', ')}.`, 'RECORD_LOCKED', 409, - `${subject()} is ${STATUS_LABELS[previous.status as string]}; only its Internal Notes can still be edited.`, + rejected + ? `${subject()} is Rejected; revise it back to Draft to change it. Until then only its Internal Notes can be edited.` + : `${subject()} is ${STATUS_LABELS[previous.status as string]}; only its Internal Notes can still be edited.`, ); } } diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 7d677dd1b..76a4b9693 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -183,13 +183,25 @@ export const Quote = ObjectSchema.create({ status: Field.select({ label: 'Status', group: 'basic', + // `kind` (#2366 row 10): the record path draws Accepted as the quote's + // successful end and Rejected / Expired as its unsuccessful ends, not as + // later steps after Accepted. `test/status-option-kinds.test.ts`. + // + // The three settled options are offered on an EXISTING quote only + // (#2366 row 3): `record.id` is absent on the New form, so a new quote + // starts at Draft, In Review or Presented. The predicate is total and + // true for every stored row, so it never refuses a write to one; the + // refusal of a user's create at a settled status is `quote_workflow`'s + // (`quote.hook.ts`), which names the quote and says what to do instead + // — this option rule's own refusal is the platform's fixed "option is + // not available". Pinned in `test/quote-settled-actions.test.ts`. options: [ - { label: 'Draft', value: 'draft', color: '#999999', default: true }, - { label: 'In Review', value: 'in_review', color: '#FFA500' }, - { label: 'Presented', value: 'presented', color: '#4169E1' }, - { label: 'Accepted', value: 'accepted', color: '#00AA00' }, - { label: 'Rejected', value: 'rejected', color: '#FF0000' }, - { label: 'Expired', value: 'expired', color: '#666666' }, + { label: 'Draft', value: 'draft', color: '#999999', default: true, kind: 'open' }, + { label: 'In Review', value: 'in_review', color: '#FFA500', kind: 'open' }, + { label: 'Presented', value: 'presented', color: '#4169E1', kind: 'open' }, + { label: 'Accepted', value: 'accepted', color: '#00AA00', kind: 'won', visibleWhen: P`has(record.created_at)` }, + { label: 'Rejected', value: 'rejected', color: '#FF0000', kind: 'lost', visibleWhen: P`has(record.created_at)` }, + { label: 'Expired', value: 'expired', color: '#666666', kind: 'lost', visibleWhen: P`has(record.created_at)` }, ], required: true, storage: { notNull: true }, @@ -329,6 +341,22 @@ export const Quote = ObjectSchema.create({ { fields: ['quote_date'] }, ], + // A settled quote (Accepted, Rejected, Expired) offers no Edit (#2366 row 2). + // `quote_workflow` (`quote.hook.ts`) refuses a user's edit of one, so the + // button only led to a form that was refused at save. The guard is the + // hook's; this is the courtesy half. What a settled quote still takes — + // Internal Notes, and a Rejected quote's move back to Draft — stays open to + // the API, and the move is the `revise_quote` action + // (`src/revenue/actions/quote.actions.ts`). The predicate is total: an + // absent status (the page's first render) offers Edit, as before. The same + // predicate gates New on the quote's line items, in + // `quote_line_item.object.ts`. Pinned in `test/quote-settled-actions.test.ts`. + userActions: { + edit: { + visibleWhen: P`!(has(record.status) && (record.status == "accepted" || record.status == "rejected" || record.status == "expired"))`, + }, + }, + // Enable advanced features // API surface. History → Field.trackHistory (ADR-0052). enable: { diff --git a/src/revenue/objects/quote_line_item.object.ts b/src/revenue/objects/quote_line_item.object.ts index e7e88d621..8314d4086 100644 --- a/src/revenue/objects/quote_line_item.object.ts +++ b/src/revenue/objects/quote_line_item.object.ts @@ -53,6 +53,19 @@ export const QuoteLineItem = ObjectSchema.create({ // through `crm_product`. Read the long note on the twin. Its writers are // `quote_line_item_product_title`, `product_title_mirror` and // `scripts/backfill-line-product-title.ts`. + // No New on a settled quote's line items (#2366 row 2). A related list's + // create toolbar evaluates this against the HOST record, the quote, so the + // predicate is `crm_quote.userActions.edit`'s, verbatim: `quote_workflow` + // refuses a user's line added to an Accepted, Rejected or Expired quote, and + // the button only led to that refusal. On a list with no quote in scope the + // predicate cannot bind and the platform hides New (fail-closed); HotCRM + // ships no such list — a line is added from its quote. Pinned in + // `test/quote-settled-actions.test.ts`. + userActions: { + create: { + visibleWhen: P`!(has(record.status) && (record.status == "accepted" || record.status == "rejected" || record.status == "expired"))`, + }, + }, nameField: 'product_title', searchableFields: ['product_title', 'description'], diff --git a/src/sales/objects/lead.object.ts b/src/sales/objects/lead.object.ts index 9f3f1e292..73e22fa62 100644 --- a/src/sales/objects/lead.object.ts +++ b/src/sales/objects/lead.object.ts @@ -628,6 +628,20 @@ export const Lead = ObjectSchema.create({ enable: { apiEnabled: true, }, + + // A converted lead offers no Edit (#2366 row 1). The converted-lead lock in + // `lead.hook.ts` refuses every user change but Description and Notes, so the + // form led a rep through every field and then refused the save. The guard is + // the hook's; this is the courtesy half. The lead's record page shows what it + // became under Conversion (account, contact, opportunity), where the work + // continues. The predicate reads `is_converted`, the flag the lock keys on + // (#2189), and is total: an absent flag (the page's first render) offers + // Edit. Pinned in `test/converted-lead-guard.test.ts`. + userActions: { + edit: { + visibleWhen: P`!(has(record.is_converted) && record.is_converted == true)`, + }, + }, // ADR-0079 record title — the stored `display_title` field defined above. nameField: 'display_title', diff --git a/src/sales/translations/en/objects.commerce.ts b/src/sales/translations/en/objects.commerce.ts index 8237f6dd3..525d0b316 100644 --- a/src/sales/translations/en/objects.commerce.ts +++ b/src/sales/translations/en/objects.commerce.ts @@ -72,6 +72,17 @@ export const commerce: Record = { quote_terms: { label: 'Terms' }, addresses_and_notes: { label: 'Addresses & Notes' }, }, + _actions: { + submit_quote_for_review: { label: 'Submit for Review', successMessage: 'Quote {{ record.display_title }} submitted for review.' }, + mark_quote_presented: { label: 'Mark Presented', successMessage: 'Quote {{ record.display_title }} marked as presented.' }, + accept_quote: { + label: 'Accept', + confirmText: 'Accept this quote? It is frozen, its opportunity is closed as won and a contract is drafted.', + successMessage: 'Quote {{ record.display_title }} accepted.', + }, + reject_quote: { label: 'Reject', successMessage: 'Quote {{ record.display_title }} rejected. Revise it to make a new offer.' }, + revise_quote: { label: 'Revise', successMessage: 'Quote {{ record.display_title }} is back at Draft.' }, + }, }, crm_quote_line_item: { label: 'Quote Line Item', diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index 5b7f5bfe4..338adea52 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -117,6 +117,10 @@ export const service: Record = { label: 'Claim Case', successMessage: 'Case claimed — it is yours now.', }, + reopen_case: { + label: 'Reopen Case', + successMessage: 'Case {{ record.display_title }} reopened.', + }, }, }, crm_knowledge_article: { diff --git a/src/sales/translations/es-ES/objects.commerce.ts b/src/sales/translations/es-ES/objects.commerce.ts index b7ae42410..38a1f15bd 100644 --- a/src/sales/translations/es-ES/objects.commerce.ts +++ b/src/sales/translations/es-ES/objects.commerce.ts @@ -84,6 +84,17 @@ export const commerce: Record = { quote_terms: { label: 'Términos' }, addresses_and_notes: { label: 'Direcciones y Notas' }, }, + _actions: { + submit_quote_for_review: { label: 'Enviar a Revisión', successMessage: 'Cotización {{ record.display_title }} enviada a revisión.' }, + mark_quote_presented: { label: 'Marcar como Presentada', successMessage: 'Cotización {{ record.display_title }} marcada como presentada.' }, + accept_quote: { + label: 'Aceptar', + confirmText: '¿Aceptar esta cotización? Queda congelada, su oportunidad se cierra como ganada y se redacta un contrato.', + successMessage: 'Cotización {{ record.display_title }} aceptada.', + }, + reject_quote: { label: 'Rechazar', successMessage: 'Cotización {{ record.display_title }} rechazada. Revísela para hacer una nueva oferta.' }, + revise_quote: { label: 'Revisar', successMessage: 'La cotización {{ record.display_title }} vuelve a Borrador.' }, + }, }, crm_quote_line_item: { _validations: { diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index d9c484e83..69848964c 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -140,6 +140,10 @@ export const service: Record = { label: 'Tomar Caso', successMessage: 'Caso tomado: ahora es suyo.', }, + reopen_case: { + label: 'Reabrir Caso', + successMessage: 'Caso {{ record.display_title }} reabierto.', + }, }, }, crm_knowledge_article: { diff --git a/src/sales/translations/ja-JP/objects.commerce.ts b/src/sales/translations/ja-JP/objects.commerce.ts index 8d025686f..405724902 100644 --- a/src/sales/translations/ja-JP/objects.commerce.ts +++ b/src/sales/translations/ja-JP/objects.commerce.ts @@ -83,6 +83,17 @@ export const commerce: Record = { quote_terms: { label: '契約条件' }, addresses_and_notes: { label: '住所と備考' }, }, + _actions: { + submit_quote_for_review: { label: 'レビューに提出', successMessage: '見積 {{ record.display_title }} をレビューに提出しました。' }, + mark_quote_presented: { label: '提示済みにする', successMessage: '見積 {{ record.display_title }} を提示済みにしました。' }, + accept_quote: { + label: '承認', + confirmText: 'この見積を承認しますか?見積は凍結され、商談は受注でクローズされ、契約が起案されます。', + successMessage: '見積 {{ record.display_title }} を承認しました。', + }, + reject_quote: { label: '却下', successMessage: '見積 {{ record.display_title }} を却下しました。改訂して新しい提案ができます。' }, + revise_quote: { label: '改訂', successMessage: '見積 {{ record.display_title }} を下書きに戻しました。' }, + }, }, crm_quote_line_item: { _validations: { diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index f28a720e6..7a046b7d1 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -137,6 +137,10 @@ export const service: Record = { label: 'ケースを引き受ける', successMessage: 'ケースを引き受けました。担当者はあなたです。', }, + reopen_case: { + label: 'ケースを再オープン', + successMessage: 'ケース {{ record.display_title }} を再オープンしました。', + }, }, }, crm_knowledge_article: { diff --git a/src/sales/translations/zh-CN/objects.commerce.ts b/src/sales/translations/zh-CN/objects.commerce.ts index e80670519..2d214e778 100644 --- a/src/sales/translations/zh-CN/objects.commerce.ts +++ b/src/sales/translations/zh-CN/objects.commerce.ts @@ -83,6 +83,17 @@ export const commerce: Record = { quote_terms: { label: '条款' }, addresses_and_notes: { label: '地址与备注' }, }, + _actions: { + submit_quote_for_review: { label: '提交审核', successMessage: '报价单 {{ record.display_title }} 已提交审核。' }, + mark_quote_presented: { label: '标记为已提交', successMessage: '报价单 {{ record.display_title }} 已标记为已提交。' }, + accept_quote: { + label: '接受', + confirmText: '接受此报价?报价将被冻结,其商机关闭为赢单,并起草一份合同。', + successMessage: '报价单 {{ record.display_title }} 已接受。', + }, + reject_quote: { label: '拒绝', successMessage: '报价单 {{ record.display_title }} 已拒绝。修订后可重新报价。' }, + revise_quote: { label: '修订', successMessage: '报价单 {{ record.display_title }} 已退回草稿。' }, + }, }, crm_quote_line_item: { _validations: { diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index 44acb7ce0..1910aab50 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -124,6 +124,10 @@ export const service: Record = { label: '认领工单', successMessage: '工单已认领,现在归你负责。', }, + reopen_case: { + label: '重新打开工单', + successMessage: '工单 {{ record.display_title }} 已重新打开。', + }, }, _sections: { // case.view.ts 表单区块名称 (#1100)。 diff --git a/src/service/actions/case.actions.ts b/src/service/actions/case.actions.ts index 73895e612..420c486ac 100644 --- a/src/service/actions/case.actions.ts +++ b/src/service/actions/case.actions.ts @@ -108,3 +108,36 @@ export const ClaimCaseAction: Action = { successMessage: 'Case claimed — it is yours now.', refreshAfter: true, }; + +/** + * Reopen Case (#2366 row 8) — a resolved or closed case back to In Progress, + * the one move `case_status_progression` (`case.object.ts`) allows out of + * either. Before this the move was Edit and the Status dropdown. + * + * A declarative update, as `complete_task` (`src/sales/actions/task.actions.ts`) + * is and for its reason: the platform's action door writes the patch AS THE + * CALLER, so the case's permissions and sharing judge it as they judge the same + * status set by Edit, and `case_sla_defaults` derives `is_closed` back to + * false on the same write. The patch is the status alone. + * + * What a reopen does to the SLA is NOT changed here, only stated (the Service + * Manual, `src/service/docs/crm_service_cases.md`): the case keeps the SLA Due + * Date it had, the clock is not restarted, a breach already recorded stays + * recorded, and a reopened case past its due date is overdue live work again. + * `test/case-reopen-action.test.ts` reads each of those back after a real run. + * + * Offered by the grant Escalate Case and Close Case read (see the header): a + * reopen edits the case. No `confirmText`: closing it again undoes it. + */ +export const ReopenCaseAction: Action = { + name: 'reopen_case', + label: 'Reopen Case', + objectName: 'crm_case', + icon: 'rotate-ccw', + operation: 'update', + patch: { status: 'in_progress' }, + locations: ['record_header'], + visible: P`has(record.status) && (record.status == "resolved" || record.status == "closed") && current_user.can("crm_case", "edit")`, + successMessage: 'Case {{ record.display_title }} reopened.', + refreshAfter: true, +}; diff --git a/src/service/actions/index.ts b/src/service/actions/index.ts index 292b6bb9c..e04ef4ab6 100644 --- a/src/service/actions/index.ts +++ b/src/service/actions/index.ts @@ -8,6 +8,6 @@ * same file-by-file registration. The three activity actions come from the * sales factory but are authored against `crm_case`, so they register here. */ -export { EscalateCaseAction, CloseCaseAction, ClaimCaseAction } from './case.actions'; +export { EscalateCaseAction, CloseCaseAction, ClaimCaseAction, ReopenCaseAction } from './case.actions'; export { LogCallAction, LogMeetingAction, CaseScheduleMeetingAction } from './case-activity.actions'; export { MarkArticleHelpfulAction, MarkArticleNotHelpfulAction } from './knowledge_article.actions'; diff --git a/src/service/docs/crm_service_cases.md b/src/service/docs/crm_service_cases.md index 43acd9c4f..d566e91f9 100644 --- a/src/service/docs/crm_service_cases.md +++ b/src/service/docs/crm_service_cases.md @@ -45,6 +45,20 @@ stateDiagram-v2 closed --> in_progress ``` +### Reopening a case + +A **Resolved** or **Closed** case shows a **Reopen Case** button to anyone who +may edit cases. It puts the case back to **In Progress**, and the case counts as +open again. + +What reopening does to the SLA: + +- The **SLA Due Date** is kept. The clock is not restarted, so a case reopened + after its due date is overdue the moment it is reopened. +- A breach already recorded stays recorded: **SLA Violated** is not cleared. +- Resolving or closing it again after the due date marks it **SLA Violated** + on that save, as for any case. + ## Case priority and the SLA due date Every case carries a **priority**, from **Low** to **Critical**, that drives its diff --git a/src/service/docs/crm_service_cases.zh.md b/src/service/docs/crm_service_cases.zh.md index f21d1aecc..9d3fa84ff 100644 --- a/src/service/docs/crm_service_cases.zh.md +++ b/src/service/docs/crm_service_cases.zh.md @@ -34,6 +34,16 @@ stateDiagram-v2 closed --> in_progress ``` +### 重新打开工单 + +**已解决**或**已关闭**的工单会向可以编辑工单的人显示**重新打开工单**按钮。它把工单退回**处理中**,工单重新算作未结。 + +重新打开对 SLA 的影响: + +- **SLA 到期**时间保持不变。时钟不会重新计时,所以过了到期时间才重新打开的工单,一打开就是逾期的。 +- 已经记录的违约仍然保留:**SLA 违反**不会被清除。 +- 过了到期时间后再次解决或关闭,与任何工单一样,那次保存会把它标记为 **SLA 违反**。 + ## 工单优先级和 SLA 到期时间 每张工单都有一个**优先级**,从**低**到**严重**,决定它的紧急程度。 diff --git a/src/service/index.ts b/src/service/index.ts index 7a952165d..973336267 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -3,7 +3,7 @@ import { defineStack, P, type ObjectStackDefinition, type ObjectStackDefinitionInput } from '@objectstack/spec'; import { - EscalateCaseAction, CloseCaseAction, ClaimCaseAction, + EscalateCaseAction, CloseCaseAction, ClaimCaseAction, ReopenCaseAction, LogCallAction, LogMeetingAction, CaseScheduleMeetingAction, MarkArticleHelpfulAction, MarkArticleNotHelpfulAction, } from './actions/index.js'; @@ -255,7 +255,7 @@ export const createHotCrmServiceStack = ( pages: [CaseDetailPage], flows: serviceFlows, actions: [ - EscalateCaseAction, CloseCaseAction, ClaimCaseAction, + EscalateCaseAction, CloseCaseAction, ClaimCaseAction, ReopenCaseAction, LogCallAction, LogMeetingAction, CaseScheduleMeetingAction, MarkArticleHelpfulAction, MarkArticleNotHelpfulAction, ], diff --git a/src/service/objects/knowledge_article.object.ts b/src/service/objects/knowledge_article.object.ts index 0965d3970..5b81c41cb 100644 --- a/src/service/objects/knowledge_article.object.ts +++ b/src/service/objects/knowledge_article.object.ts @@ -209,11 +209,14 @@ export const KnowledgeArticle = ObjectSchema.create({ storage: { notNull: true }, group: 'basic', trackHistory: true, + // `kind` (#2366 row 10): Published is the article's successful end and + // Archived its retired end, drawn apart from the path rather than as a + // step after Published. `test/status-option-kinds.test.ts`. options: [ - { label: 'Draft', value: 'draft', color: '#94A3B8', default: true }, - { label: 'In Review', value: 'in_review', color: '#F59E0B' }, - { label: 'Published', value: 'published', color: '#10B981' }, - { label: 'Archived', value: 'archived', color: '#475569' }, + { label: 'Draft', value: 'draft', color: '#94A3B8', default: true, kind: 'open' }, + { label: 'In Review', value: 'in_review', color: '#F59E0B', kind: 'open' }, + { label: 'Published', value: 'published', color: '#10B981', kind: 'won' }, + { label: 'Archived', value: 'archived', color: '#475569', kind: 'lost' }, ], }), diff --git a/src/service/pages/case_detail.page.ts b/src/service/pages/case_detail.page.ts index 3cf23ce12..0069789ca 100644 --- a/src/service/pages/case_detail.page.ts +++ b/src/service/pages/case_detail.page.ts @@ -68,9 +68,12 @@ export const CaseDetailPage: Page = { // draws three buttons inline and folds the rest into ⋯: on an // ownerless case claiming is the gesture, and on an owned one it // is hidden, so the inline three stay escalate / close / call. + // `reopen_case` (#2366 row 8) goes second for the same reason: it + // shows only on a resolved or closed case, where reopening is the + // gesture, and on an open case the inline three are unchanged. // `test/action-references.test.ts` holds this list to every // crm_case action placed at `record_header`. - actions: ['claim_case', 'escalate_case', 'close_case', 'log_call', 'log_meeting', 'schedule_meeting'], + actions: ['claim_case', 'reopen_case', 'escalate_case', 'close_case', 'log_call', 'log_meeting', 'schedule_meeting'], }, }, { diff --git a/test/accepted-quote-frees-contact.test.ts b/test/accepted-quote-frees-contact.test.ts index f86d38300..2204518b0 100644 --- a/test/accepted-quote-frees-contact.test.ts +++ b/test/accepted-quote-frees-contact.test.ts @@ -209,10 +209,14 @@ describe('the gate still stands where #1017 put it', () => { expect(after!.crm_contact).toBe(quote.crm_contact); }); - it('refuses a quote born accepted with no contact (an insert has no previous row)', async () => { + // Born PRESENTED, not accepted: since #2366 a user cannot create a quote as + // Accepted at all (`quote_workflow` refuses it before this rule is reached, + // pinned in `test/quote-settled-actions.test.ts`), so presented is the + // insert this gate still judges. + it('refuses a quote born presented with no contact (an insert has no previous row)', async () => { const { account } = await build('draft'); const res = await rest('POST', '/data/crm_quote', { - name: `EQ-${k}-born`, status: 'accepted', crm_account: account.id, + name: `EQ-${k}-born`, status: 'presented', crm_account: account.id, quote_date: '2026-01-01', expiration_date: '2030-12-31', }); expectContactRequired(res); diff --git a/test/campaign-metric-fields-readonly.test.ts b/test/campaign-metric-fields-readonly.test.ts new file mode 100644 index 000000000..8f2484707 --- /dev/null +++ b/test/campaign-metric-fields-readonly.test.ts @@ -0,0 +1,132 @@ +// 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 stack from './helpers/composed-stack'; +import campaignHooks, { CAMPAIGN_METRIC_FIELDS } from '../src/marketing/objects/campaign.hook'; +import campaignMemberHooks from '../src/marketing/objects/campaign_member.hook'; +import opportunityCampaignMetricsHooks from '../src/sales/objects/opportunity.campaign-metrics.hook'; +import leadCampaignMetricsHooks from '../src/sales/objects/lead.campaign-metrics.hook'; +import { hotcrmStack, signUpPerson, systemUpdate, daysFromNow, type Person } from './helpers/verify-stack'; + +/** + * The hook-maintained campaign numbers are read-only to a person (#2366 row 4). + * + * Measured on 18.2.0 before it (#2320 Marketing S3, S7): the campaign form + * offered Number Sent, Responses, Leads, Converted Leads, Opportunities, Won + * Opportunities and Actual Revenue as editable, and a member's form offered + * Has Responded and Response Date. A value typed there stayed until the next + * write the refresh hooks caught recomputed it. + * + * Both halves are pinned, because a disabled input is not a guard: + * + * - the GUARD: each field is `readonly`, so a user's write of it is stripped + * by the engine — through the same write door the form saves through; + * - the WRITERS still land: all four refresh hooks run elevated (a readonly + * field is stripped only from a non-system write), and the member lifecycle + * hook's own stamp on the caller's write is kept. + */ + +type Rec = Record; + +const objects: Rec[] = (stack as Rec).objects ?? []; +const fieldsOf = (name: string): Record => objects.find((o) => o.name === name)!.fields; + +const REFRESH_HOOKS = [ + ...campaignHooks, ...campaignMemberHooks, ...opportunityCampaignMetricsHooks, ...leadCampaignMetricsHooks, +].filter((h: Rec) => /_refresh$/.test(h.name)) as Rec[]; + +describe('the guard is declared', () => { + it.each(CAMPAIGN_METRIC_FIELDS)('crm_campaign.%s is readonly', (field) => { + expect(fieldsOf('crm_campaign')[field]?.readonly).toBe(true); + }); + + it.each(['has_responded', 'response_date'])('crm_campaign_member.%s is readonly', (field) => { + expect(fieldsOf('crm_campaign_member')[field]?.readonly).toBe(true); + }); + + it('every refresh hook that writes the metric block runs elevated, so its write is not stripped', () => { + expect(REFRESH_HOOKS.map((h) => h.name).sort()).toEqual([ + 'campaign_attribution_refresh', 'campaign_lead_conversion_refresh', + 'campaign_member_metrics_refresh', 'campaign_metrics_refresh', + ]); + for (const hook of REFRESH_HOOKS) expect(hook.runAs, hook.name).toBe('system'); + }); +}); + +let verify: VerifyStack; +let marketer: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + marketer = await signUpPerson(verify, 'mkt@campaign-metric-readonly.test', { + name: 'Metric Marketer', positions: ['marketing_user'], permissionSets: ['marketing_user'], + }); +}, 180_000); + +const stored = async (object: string, id: string): Promise => (await verify.rows(object, { id }))[0]!; +// Each case waits on async refresh hooks (up to 10 s), so the runtime +// describes carry a 30 s budget rather than vitest's 5 s default (#2357). +const settlesAt = (campaignId: string, expected: Rec) => + vi.waitFor(async () => expect(await stored('crm_campaign', campaignId)).toMatchObject(expected), { timeout: 10_000, interval: 50 }); + +/** A running campaign of the marketer's with one lead member, its count settled. */ +const liveCampaign = async (): Promise<{ campaign: Rec; member: Rec }> => { + const n = ++k; + const [campaign] = await verify.seed('crm_campaign', [{ + name: `Readonly Push ${n}`, status: 'in_progress', start_date: daysFromNow(-7), end_date: daysFromNow(30), owner_id: marketer.id, + }]); + const [lead] = await verify.seed('crm_lead', [{ + first_name: 'Rhea', last_name: `Only ${n}`, company: `Only Co ${n}`, email: `rhea${n}@campaign-metric-readonly.test`, + email_opt_out: false, owner_id: marketer.id, + }]); + const member = await verify.hooks.run('crm_campaign_member', 'insert', { + crm_campaign: campaign!.id, crm_lead: lead!.id, status: 'sent', + }, { as: marketer.token }); + await settlesAt(campaign!.id, { num_sent: 1, num_leads: 1 }); + return { campaign: campaign!, member }; +}; + +describe('a person cannot type the numbers in', { timeout: 30_000 }, () => { + it('drops the marketer’s write of a count and of Actual Revenue, and keeps the rest of the same write', async () => { + const { campaign } = await liveCampaign(); + await verify.hooks.run('crm_campaign', 'update', { + id: campaign.id, num_sent: 999, num_responses: 50, actual_revenue: 1_000_000, description: 'Spring wave two.', + }, { as: marketer.token }); + const after = await stored('crm_campaign', campaign.id); + expect(after).toMatchObject({ num_sent: 1, num_responses: 0, description: 'Spring wave two.' }); + expect(Number(after.actual_revenue ?? 0)).toBe(0); + }); + + it('drops the marketer’s Has Responded and Response Date on a member who has not responded', async () => { + const { member } = await liveCampaign(); + await verify.hooks.run('crm_campaign_member', 'update', { + id: member.id, has_responded: true, response_date: '2026-01-01T00:00:00.000Z', + }, { as: marketer.token }); + const after = await stored('crm_campaign_member', member.id); + expect(after.has_responded).toBe(false); + expect(after.response_date ?? null).toBeNull(); + }); +}); + +describe('the writers still land', { timeout: 30_000 }, () => { + it('Mark Responded stamps the response, and the campaign counts it', async () => { + const { campaign, member } = await liveCampaign(); + await verify.actions.run('crm_campaign_member', 'mark_responded', { as: marketer.token, recordId: member.id }); + const after = await stored('crm_campaign_member', member.id); + expect(after.status).toBe('responded'); + expect(after.has_responded).toBe(true); + expect(after.response_date).toBeTruthy(); + await settlesAt(campaign.id, { num_responses: 1 }); + }); + + it('the marketer moving the status recomputes the block (campaign_metrics_refresh)', async () => { + const { campaign } = await liveCampaign(); + // Knock the stored count off by a system write, so only a recompute puts it back. + await systemUpdate(verify, 'crm_campaign', { id: campaign.id, num_sent: 0, num_leads: 0 }); + expect((await stored('crm_campaign', campaign.id)).num_sent).toBe(0); + await verify.hooks.run('crm_campaign', 'update', { id: campaign.id, status: 'completed' }, { as: marketer.token }); + await settlesAt(campaign.id, { status: 'completed', num_sent: 1, num_leads: 1 }); + }); +}); diff --git a/test/case-reopen-action.test.ts b/test/case-reopen-action.test.ts new file mode 100644 index 000000000..72c52a731 --- /dev/null +++ b/test/case-reopen-action.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; +import type { VerifyStack } from '@objectstack/verify'; +import stack from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +/** + * Reopen Case (#2366 row 8). + * + * Measured on 18.2.0 before it (#2320 Service S8): a resolved or closed case + * had no Reopen, though `case_status_progression` allows both back to In + * Progress; the move was Edit and the Status dropdown. + * + * What is pinned: + * + * - the OFFER: on a resolved or closed case, to a persona whose sets may edit + * a case — the grant Escalate Case and Close Case read — and on nothing else; + * and the move it makes is one the machine declares; + * - the WRITE, through the action door as the agent who owns the case: back + * to In Progress, `is_closed` derived false; + * - the SLA, as the Service Manual states it: the due date is kept (the clock + * is not restarted), and a breach already recorded stays recorded. + */ + +type Rec = Record; + +const actions: Rec[] = (stack as Rec).actions ?? []; +const reopen: Rec = actions.find((a) => a.name === 'reopen_case')!; +const caseObject: Rec = ((stack as Rec).objects as Rec[]).find((o) => o.name === 'crm_case')!; +const machine: Record = + (caseObject.validations as Rec[]).find((v) => v.name === 'case_status_progression')!.transitions; +const STATUSES = Object.keys(machine); + +let verify: VerifyStack; +let agent: Person; +let rep: Person; +let accountId: string; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + agent = await signUpPerson(verify, 'agent@case-reopen.test', { + name: 'Reopen Agent', positions: ['service_agent'], permissionSets: ['service_agent'], + }); + rep = await signUpPerson(verify, 'rep@case-reopen.test', { + name: 'Reopen Rep', positions: ['sales_rep', 'na_sales_team'], permissionSets: ['sales_rep'], + }); + const [account] = await verify.seed('crm_account', [{ name: 'Reopen Co', owner_id: agent.id }]); + accountId = String(account!.id); +}, 180_000); + +const permissionsOf = async (p: Person): Promise => { + const res = await verify.apiAs(p.token, 'GET', '/auth/me/permissions'); + expect(res.status, await res.clone().text()).toBe(200); + const body: Rec = await res.json(); + return (body.data ?? body).objects; +}; + +const offered = (record: Rec, permissions: Rec): boolean => { + const result = ExpressionEngine.evaluate(reopen.visible, { + user: { id: 'u1', positions: [] }, record, permissions: permissions as never, + }); + expect(result.ok, `reopen_case.visible did not evaluate: ${JSON.stringify(result)}`).toBe(true); + return (result as Rec).value === true; +}; + +describe('the offer', () => { + it('is a declarative update to In Progress, a move the machine declares from both statuses it is offered on', async () => { + expect(reopen.operation).toBe('update'); + expect(reopen.patch).toEqual({ status: 'in_progress' }); + const permissions = await permissionsOf(agent); + const from = STATUSES.filter((status) => offered({ status }, permissions)); + expect(from).toEqual(['resolved', 'closed']); + for (const status of from) expect(machine[status]).toContain('in_progress'); + }); + + it('is withheld from a sales rep, whose set reads cases but may not edit them', async () => { + const permissions = await permissionsOf(rep); + expect(offered({ status: 'closed' }, permissions)).toBe(false); + }); + + it('is withheld before the record has loaded', async () => { + expect(offered({}, await permissionsOf(agent))).toBe(false); + }); +}); + +const stored = async (id: string): Promise => (await verify.rows('crm_case', { id }))[0]!; + +/** A case the agent owns, settled at `status`, past an SLA due date. */ +const settledCase = async (status: 'resolved' | 'closed', breached: boolean): Promise => (await verify.seed('crm_case', [{ + subject: `Reopen me ${++k}`, description: 'It broke again.', crm_account: accountId, priority: 'medium', + status, resolution: 'Restarted the service.', owner_id: agent.id, + sla_due_date: '2026-01-02T00:00:00.000Z', closed_date: status === 'closed' ? '2026-01-03T00:00:00.000Z' : null, + is_sla_violated: breached, +}]))[0]!; + +describe('the write, and what it does to the SLA', () => { + it.each(['resolved', 'closed'] as const)('takes a %s case back to In Progress as the agent who owns it', async (status) => { + const c = await settledCase(status, false); + await verify.actions.run('crm_case', 'reopen_case', { as: agent.token, recordId: c.id }); + const after = await stored(c.id); + expect(after.status).toBe('in_progress'); + expect(after.is_closed).toBe(false); + }); + + it('keeps the SLA due date: the clock is not restarted', async () => { + const c = await settledCase('closed', false); + await verify.actions.run('crm_case', 'reopen_case', { as: agent.token, recordId: c.id }); + expect(String((await stored(c.id)).sla_due_date)).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('keeps a breach already recorded', async () => { + const c = await settledCase('closed', true); + await verify.actions.run('crm_case', 'reopen_case', { as: agent.token, recordId: c.id }); + expect((await stored(c.id)).is_sla_violated).toBe(true); + }); +}); diff --git a/test/converted-lead-guard.test.ts b/test/converted-lead-guard.test.ts index 045ecda24..d23fa054d 100644 --- a/test/converted-lead-guard.test.ts +++ b/test/converted-lead-guard.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; import type { VerifyStack } from '@objectstack/verify'; import stack from './helpers/composed-stack'; import { hotcrmStack, signUpPerson, systemUpdate, type Person } from './helpers/verify-stack'; @@ -246,3 +247,27 @@ describe('the hook is the guard that actually speaks', () => { expect((await edit(lead!, { company: 'Globex' })).company).toBe('Globex'); }); }); + +describe('the record page offers no Edit on a converted lead (#2366 row 1)', () => { + // The lock above is the guard; this is the offer. Before it, a converted lead + // drew Edit (and inline editing) and every save but Description and Notes + // ended in the 409 above. Hiding Edit also removes inline editing (measured on + // 18.2.0); the two narrative fields stay writable through the API, where the + // lock lets them through. + const editWhen = (): unknown => lead?.userActions?.edit?.visibleWhen; + const offered = (record: AnyRec): boolean => { + const result = ExpressionEngine.evaluate(editWhen() as never, { record }); + expect(result.ok, JSON.stringify(result)).toBe(true); + return (result as AnyRec).value === true; + }; + + it('hides Edit on a converted lead', () => { + expect(editWhen(), 'crm_lead.userActions.edit.visibleWhen').toBeTruthy(); + expect(offered({ is_converted: true, status: 'converted' })).toBe(false); + }); + + it('CONTROL — offers Edit on an unconverted lead, and on the first render before the record loads', () => { + expect(offered({ is_converted: false, status: 'qualified' })).toBe(true); + expect(offered({})).toBe(true); + }); +}); diff --git a/test/quote-lifecycle-actions.test.ts b/test/quote-lifecycle-actions.test.ts new file mode 100644 index 000000000..5e0c31604 --- /dev/null +++ b/test/quote-lifecycle-actions.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; +import type { VerifyStack } from '@objectstack/verify'; +import stack from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +/** + * The quote's lifecycle actions (#2366 row 9). + * + * Measured on 18.2.0 before them (#2320 Revenue S3): a quote's status moved + * only through Edit and the Status dropdown. Now each step of + * `quote_status_progression` has a button, offered only where the step leaves + * from (`src/revenue/actions/quote.actions.ts`). + * + * What is pinned: + * + * - the MACHINE AGREES: every status an action is offered on, read off its + * `visible` by evaluating it, is a status its patch is a declared move + * from — and every declared move but `→ expired` (the sweep's) has an + * action. The machine is read off the object, so a change to either side + * that the other does not follow turns this red; + * - the OFFER follows the grant: a persona whose sets may not edit a quote is + * offered none of them, by the effective permissions the server publishes; + * - the WRITE is the caller's: a rep walks their own quote through Submit, + * Mark Presented and Accept, and Reject and Revise, through the action door + * the console posts to; another rep, who may not edit that quote, moves + * nothing. + */ + +type Rec = Record; + +const ACTIONS = ['submit_quote_for_review', 'mark_quote_presented', 'accept_quote', 'reject_quote', 'revise_quote'] as const; +const STATUSES = ['draft', 'in_review', 'presented', 'accepted', 'rejected', 'expired'] as const; + +const actions: Rec[] = (stack as Rec).actions ?? []; +const actionNamed = (name: string): Rec => { + const a = actions.find((x) => x.name === name); + if (!a) throw new Error(`no ${name} action registered`); + return a; +}; +const quote: Rec = ((stack as Rec).objects as Rec[]).find((o) => o.name === 'crm_quote')!; +const machine: Record = + (quote.validations as Rec[]).find((v) => v.name === 'quote_status_progression')!.transitions; + +/** Evaluate an action's `visible` the way the console does. */ +const offered = (name: string, record: Rec, permissions: Rec = { crm_quote: { allowEdit: true } }): boolean => { + const result = ExpressionEngine.evaluate(actionNamed(name).visible, { + user: { id: 'u1', positions: [] }, record, permissions: permissions as never, + }); + expect(result.ok, `${name}.visible did not evaluate: ${JSON.stringify(result)}`).toBe(true); + return (result as Rec).value === true; +}; +const fromStatuses = (name: string): string[] => STATUSES.filter((status) => offered(name, { status })); + +describe('each action is a declared step of the quote machine', () => { + it.each(ACTIONS)('%s is a declarative update of the status alone, offered only where its move is declared', (name) => { + const action = actionNamed(name); + expect(action.operation).toBe('update'); + expect(Object.keys(action.patch)).toEqual(['status']); + const to = action.patch.status as string; + const from = fromStatuses(name); + expect(from.length, `${name} is offered on no status`).toBeGreaterThan(0); + for (const status of from) expect(machine[status], `${name}: ${status} → ${to} is not a declared move`).toContain(to); + }); + + it('every declared move but the expiry sweep has its action', () => { + const covered = new Set(ACTIONS.flatMap((name) => fromStatuses(name).map((from) => `${from}→${actionNamed(name).patch.status}`))); + const declared = Object.entries(machine).flatMap(([from, tos]) => tos.filter((to) => to !== 'expired').map((to) => `${from}→${to}`)); + expect(declared.filter((move) => !covered.has(move))).toEqual([]); + }); + + it.each(ACTIONS)('%s is withheld before the record has loaded, and names the quote when it succeeds', (name) => { + expect(offered(name, {})).toBe(false); + const message = actionNamed(name).successMessage; + expect(String(message?.source ?? message)).toContain('{{ record.display_title }}'); + }); + + it('only Accept asks first: it cannot be walked back from the quote', () => { + expect(ACTIONS.filter((name) => actionNamed(name).confirmText)).toEqual(['accept_quote']); + }); +}); + +let verify: VerifyStack; +let rep: Person; +let otherRep: Person; +let marketer: Person; +let agent: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + rep = await signUpPerson(verify, 'rep@quote-lifecycle.test', { + name: 'Life Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + otherRep = await signUpPerson(verify, 'other@quote-lifecycle.test', { + name: 'Other Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + marketer = await signUpPerson(verify, 'mkt@quote-lifecycle.test', { + name: 'Life Marketer', positions: ['marketing_user'], permissionSets: ['marketing_user'], + }); + agent = await signUpPerson(verify, 'agent@quote-lifecycle.test', { + name: 'Life Agent', positions: ['service_agent'], permissionSets: ['service_agent'], + }); +}, 180_000); + +const permissionsOf = async (p: Person): Promise => { + const res = await verify.apiAs(p.token, 'GET', '/auth/me/permissions'); + expect(res.status, await res.clone().text()).toBe(200); + const body: Rec = await res.json(); + return (body.data ?? body).objects; +}; + +describe('the offer follows the grant to edit a quote', () => { + it('is made to a rep, on the statuses each step leaves from', async () => { + const permissions = await permissionsOf(rep); + expect(offered('submit_quote_for_review', { status: 'draft' }, permissions)).toBe(true); + expect(offered('accept_quote', { status: 'presented' }, permissions)).toBe(true); + }); + + it.each([['marketing_user', () => marketer], ['service_agent', () => agent]] as const)( + 'is withheld from a %s, whose sets may not edit a quote', + async (_name, who) => { + const permissions = await permissionsOf(who()); + for (const name of ACTIONS) { + for (const status of STATUSES) expect(offered(name, { status }, permissions), `${name} on ${status}`).toBe(false); + } + }, + ); +}); + +const stored = async (id: string): Promise => (await verify.rows('crm_quote', { id }))[0]!; +const run = (name: string, id: string, who: Person = rep): Promise => + verify.actions.run('crm_quote', name, { as: who.token, recordId: id }); + +/** A draft quote on an account and contact the rep owns. */ +const draftQuote = async (): Promise => { + const [account] = await verify.seed('crm_account', [{ name: `Life Co ${++k}`, owner_id: rep.id }]); + const [contact] = await verify.seed('crm_contact', [{ + first_name: 'Lia', last_name: `Life ${k}`, email: `lia${k}@quote-lifecycle.test`, crm_account: account!.id, owner_id: rep.id, + }]); + return verify.hooks.run('crm_quote', 'insert', { + name: `Life Quote ${k}`, crm_account: account!.id, crm_contact: contact!.id, status: 'draft', + quote_date: '2026-01-01', expiration_date: '2030-12-31', discount: 0, tax: 0, shipping_handling: 0, + }, { as: rep.token }); +}; + +describe('the write is the caller’s', () => { + it('walks a quote from Draft to Accepted, one step per action', async () => { + const q = await draftQuote(); + await run('submit_quote_for_review', q.id); + expect((await stored(q.id)).status).toBe('in_review'); + await run('mark_quote_presented', q.id); + expect((await stored(q.id)).status).toBe('presented'); + await run('accept_quote', q.id); + expect((await stored(q.id)).status).toBe('accepted'); + }); + + it('rejects a presented quote, and Revise takes it back to Draft', async () => { + const q = await draftQuote(); + await run('submit_quote_for_review', q.id); + await run('mark_quote_presented', q.id); + await run('reject_quote', q.id); + expect((await stored(q.id)).status).toBe('rejected'); + await run('revise_quote', q.id); + expect((await stored(q.id)).status).toBe('draft'); + }); + + it('moves nothing for a rep who may not edit the quote', async () => { + const q = await draftQuote(); + const err = (await run('submit_quote_for_review', q.id, otherRep).then(() => null, (e: Rec) => e)) as Rec | null; + expect(err, 'another rep moved a quote they cannot edit').toBeTruthy(); + expect([err!.code, err!.status]).toEqual(['RECORD_NOT_FOUND', 404]); + expect((await stored(q.id)).status).toBe('draft'); + }); +}); diff --git a/test/quote-settled-actions.test.ts b/test/quote-settled-actions.test.ts new file mode 100644 index 000000000..43946ffba --- /dev/null +++ b/test/quote-settled-actions.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; +import type { VerifyStack } from '@objectstack/verify'; +import { REFUSAL_CODES } from '../src/sales/objects/_refusal'; +import stack from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, systemUpdate, type Person } from './helpers/verify-stack'; + +/** + * A settled quote offers only what it can still take (#2366 rows 2 and 3). + * + * Measured on 18.2.0 before this change (#2320 Revenue S7, S10): an accepted + * quote still offered Edit and a New on its line items, and both ended in + * `quote_workflow`'s refusal at save; the New Quote form offered Accepted, + * Rejected and Expired, and a rep's REST create at any of them answered 201. + * + * Two halves, both pinned, because a hidden button is not a guard: + * + * - the OFFER — `crm_quote.userActions.edit`, `crm_quote_line_item`'s + * `userActions.create` (evaluated against the host quote on its related + * list) and the settled options' `visibleWhen`, evaluated here on the + * platform's CEL engine against the records the console binds; + * - the GUARD — `quote_workflow` refuses a user's create at a settled status + * and a user's edit of a Rejected quote's fields and lines, and lets a + * Rejected quote move back to Draft, where it is revised. + * + * Settled is Accepted, Rejected and Expired (the card's reading). Rejected is + * not terminal: its one way on is Revise (`revise_quote`), so the freeze keeps + * its status writable and nothing else. + */ + +type Rec = Record; + +const objects: Rec[] = (stack as Rec).objects ?? []; +const objectNamed = (name: string): Rec => { + const o = objects.find((x) => x.name === name); + if (!o) throw new Error(`no ${name} object registered`); + return o; +}; + +/** Evaluate a predicate as the console does: the record in scope as `record`. */ +const holds = (predicate: unknown, record: Rec): boolean => { + const result = ExpressionEngine.evaluate(predicate as never, { record }); + expect(result.ok, `predicate did not evaluate on ${JSON.stringify(record)}: ${JSON.stringify(result)}`).toBe(true); + return (result as Rec).value === true; +}; + +const SETTLED = ['accepted', 'rejected', 'expired'] as const; +const OPEN = ['draft', 'in_review', 'presented'] as const; + +describe('the offer: no Edit and no line New on a settled quote', () => { + const editWhen = (): unknown => objectNamed('crm_quote').userActions?.edit?.visibleWhen; + const newLineWhen = (): unknown => objectNamed('crm_quote_line_item').userActions?.create?.visibleWhen; + + it('declares both predicates', () => { + expect(editWhen(), 'crm_quote.userActions.edit.visibleWhen').toBeTruthy(); + expect(newLineWhen(), 'crm_quote_line_item.userActions.create.visibleWhen').toBeTruthy(); + }); + + it.each(SETTLED)('hides Edit, and New on the lines, on an %s quote', (status) => { + expect(holds(editWhen(), { status })).toBe(false); + expect(holds(newLineWhen(), { status }), 'the related list binds the HOST quote').toBe(false); + }); + + it.each(OPEN)('CONTROL — offers both on a %s quote', (status) => { + expect(holds(editWhen(), { status })).toBe(true); + expect(holds(newLineWhen(), { status })).toBe(true); + }); + + it('is total: a record with no status yet (the first render) offers Edit', () => { + expect(holds(editWhen(), {})).toBe(true); + }); +}); + +describe('the offer: a new quote starts unsettled', () => { + const options = (): Rec[] => objectNamed('crm_quote').fields.status.options; + + it.each(SETTLED)('withholds %s on the New form (no created_at yet) and offers it on a stored quote', (value) => { + const option = options().find((o) => o.value === value)!; + expect(holds(option.visibleWhen, { name: 'New quote' }), 'offered on the New form').toBe(false); + expect(holds(option.visibleWhen, { created_at: '2026-01-01T00:00:00.000Z', status: 'presented' })).toBe(true); + }); + + it.each(OPEN)('CONTROL — %s carries no predicate and is offered on both', (value) => { + expect(options().find((o) => o.value === value)!.visibleWhen).toBeUndefined(); + }); +}); + +let verify: VerifyStack; +let rep: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + rep = await signUpPerson(verify, 'rep@quote-settled-actions.test', { + name: 'Settle Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); +}, 180_000); + +const stored = async (object: string, id: string): Promise => (await verify.rows(object, { id }))[0]!; +const refusalOf = (p: Promise): Promise => p.then(() => null, (e: Rec) => e); + +/** An account and contact the rep owns, so the rep may write quotes on it. */ +const customer = async (): Promise<{ account: Rec; contact: Rec }> => { + const [account] = await verify.seed('crm_account', [{ name: `Settle Co ${++k}`, owner_id: rep.id }]); + const [contact] = await verify.seed('crm_contact', [{ + first_name: 'Sam', last_name: `Settle ${k}`, email: `sam${k}@quote-settled-actions.test`, crm_account: account!.id, owner_id: rep.id, + }]); + return { account: account!, contact: contact! }; +}; + +const quoteBody = (c: { account: Rec; contact: Rec }, status: string): Rec => ({ + name: `Settle Quote ${++k}`, crm_account: c.account.id, crm_contact: c.contact.id, status, + quote_date: '2026-01-01', expiration_date: '2030-12-31', discount: 0, tax: 0, shipping_handling: 0, +}); + +describe('the guard: a user cannot create a quote at a settled status', () => { + it.each(SETTLED)('refuses a rep creating a quote as %s, through the REST door, and stores nothing', async (status) => { + const c = await customer(); + const body = quoteBody(c, status); + const res = await verify.apiAs(rep.token, 'POST', '/data/crm_quote', body); + const answer = (await res.json()) as Rec; + expect({ status: res.status, code: answer.code }, JSON.stringify(answer)).toEqual({ + status: REFUSAL_CODES.invalid_value.status, code: REFUSAL_CODES.invalid_value.code, + }); + const label = { accepted: 'Accepted', rejected: 'Rejected', expired: 'Expired' }[status]; + expect(answer.userMessage).toBe( + `Quote ${body.name} cannot be created as ${label}. A quote starts as Draft, In Review or Presented, and is accepted, rejected or expired from there.`, + ); + expect(await verify.rows('crm_quote', { name: body.name })).toHaveLength(0); + }); + + it.each(OPEN)('CONTROL — a rep still creates a quote as %s', async (status) => { + const c = await customer(); + const created = await verify.hooks.run('crm_quote', 'insert', quoteBody(c, status), { as: rep.token }); + expect((await stored('crm_quote', created.id)).status).toBe(status); + }); + + it('CONTROL — a system write still stores a settled quote (the seed carries settled history)', async () => { + const c = await customer(); + const [row] = await verify.seed('crm_quote', [{ ...quoteBody(c, 'accepted'), owner_id: rep.id }]); + expect((await stored('crm_quote', row!.id)).status).toBe('accepted'); + }); +}); + +describe('the guard: a Rejected quote is revised at Draft, not in place', () => { + /** A rep's quote with one line, then rejected the way the app rejects it. */ + const rejectedQuote = async (): Promise<{ quote: Rec; line: Rec; product: Rec }> => { + const c = await customer(); + const quote = await verify.hooks.run('crm_quote', 'insert', quoteBody(c, 'draft'), { as: rep.token }); + const [product] = await verify.seed('crm_product', [{ name: `Settle Product ${k}`, product_code: `ST-${k}`, is_active: true, list_price: 100 }]); + const line = await verify.hooks.run('crm_quote_line_item', 'insert', { + crm_quote: quote.id, crm_product: product!.id, quantity: 2, unit_price: 100, + }, { as: rep.token }); + await systemUpdate(verify, 'crm_quote', { id: quote.id, status: 'rejected' }); + return { quote: await stored('crm_quote', quote.id), line, product: product! }; + }; + + it('refuses a rep changing its fields, naming the way out', async () => { + const { quote } = await rejectedQuote(); + const err = await refusalOf(verify.hooks.run('crm_quote', 'update', { id: quote.id, discount: 5 }, { as: rep.token })); + expect([err?.code, err?.status]).toEqual([REFUSAL_CODES.locked.code, REFUSAL_CODES.locked.status]); + expect(err!.userMessage).toBe( + `Quote ${quote.quote_number} - ${quote.name} is Rejected; revise it back to Draft to change it. Until then only its Internal Notes can be edited.`, + ); + expect(Number((await stored('crm_quote', quote.id)).discount)).toBe(0); + }); + + it('refuses a rep adding a line to it', async () => { + const { quote, product } = await rejectedQuote(); + const err = await refusalOf(verify.hooks.run('crm_quote_line_item', 'insert', { + crm_quote: quote.id, crm_product: product.id, quantity: 1, unit_price: 100, + }, { as: rep.token })); + expect([err?.code, err?.status]).toEqual([REFUSAL_CODES.locked.code, REFUSAL_CODES.locked.status]); + expect(err!.userMessage).toBe( + `Quote ${quote.quote_number} - ${quote.name} is Rejected, so its line items can no longer be added, changed or removed. Revise it back to Draft to change them.`, + ); + expect(await verify.rows('crm_quote_line_item', { crm_quote: quote.id })).toHaveLength(1); + }); + + it('lets the rep move it back to Draft, and then revise its numbers and lines', async () => { + const { quote, line } = await rejectedQuote(); + await verify.hooks.run('crm_quote', 'update', { id: quote.id, status: 'draft' }, { as: rep.token }); + await verify.hooks.run('crm_quote', 'update', { id: quote.id, discount: 5 }, { as: rep.token }); + await verify.hooks.run('crm_quote_line_item', 'update', { id: line.id, quantity: 3 }, { as: rep.token }); + const after = await stored('crm_quote', quote.id); + expect(after.status).toBe('draft'); + expect(Number(after.discount)).toBe(5); + expect(Number((await stored('crm_quote_line_item', line.id)).quantity)).toBe(3); + }); + + it('still takes its Internal Notes in place', async () => { + const { quote } = await rejectedQuote(); + await verify.hooks.run('crm_quote', 'update', { id: quote.id, internal_notes: 'Customer wants a 3-year term.' }, { as: rep.token }); + expect((await stored('crm_quote', quote.id)).internal_notes).toBe('Customer wants a 3-year term.'); + }); +}); diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index c9a9ea801..1409fa246 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -145,11 +145,12 @@ describe('every refusal names a code the platform will echo (#1075)', () => { // `account_protection`; 31 until #2334 added the large-deal close-won // refusal to `opportunity_lifecycle`; 32 until #2344 added // `campaign_member_enrollment_gate`'s three (a closed campaign, an - // opted-out lead, an opted-out contact). + // opted-out lead, an opted-out contact); 35 until #2366 added + // `quote_workflow`'s refusal of a quote created at a settled status. // The number is hand-maintained on purpose: a new refusal has to be a // deliberate edit here, so a guard that quietly stopped being swept cannot // hide behind a count that follows it. - expect(sites).toHaveLength(35); + expect(sites).toHaveLength(36); }); it('uses only members of the platform ErrorCode enum', () => { diff --git a/test/refusal-labels.test.ts b/test/refusal-labels.test.ts index 7d81620ad..2b2d6080b 100644 --- a/test/refusal-labels.test.ts +++ b/test/refusal-labels.test.ts @@ -61,9 +61,11 @@ describe('freeze refusals use the labels the user sees (#2338 item 12)', () => { // second copy would be read by neither assertion below. expect(source.match(/STATUS_LABELS\s*=/g)).toHaveLength(1); const statuses = table(source, 'STATUS_LABELS'); - expect(statuses.map(([k]) => k).sort()).toEqual(['accepted', 'expired']); + expect(statuses.map(([k]) => k).sort()).toEqual(['accepted', 'expired', 'rejected']); for (const [value, label] of statuses) expect(label, value).toBe(optionLabel(Quote, 'status', value)); expect(source).toContain(`only its ${fieldLabel(Quote, 'internal_notes')} can still be edited`); + // A Rejected quote's own sentence (#2366): the way out, then the one field. + expect(source).toContain(`only its ${fieldLabel(Quote, 'internal_notes')} can be edited`); }); it('contract_validation: the end date', () => { diff --git a/test/status-option-kinds.test.ts b/test/status-option-kinds.test.ts index 0097ce1aa..c1e46b1b9 100644 --- a/test/status-option-kinds.test.ts +++ b/test/status-option-kinds.test.ts @@ -68,9 +68,31 @@ const EXPECTED: Array<[string, string, Record]> = [ 'status', { not_started: 'open', in_progress: 'open', waiting: 'open', completed: 'won', deferred: 'open' }, ], + // #2366 row 10. A Rejected or Expired quote is lost, as an unsubscribed member + // and an aborted campaign are; an Archived article is retired, not published. + [ + 'crm_quote', + 'status', + { draft: 'open', in_review: 'open', presented: 'open', accepted: 'won', rejected: 'lost', expired: 'lost' }, + ], + [ + 'crm_campaign_member', + 'status', + { sent: 'open', responded: 'open', converted: 'won', unsubscribed: 'lost' }, + ], + [ + 'crm_campaign', + 'status', + { planning: 'open', in_progress: 'open', completed: 'won', aborted: 'lost' }, + ], + [ + 'crm_knowledge_article', + 'status', + { draft: 'open', in_review: 'open', published: 'won', archived: 'lost' }, + ], ]; -describe('lifecycle kind on status options (#2326)', () => { +describe('lifecycle kind on status options (#2326, #2366)', () => { it.each(EXPECTED)('%s.%s declares the ruled kind on every option', (objectName, field, expected) => { expect(kindsOf(objectName, field)).toStrictEqual(expected); });