diff --git a/.changeset/1995-qualification-approval-gate.md b/.changeset/1995-qualification-approval-gate.md new file mode 100644 index 00000000..e93e5d53 --- /dev/null +++ b/.changeset/1995-qualification-approval-gate.md @@ -0,0 +1,29 @@ +--- +'hotcrm': minor +--- + +An opportunity can now require **qualification approval (立项)** before it is committed: +until the deal is approved, its stage cannot move, *Will Bid* cannot be recorded, and it +can be neither closed won/lost nor asked to be. Everything else stays open, so a new deal +can still be worked while it waits. **The gate ships off** (REQ-0006 step 11: +「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、投标、赢丢单操作。」). + +**How it works, once armed.** The rep ticks *Request Qualification Approval* in the deal's +**Qualification** section, and one request appears in the approval inbox HotCRM already +mounts, routed to the `sales_manager` position. Approved, the deal is qualified for good. +Rejected, the box is unticked and the deal stays held; ticking it again asks again. The +verdict lands on a new read-only field, *Qualification Approval*, and a refused move is +answered `409 RECORD_LOCKED` with a sentence naming what was held and the way forward. +Unlike the status-change gate, the deal is **not** locked while the request waits — that +is the customer's own 「新增商机可跟进」. + +**Off by default, bit for bit.** The switch is the field default on *Qualification +Approval*, shipped as *Not Required*: no deal is ever born pending, the new flow +(*Opportunity Qualification Approval*) opens nothing, and moving the stage, *Will Bid* and +closing behave exactly as before. An install arms the gate by changing that one default +to *Pending*; deals that already exist keep *Not Required*. + +**With the status-change gate armed too, qualification comes first.** A deal that is not +yet qualified cannot raise a won/lost request either; once it is qualified, the +status-change gate behaves exactly as it does alone. Each gate keeps its own verdict +field, and the amount-based *Large Deal Approval* is unchanged. diff --git a/README.md b/README.md index 6270bbc9..0b7459d8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # HotCRM > **The reference app for AI-written enterprise software.** A complete CRM — -> 18 objects, 31 flows, 5 dashboards, 6 AI skills, 4 languages — built as four +> 18 objects, 32 flows, 5 dashboards, 6 AI skills, 4 languages — built as four > packages (sales, service, revenue, marketing) that compile to one artifact. > The **sales package**, the one a customer installs, carries its whole > business semantics (objects, flows, actions, hooks) in **~54k tokens** @@ -69,7 +69,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application* | `crm_event` | | | | | `crm_event_attendee` | | | | -Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **31 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. +Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **32 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. > **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index e4aaa5d3..c9e149e9 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -50,7 +50,7 @@ A flow fires one of three ways, set by its start node: > **Auto-launch needs the `triggers` capability.** Record-change and scheduled flows only fire when the stack's `requires` list includes `triggers` — it installs the record-change + schedule trigger providers (schedule triggers also use the job service). Screen flows are always launched manually. -**Built-in flows in HotCRM** (31). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: +**Built-in flows in HotCRM** (32). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: | Flow | Trigger | What it does | | --- | --- | --- | @@ -70,6 +70,7 @@ A flow fires one of three ways, set by its start node: | **Large Deal Approval** | Record change (update) | Tiered sign-off via approval nodes — Sales Manager ≥ $100K, Sales Director > $500K | | **Large Deal Approval (on create)** | Record change (insert) | The same intake for opportunities *created* at or above the threshold | | **Opportunity Status Change Approval** | Record change (update) | Sign-off a deal needs before it is declared won or lost. Ships switched OFF — it opens nothing until an install arms the gate on the opportunity's **Status Change Approval** field; armed, the rep sets **Requested Status** and the stage moves only when the request is approved | +| **Opportunity Qualification Approval** | Record change (update) | The qualification (立项) sign-off a new deal needs before its stage, its bid decision or its won/lost call may change. Ships switched OFF — it opens nothing until an install arms the gate on the opportunity's **Qualification Approval** field; armed, the rep ticks **Request Qualification Approval**, and everything else on the deal stays editable while it waits | | **Lead Conversion Approval** | Record change (insert) | Sign-off a lead needs before it can be converted. Ships switched OFF — it opens nothing until an install arms the gate on the lead's **Conversion Approval** field | | **Account Approval** | Record change (insert) | Sign-off a newly created account needs before it counts as established data. Ships switched **ON** — a new account starts *Pending* and is decided in the approval inbox; an install that wants no account sign-off changes the default on the account's **Approval Status** field | | **Large Deal Won Alert** | Record change (update) | When an opportunity of $100K or more turns *Closed Won*, notify the owner — the owner alone, not their manager | @@ -110,7 +111,7 @@ Since ObjectStack 7.4, approvals are modeled as **`approval` nodes inside a flow HotCRM's built-in **Opportunity Approval** flow chains two approval nodes for tiered sign-off (manager → director). See [Revenue › Approvals](/docs/revenue/approvals) for thresholds, what approvers see, and the audit trail. -A second, independent gate — **Opportunity Status Change Approval** — asks for sign-off before a deal is declared won or lost. It ships switched off and keeps its verdict in its own field, so arming it never changes the amount-based sign-off; see [Sales › Opportunity Qualification](/docs/sales/opportunity-qualification). +A second, independent gate — **Opportunity Status Change Approval** — asks for sign-off before a deal is declared won or lost. It ships switched off and keeps its verdict in its own field, so arming it never changes the amount-based sign-off. A third, **Opportunity Qualification Approval**, is the qualification (立项) sign-off a new deal needs before its stage, bid decision or won/lost call may change; it too ships off, and with both armed it comes first. See [Sales › Opportunity Qualification](/docs/sales/opportunity-qualification). ## Order of operations diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index ddebc106..5cd04317 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -50,7 +50,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。 -**HotCRM 中的内置流程**(31 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(32 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | @@ -70,6 +70,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **大额商机审批** | 记录变更(更新) | 通过 approval 节点分级签核 —— 销售经理 ≥ $100K,销售总监 > $500K | | **大额商机审批(新建时)** | 记录变更(插入) | 同一套受理逻辑,用于*创建时*金额已达到阈值的商机 | | **商机状态变更审批** | 记录变更(更新) | 商机宣布赢单或丢单之前需要的签核。出厂默认是**关闭**的:在商机的**状态变更审批**字段上打开闸门之前,它不会发起任何审批;打开之后,销售填写**申请变更状态**,请求获批后阶段才会变更 | +| **商机立项审批** | 记录变更(更新) | 新建商机在更新阶段、决定是否投标或赢丢单之前需要的立项签核。出厂默认是**关闭**的:在商机的**立项审批**字段上打开闸门之前,它不会发起任何审批;打开之后,销售勾选**申请立项审批**,等待审批期间商机的其他内容照常可以编辑 | | **线索转化审批** | 记录变更(插入) | 线索转化前需要的签核。出厂默认是**关闭**的:在线索的**转化审批**字段上打开闸门之前,它不会发起任何审批 | | **客户审批** | 记录变更(插入) | 新建客户在成为正式数据之前需要的签核。出厂默认是**打开**的:新客户以**待审批**状态创建,并在审批收件箱中裁定;不需要客户签核的安装,改掉客户**审批状态**字段的默认值即可 | | **大额商机赢单提醒** | 记录变更(更新) | 金额 $100K 及以上的商机转为 *Closed Won* 时,通知负责人 —— 只通知负责人本人,不通知其经理 | @@ -110,7 +111,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 HotCRM 内置的 **商机审批** 流程串联两个 approval 节点实现分级签核(经理 → 总监)。关于阈值、审批人所见以及审计轨迹,参见 [营收云 › 审批](/zh-Hans/docs/revenue/approvals)。 -另有一道独立的闸门 —— **商机状态变更审批** —— 在商机宣布赢单或丢单之前要求签核。它出厂默认关闭,并把结论记在自己的字段里,因此打开它不会改变按金额分级的签核;参见[销售云 › 商机资格评估](/zh-Hans/docs/sales/opportunity-qualification)。 +另有一道独立的闸门 —— **商机状态变更审批** —— 在商机宣布赢单或丢单之前要求签核。它出厂默认关闭,并把结论记在自己的字段里,因此打开它不会改变按金额分级的签核。第三道 —— **商机立项审批** —— 是新建商机在更新阶段、决定是否投标或赢丢单之前需要的立项签核;它同样出厂关闭,两道都打开时它排在前面。参见[销售云 › 商机资格评估](/zh-Hans/docs/sales/opportunity-qualification)。 ## 操作顺序 diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index 7fd8ea86..c47a482e 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -52,7 +52,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。 -**HotCRM 中的內建流程**(31 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: +**HotCRM 中的內建流程**(32 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | @@ -72,6 +72,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **大額商機審批** | 記錄變更(更新) | 透過 approval 節點分級簽核 —— 銷售經理 ≥ $100K,銷售總監 > $500K | | **大額商機審批(新建時)** | 記錄變更(插入) | 同一套受理邏輯,用於*建立時*金額已達到閾值的商機 | | **商機狀態變更審批** | 記錄變更(更新) | 商機宣布贏單或丟單之前需要的簽核。出廠預設是**關閉**的:在商機的**狀態變更審批**欄位上打開閘門之前,它不會發起任何審批;打開之後,銷售填寫**申請變更狀態**,請求獲批後階段才會變更 | +| **商機立項審批** | 記錄變更(更新) | 新建商機在更新階段、決定是否投標或贏丟單之前需要的立項簽核。出廠預設是**關閉**的:在商機的**立項審批**欄位上打開閘門之前,它不會發起任何審批;打開之後,銷售勾選**申請立項審批**,等待審批期間商機的其他內容照常可以編輯 | | **線索轉化審批** | 記錄變更(插入) | 線索轉化前需要的簽核。出廠預設是**關閉**的:在線索的**轉化審批**欄位上打開閘門之前,它不會發起任何審批 | | **客戶審批** | 記錄變更(插入) | 新建客戶在成為正式資料之前需要的簽核。出廠預設是**打開**的:新客戶以**待審批**狀態建立,並在審批收件匣中裁定;不需要客戶簽核的安裝,改掉客戶**審批狀態**欄位的預設值即可 | | **大額商機贏單提醒** | 記錄變更(更新) | 金額 $100K 及以上的商機轉為 *Closed Won* 時,通知負責人 —— 只通知負責人本人,不通知其經理 | @@ -112,7 +113,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 HotCRM 內建的 **商機審批** 流程串聯兩個 approval 節點實作分級簽核(經理 → 總監)。關於閾值、審批人所見以及稽核軌跡,參見 [營收雲 › 審批](/zh-Hant/docs/revenue/approvals)。 -另有一道獨立的閘門 —— **商機狀態變更審批** —— 在商機宣布贏單或丟單之前要求簽核。它出廠預設關閉,並把結論記在自己的欄位裡,因此打開它不會改變按金額分級的簽核;參見[銷售雲 › 商機資格評估](/zh-Hant/docs/sales/opportunity-qualification)。 +另有一道獨立的閘門 —— **商機狀態變更審批** —— 在商機宣布贏單或丟單之前要求簽核。它出廠預設關閉,並把結論記在自己的欄位裡,因此打開它不會改變按金額分級的簽核。第三道 —— **商機立項審批** —— 是新建商機在更新階段、決定是否投標或贏丟單之前需要的立項簽核;它同樣出廠關閉,兩道都打開時它排在前面。參見[銷售雲 › 商機資格評估](/zh-Hant/docs/sales/opportunity-qualification)。 ## 操作順序 diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 6c2f0f3e..9320f22b 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -56,7 +56,7 @@ The nine names below are `crm_opportunity`'s **field groups** (`fieldGroups` in | **Basic Information** | Opportunity Owner, Opportunity Name, Account, Primary Contact | | **Financials** | Amount, Expected Revenue | | **Sales Process** | Stage, Probability (%), Close Date, Stage Entry Date, Customer Initiation Date, Expected Tender Date, Expected Signing Date, Expected Tender Amount, Expected Signing Amount, Approval Status, Approved Date, Requested Status, Status Change Approval | -| **Qualification** | Will Bid, Controllability, Priority, Deal Level, Involves Subcontracting, Subcontracting Note | +| **Qualification** | Will Bid, Controllability, Priority, Deal Level, Involves Subcontracting, Subcontracting Note, Request Qualification Approval, Qualification Approval | | **Deal Narrative** | Customer Background, Project Background, Risk Analysis, Payment Terms | | **Classification** | Opportunity Type, Business Line, Lead Source, Win Reason, Loss Reason, Loss/Win Details | | **Campaigns** *(collapsed by default)* | Campaign | diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index 26c290b8..5dbd67cd 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -56,7 +56,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 | **基本信息** | 商机负责人、商机名称、所属客户、主要联系人 | | **财务** | 金额、预期收入 | | **销售流程** | 阶段、成交概率 (%)、预计成交日期、进入当前阶段日期、客户立项日期、预计招标日期、预计签约日期、预计招标金额、预计签约金额、审批状态、批准时间、申请变更状态、状态变更审批 | -| **资格评估** | 是否投标、可控性、优先级、商机级别、涉及分包、分包说明 | +| **资格评估** | 是否投标、可控性、优先级、商机级别、涉及分包、分包说明、申请立项审批、立项审批 | | **商机叙述** | 客户简介、项目背景、风险分析、付款条款 | | **分类** | 类型、业务线、线索来源、赢单原因、丢单原因、赢/丢单详情 | | **营销活动** *(默认折叠)* | 营销活动 | diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index aaed4f1f..96f3da81 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -58,7 +58,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 | **基本資訊** | 商機負責人、商機名稱、所屬客戶、主要聯絡人 | | **財務** | 金額、預期收入 | | **銷售流程** | 階段、成交機率 (%)、預計成交日期、進入當前階段日期、客戶立項日期、預計招標日期、預計簽約日期、預計招標金額、預計簽約金額、審批狀態、批准時間、申請變更狀態、狀態變更審批 | -| **資格評估** | 是否投標、可控性、優先級、商機級別、涉及分包、分包說明 | +| **資格評估** | 是否投標、可控性、優先級、商機級別、涉及分包、分包說明、申請立項審批、立項審批 | | **商機敘述** | 客戶簡介、專案背景、風險分析、付款條款 | | **分類** | 類型、業務線、潛在客戶來源、贏單原因、丟單原因、贏/丟單詳情 | | **行銷活動** *(預設摺疊)* | 行銷活動 | diff --git a/content/docs/sales/opportunity-qualification.mdx b/content/docs/sales/opportunity-qualification.mdx index 02f06df7..de051ca9 100644 --- a/content/docs/sales/opportunity-qualification.mdx +++ b/content/docs/sales/opportunity-qualification.mdx @@ -1,15 +1,16 @@ --- title: Opportunity Qualification -description: Whether a deal is worth pursuing, the customer's own procurement calendar, the written case for the deal, and the optional sign-off before a deal is declared won or lost. +description: Whether a deal is worth pursuing, the customer's own procurement calendar, the written case for the deal, and the two optional sign-offs — qualification approval before a new deal moves, and approval before a deal is declared won or lost. --- # Qualification, the customer's calendar, and sign-off on the outcome -An opportunity already says what the deal is worth and where it stands in *your* pipeline. Four more things on the record answer questions those numbers cannot: +An opportunity already says what the deal is worth and where it stands in *your* pipeline. Five more things on the record answer questions those numbers cannot: - **Qualification** — should we pursue this deal at all, and how hard? - **The customer's calendar** — when the buyer plans to start, tender and sign, and for how much. - **Deal Narrative** — the written case for the deal, in parts a reviewer can read one at a time. +- **Qualification Approval** — whether a new deal has to be approved (立项) before its stage, its bid decision or its won/lost call may change. **Off unless your admin turns it on.** - **Status Change Approval** — whether somebody has to sign off before a deal is declared won or lost. **Off unless your admin turns it on.** All of it sits on the opportunity's **Details** tab, in the **Qualification**, **Sales Process** and **Deal Narrative** sections. @@ -50,6 +51,29 @@ Attachments still go on the record's files, as they always have. **Opportunity Type** says how the deal relates to the customer — new business, an upgrade, a renewal, an expansion. **Business Line** is a separate question: *what kind of business* the deal is — **Product**, **Professional Services**, **Consulting**, **Support & Maintenance** or **Other**. The starter list is generic; your admin can replace it with your own lines of business. +## Qualification approval + +Some organisations approve a deal as a project — 立项 — before anyone commits to it. Until that approval, a new deal can be **worked**: log calls and meetings, write the narrative, record the customer's calendar and the amount. What waits is **committing** it: moving its stage, recording **Will Bid**, and the won/lost call — whether that is closing it directly or asking for it with **Requested Status**. The **Qualification Approval** field records where that stands: + +| Qualification Approval | What it means | +| --- | --- | +| **Not Required** | No qualification approval is asked for. This is how HotCRM ships, and it is what every deal shows unless your admin has turned the gate on. | +| **Pending** | The gate is on for this deal and it has not been approved yet. | +| **Approved** | The deal is qualified. Its stage, Will Bid and won/lost call are open from now on. | +| **Rejected** | The approver said no to the last request. The deal stays held. | + +You never set this field yourself — it is filled in by the approval, and it is read-only. + +**While a deal is Pending or Rejected, its stage, Will Bid and Requested Status cannot change.** To have it approved: + +1. Tick **Request Qualification Approval** in the deal's **Qualification** section. +2. An approval request opens in the approval inbox. The deal is **not** locked while it waits: everything other than those three stays editable, so keep working it. +3. If it is approved, the deal is qualified for good. If it is rejected, the box is unticked and the deal stays held — tick it again when something changes to ask again. + +> **Nothing changes for you unless your admin arms the gate.** Out of the box every deal reads *Not Required*, and moving the stage, Will Bid and closing work exactly as they always have. Deals created before the gate was turned on are never held by it. + +**When both approvals are on, qualification comes first.** A deal that is not yet qualified cannot ask for a status change either; once it is approved, closing works as the next section describes. + ## When closing a deal needs approval Declaring a deal won or lost moves the forecast, commission and planning, and it cannot be taken back. Some organisations want sign-off on that moment, whatever the deal's size. The **Status Change Approval** field records where that stands: @@ -76,6 +100,8 @@ You never set this field yourself — it is filled in by the approval, and it is - ✅ Fill in **Will Bid** as soon as you have decided, and leave it empty until then. A pipeline review reads the empty ones as the decisions still to make. - ✅ Record the customer's tender date the day you hear it. It is the date your bid team plans against, and **Tender This Quarter** can only list the deals that carry one. - ✅ If closing is refused, look at **Status Change Approval** before asking anyone: *Pending* means set **Requested Status** instead, *Rejected* is a decision to talk about. +- ✅ Fill in the narrative and the customer's calendar before you request qualification approval. They are what the approver reads. +- ✅ If a stage move or **Will Bid** is refused, look at **Qualification Approval**: *Pending* means tick **Request Qualification Approval** (or wait for the request already open), *Rejected* is a decision to talk about. - ⛔ Don't move **Close Date** to match the tender date. The two are different events, and the forecast depends on the close date being yours. ## Tips for admins @@ -85,3 +111,6 @@ You never set this field yourself — it is filled in by the approval, and it is - The gate is **off by default** and is armed by changing the default value of the opportunity's **Status Change Approval** field from *Not Required* to *Pending*. From then on each new deal is born *Pending*; deals that already exist keep *Not Required*. Requests are routed to the holders of the `sales_manager` position, and the flow behind them is **Opportunity Status Change Approval**, listed in [Administration › Automation](/docs/administration/automation). - Staff that position before you arm the gate. An approval routed to an empty bench has nobody to decide it, and a deal waiting on it stays locked. - A request written by an integration, with nobody signed in, still opens an approval. The refusal of a direct close, though, applies to edits made by signed-in users: a system write that sets the stage itself is not stopped. +- Qualification approval is armed the same way, on its own field: change the default value of the opportunity's **Qualification Approval** field from *Not Required* to *Pending*. Each new deal is then born *Pending*; deals that already exist keep *Not Required*. Requests go to the same `sales_manager` position, through the flow **Opportunity Qualification Approval**. The same signed-in-users boundary applies to what it holds. +- The two gates can be armed independently, and with both armed qualification comes first. Each keeps its own verdict field, and neither changes the amount-based **Large Deal Approval**. +- A deal carries one open approval at a time. If a deal grows past the large-deal threshold while its qualification request is waiting, the amount sign-off is not opened then; it opens on the deal's next save, and the qualification decision itself is one. diff --git a/content/docs/sales/opportunity-qualification.zh-Hans.mdx b/content/docs/sales/opportunity-qualification.zh-Hans.mdx index f634c7fc..c0373f63 100644 --- a/content/docs/sales/opportunity-qualification.zh-Hans.mdx +++ b/content/docs/sales/opportunity-qualification.zh-Hans.mdx @@ -1,15 +1,16 @@ --- title: 商机资格评估 -description: 这笔交易值不值得跟、客户自己的采购日程、写下来的商机论证,以及宣布赢单或丢单之前可选的那道签核。 +description: 这笔交易值不值得跟、客户自己的采购日程、写下来的商机论证,以及两道可选的签核——新商机推进之前的立项审批,和宣布赢单或丢单之前的审批。 --- # 资格评估、客户的日程,以及对结果的签核 -商机上本来就写着这笔交易值多少、在*你的*管线里走到了哪一步。记录上还有四样东西,回答的是那些数字回答不了的问题: +商机上本来就写着这笔交易值多少、在*你的*管线里走到了哪一步。记录上还有五样东西,回答的是那些数字回答不了的问题: - **资格评估**——这笔交易要不要跟,花多大力气跟? - **客户的日程**——买方打算什么时候立项、招标、签约,各是多少金额。 - **商机叙述**——这笔交易的书面论证,拆成评审人可以逐段读的几部分。 +- **立项审批**——新商机在更新阶段、决定是否投标或赢丢单之前,是否需要先通过立项。**除非管理员打开,否则它是关着的。** - **状态变更审批**——宣布赢单或丢单之前是否需要有人签核。**除非管理员打开,否则它是关着的。** 这些都在商机的 *Details* 标签页上,分别位于**资格评估**、**销售流程**和**商机叙述**三个分区里。 @@ -50,6 +51,29 @@ description: 这笔交易值不值得跟、客户自己的采购日程、写下 **类型**说的是这笔交易和客户是什么关系——新业务、升级、续约、扩展。**业务线**回答的是另一个问题:这笔交易*属于哪一类业务*——**产品**、**专业服务**、**咨询服务**、**运维支持**或**其他**。起始清单是通用的;管理员可以把它换成你们自己的业务线。 +## 立项审批 [#qualification-approval] + +有些组织要先对一笔交易立项、批准之后,才愿意为它投入。立项通过之前,新商机照样可以**跟进**:记录电话和拜访、撰写商机叙述、登记客户的日程和金额。要等审批的是**推进**它的那几步:变更阶段、填写**是否投标**,以及赢单或丢单——不论是直接关单,还是通过**申请变更状态**去申请。商机上的**立项审批**字段记录这件事走到哪一步: + +| 立项审批 | 含义 | +| --- | --- | +| **无需审批** | 不要求立项审批。这是 HotCRM 的出厂状态,除非管理员打开了闸门,否则每笔交易都是这个值。 | +| **审批中** | 这笔交易的闸门是开着的,还没有通过立项。 | +| **已批准** | 立项已通过。从此可以变更阶段、填写是否投标、赢单或丢单。 | +| **已驳回** | 审批人否决了上一次申请。交易仍然被挡着。 | + +这个字段不由你填写——它由审批流程写入,是只读的。 + +**只要交易处在"审批中"或"已驳回",它的阶段、是否投标和申请变更状态就不能改。** 要让它通过: + +1. 在交易的**资格评估**分区里勾选**申请立项审批**。 +2. 审批收件箱里会出现一条审批请求。等待期间交易**不会**被锁定:除了上面那三项,其他内容都照常可以编辑,跟进不必停。 +3. 获批后,这笔交易就一直处于已立项状态;被驳回时,勾选会被取消,交易仍然被挡着——情况有变时再勾选一次即可重新申请。 + +> **管理员不打开闸门,你这边什么都不会变。** 出厂状态下每笔交易都显示*无需审批*,变更阶段、填写是否投标和关单都和以前完全一样。闸门打开之前创建的交易永远不会被它挡住。 + +**两道审批都打开时,立项排在前面。** 还没立项的交易也不能申请状态变更;立项通过之后,关单就按下一节说的方式进行。 + ## 什么时候关单需要审批 宣布一笔交易赢单或丢单,会牵动预测、提成和规划,而且无法撤回。有些组织希望无论金额大小,都要对这一刻签核。商机上的**状态变更审批**字段记录这件事走到哪一步: @@ -76,6 +100,8 @@ description: 这笔交易值不值得跟、客户自己的采购日程、写下 - ✅ 一决定就填**是否投标**,没决定就留空。管线评审会把空着的那些当作还没做的决定来读。 - ✅ 听到客户的招标日期当天就把它记下来。投标团队按这个日期排工作,而**本季度预计招标**只能列出填了日期的交易。 - ✅ 关单被拒绝时,先看**状态变更审批**的值再去问人:*审批中*意味着应改为填写**申请变更状态**,*已驳回*是需要谈一谈的结论。 +- ✅ 申请立项审批之前,先把商机叙述和客户的日程填好。审批人读的就是这些。 +- ✅ 变更阶段或填写**是否投标**被拒绝时,看一下**立项审批**:*审批中*意味着应勾选**申请立项审批**(或等待已经发出的那条申请),*已驳回*是需要谈一谈的结论。 - ⛔ 不要把**预计成交日期**改成和招标日期一样。两者是不同的事件,而预测依赖的是你自己的预计成交日期。 ## 给管理员的建议 @@ -85,3 +111,6 @@ description: 这笔交易值不值得跟、客户自己的采购日程、写下 - 这道闸门**默认关闭**,打开的方式是把商机**状态变更审批**字段的默认值从*无需审批*改成*审批中*。从那时起,每笔新交易一创建就处于*审批中*;已有的交易保持*无需审批*。申请会路由给 `sales_manager` 岗位的成员;背后的流程叫**商机状态变更审批**,列在[系统管理 › 自动化](/zh-Hans/docs/administration/automation)里。 - 打开闸门之前先把那个岗位配上人。路由到空岗位的审批没有人能做决定,而等待它的交易会一直被锁定。 - 由集成写入、没有任何人登录的申请,同样会发起审批。不过,拒绝直接关单只针对已登录用户的编辑:直接写入阶段的系统写入不会被拦下。 +- 立项审批的打开方式相同,只是在它自己的字段上:把商机**立项审批**字段的默认值从*无需审批*改成*审批中*。从那时起,每笔新交易一创建就处于*审批中*;已有的交易保持*无需审批*。申请同样路由给 `sales_manager` 岗位,背后的流程叫**商机立项审批**。它挡住的那几项,同样只针对已登录用户的编辑。 +- 两道闸门可以分别打开;两道都打开时,立项排在前面。它们各自把结论记在自己的字段里,都不会改变按金额分级的**大额商机审批**。 +- 一笔交易同一时间只能有一条进行中的审批。如果交易在立项申请等待期间金额涨过了大额门槛,金额签核当时不会发起;它会在这笔交易下一次保存时发起,而立项审批的结论本身就是一次保存。 diff --git a/content/docs/sales/opportunity-qualification.zh-Hant.mdx b/content/docs/sales/opportunity-qualification.zh-Hant.mdx index 0c59351d..4aeac821 100644 --- a/content/docs/sales/opportunity-qualification.zh-Hant.mdx +++ b/content/docs/sales/opportunity-qualification.zh-Hant.mdx @@ -1,17 +1,18 @@ --- title: 商機資格評估 -description: 這筆交易值不值得跟、客戶自己的採購日程、寫下來的商機論證,以及宣布贏單或丟單之前可選的那道簽核。 +description: 這筆交易值不值得跟、客戶自己的採購日程、寫下來的商機論證,以及兩道可選的簽核——新商機推進之前的立項審批,和宣布贏單或丟單之前的審批。 --- # 資格評估、客戶的日程,以及對結果的簽核 本應用未隨附繁體中文語言包,因此本頁出現的介面名詞依固定順序取用:zh-CN 語言包已收錄者,採其用詞的繁體寫法;未收錄者,保留產品內的英文原名,不另造譯名。 -商機上本來就寫著這筆交易值多少、在*你的*管線裡走到了哪一步。記錄上還有四樣東西,回答的是那些數字回答不了的問題: +商機上本來就寫著這筆交易值多少、在*你的*管線裡走到了哪一步。記錄上還有五樣東西,回答的是那些數字回答不了的問題: - **資格評估**——這筆交易要不要跟,花多大力氣跟? - **客戶的日程**——買方打算什麼時候立項、招標、簽約,各是多少金額。 - **商機敘述**——這筆交易的書面論證,拆成評審人可以逐段讀的幾部分。 +- **立項審批**——新商機在更新階段、決定是否投標或贏丟單之前,是否需要先通過立項。**除非管理員打開,否則它是關著的。** - **狀態變更審批**——宣布贏單或丟單之前是否需要有人簽核。**除非管理員打開,否則它是關著的。** 這些都在商機的 *Details* 標籤頁上,分別位於**資格評估**、**銷售流程**和**商機敘述**三個分區裡。 @@ -52,6 +53,29 @@ description: 這筆交易值不值得跟、客戶自己的採購日程、寫下 **類型**說的是這筆交易和客戶是什麼關係——新業務、升級、續約、擴展。**業務線**回答的是另一個問題:這筆交易*屬於哪一類業務*——**產品**、**專業服務**、**諮詢服務**、**運維支援**或**其他**。起始清單是通用的;管理員可以把它換成你們自己的業務線。 +## 立項審批 [#qualification-approval] + +有些組織要先對一筆交易立項、批准之後,才願意為它投入。立項通過之前,新商機照樣可以**跟進**:記錄電話和拜訪、撰寫商機敘述、登記客戶的日程和金額。要等審批的是**推進**它的那幾步:變更階段、填寫**是否投標**,以及贏單或丟單——不論是直接關單,還是透過**申請變更狀態**去申請。商機上的**立項審批**欄位記錄這件事走到哪一步: + +| 立項審批 | 含義 | +| --- | --- | +| **無需審批** | 不要求立項審批。這是 HotCRM 的出廠狀態,除非管理員打開了閘門,否則每筆交易都是這個值。 | +| **審批中** | 這筆交易的閘門是開著的,還沒有通過立項。 | +| **已批准** | 立項已通過。從此可以變更階段、填寫是否投標、贏單或丟單。 | +| **已駁回** | 審批人否決了上一次申請。交易仍然被擋著。 | + +這個欄位不由你填寫——它由審批流程寫入,是唯讀的。 + +**只要交易處在「審批中」或「已駁回」,它的階段、是否投標和申請變更狀態就不能改。** 要讓它通過: + +1. 在交易的**資格評估**分區裡勾選**申請立項審批**。 +2. 審批收件匣裡會出現一條審批請求。等待期間交易**不會**被鎖定:除了上面那三項,其他內容都照常可以編輯,跟進不必停。 +3. 獲批後,這筆交易就一直處於已立項狀態;被駁回時,勾選會被取消,交易仍然被擋著——情況有變時再勾選一次即可重新申請。 + +> **管理員不打開閘門,你這邊什麼都不會變。** 出廠狀態下每筆交易都顯示*無需審批*,變更階段、填寫是否投標和關單都和以前完全一樣。閘門打開之前建立的交易永遠不會被它擋住。 + +**兩道審批都打開時,立項排在前面。** 還沒立項的交易也不能申請狀態變更;立項通過之後,關單就按下一節說的方式進行。 + ## 什麼時候關單需要審批 宣布一筆交易贏單或丟單,會牽動預測、提成和規劃,而且無法撤回。有些組織希望無論金額大小,都要對這一刻簽核。商機上的**狀態變更審批**欄位記錄這件事走到哪一步: @@ -78,6 +102,8 @@ description: 這筆交易值不值得跟、客戶自己的採購日程、寫下 - ✅ 一決定就填**是否投標**,沒決定就留空。管線評審會把空著的那些當作還沒做的決定來讀。 - ✅ 聽到客戶的招標日期當天就把它記下來。投標團隊按這個日期排工作,而**本季度預計招標**只能列出填了日期的交易。 - ✅ 關單被拒絕時,先看**狀態變更審批**的值再去問人:*審批中*意味著應改為填寫**申請變更狀態**,*已駁回*是需要談一談的結論。 +- ✅ 申請立項審批之前,先把商機敘述和客戶的日程填好。審批人讀的就是這些。 +- ✅ 變更階段或填寫**是否投標**被拒絕時,看一下**立項審批**:*審批中*意味著應勾選**申請立項審批**(或等待已經發出的那條申請),*已駁回*是需要談一談的結論。 - ⛔ 不要把**預計成交日期**改成和招標日期一樣。兩者是不同的事件,而預測依賴的是你自己的預計成交日期。 ## 給管理員的建議 @@ -87,3 +113,6 @@ description: 這筆交易值不值得跟、客戶自己的採購日程、寫下 - 這道閘門**預設關閉**,打開的方式是把商機**狀態變更審批**欄位的預設值從*無需審批*改成*審批中*。從那時起,每筆新交易一建立就處於*審批中*;已有的交易保持*無需審批*。申請會路由給 `sales_manager` 職位的成員;背後的流程叫**商機狀態變更審批**,列在[系統管理 › 自動化](/zh-Hant/docs/administration/automation)裡。 - 打開閘門之前先把那個職位配上人。路由到空職位的審批沒有人能做決定,而等待它的交易會一直被鎖定。 - 由整合寫入、沒有任何人登入的申請,同樣會發起審批。不過,拒絕直接關單只針對已登入使用者的編輯:直接寫入階段的系統寫入不會被攔下。 +- 立項審批的打開方式相同,只是在它自己的欄位上:把商機**立項審批**欄位的預設值從*無需審批*改成*審批中*。從那時起,每筆新交易一建立就處於*審批中*;已有的交易保持*無需審批*。申請同樣路由給 `sales_manager` 職位,背後的流程叫**商機立項審批**。它擋住的那幾項,同樣只針對已登入使用者的編輯。 +- 兩道閘門可以分別打開;兩道都打開時,立項排在前面。它們各自把結論記在自己的欄位裡,都不會改變按金額分級的**大額商機審批**。 +- 一筆交易同一時間只能有一條進行中的審批。如果交易在立項申請等待期間金額漲過了大額門檻,金額簽核當時不會發起;它會在這筆交易下一次儲存時發起,而立項審批的結論本身就是一次儲存。 diff --git a/docs/STATUS.md b/docs/STATUS.md index f36cf7bb..95611aae 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,9 +19,9 @@ loader registers: ```text HotCRM v3.1.0 -Data: 18 Objects 361 Fields +Data: 18 Objects 363 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions -Logic: 31 Flows +Logic: 32 Flows Security: 12 Positions 7 Permissions ``` diff --git a/objectstack.composition.ts b/objectstack.composition.ts index ebde1666..8a4f26e4 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -102,7 +102,7 @@ import { AccountApprovalFlow, LeadConversionFlow, LeadConversionApprovalFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, - OpportunityStatusChangeApprovalFlow, + OpportunityStatusChangeApprovalFlow, OpportunityQualificationApprovalFlow, OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow, TaskDueReminderFlow, TaskUrgentAlertFlow, BillingHandoffClosedWonFlow, } from './src/sales/flows/index.js'; @@ -225,6 +225,7 @@ export const allFlows = [ OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, OpportunityStatusChangeApprovalFlow, + OpportunityQualificationApprovalFlow, QuoteGenerationFlow, ContractRenewalFlow, CaseSlaMonitorFlow, diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index f0b7c2f0..a336378f 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -20,6 +20,7 @@ export { AccountApprovalFlow } from './account-approval.flow'; export { ScheduleFollowUpFlow } from './schedule-followup.flow'; export { OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow } from './opportunity-approval.flow'; export { OpportunityStatusChangeApprovalFlow } from './opportunity-status-change-approval.flow'; +export { OpportunityQualificationApprovalFlow } from './opportunity-qualification-approval.flow'; export { OpportunityStagnationFlow } from './opportunity-stagnation.flow'; export { ForecastSnapshotFlow } from './forecast-snapshot.flow'; export { LeadAssignmentFlow } from './lead-assignment.flow'; diff --git a/src/sales/flows/opportunity-qualification-approval.flow.ts b/src/sales/flows/opportunity-qualification-approval.flow.ts new file mode 100644 index 00000000..e2035981 --- /dev/null +++ b/src/sales/flows/opportunity-qualification-approval.flow.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { P } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +type Flow = Automation.Flow; + +/** + * Opportunity Qualification Approval — the 立项 sign-off a new deal needs + * before its stage, its bid decision or its won/lost call may change. + * + * REQ-0006 step 11: 「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、投标、 + * 赢丢单操作。」 Expressed as an **approval node** (`type: 'approval'`, ADR-0019), + * the construct `opportunity-approval.flow.ts` already uses — ⛔ there is no + * `workflow` metadata type and no standalone `ApprovalProcess` to author. + * + * Built on `opportunity-status-change-approval.flow.ts` (REQ-0006 step 14) and + * deliberately the same in every load-bearing term: the switch is a verdict + * field's `defaultValue`, the rep writes a REQUEST and the flow opens one + * approval when the request is NEW, `rejected` re-opens on a new request, + * `runAs: 'system'`, `onEmptyApprovers: 'admin_rescue'`. The refusal of the + * three gated acts lives beside the step-14 refusal in `opportunity.hook.ts`'s + * `opportunity_lifecycle`. Where it differs, the reason is written beside it + * (`lockRecord`). + * + * ## ⚠️ This flow is INERT until an install arms the gate, and that is by design + * + * REQ-0006 *Disposition*: "**Both gates must be configurable — off by + * default**, so existing installs keep today's amount-only behaviour." + * + * The switch is `crm_opportunity.qualification_approval_status`'s + * `defaultValue`, ⛔ not this flow's `status` (`draft` still fires triggers and + * `obsolete` is a gate no install can turn ON — measured and recorded in + * `lead-conversion-approval.flow.ts`; ⛔ do not re-derive it here). Shipped, + * that default is `not_required`, so no deal is ever born `pending`, the start + * condition below is false for every record that has ever existed, and this + * flow opens nothing — it costs one predicate evaluation per update. An + * install arms the gate by changing that one value to `'pending'`. + * + * ## With the status-change gate armed too + * + * 立项 comes first: while this verdict is `pending` or `rejected`, + * `opportunity_lifecycle` refuses a new `requested_status`, so no status-change + * approval can be asked for. Once 立项 is `approved` this flow is out of reach + * for good, and the status-change gate behaves exactly as it does alone. This + * flow's own writes carry neither `stage` nor `requested_status`, so they can + * trip neither gate; both columns sit in the hook's APPROVAL_FIELDS so the + * closed-deal freeze cannot refuse them either. + * + * ⚠️ The platform holds ONE pending approval per record (`openNodeRequest` + * throws `DUPLICATE_REQUEST` otherwise, `@objectstack/plugin-approvals` + * 17.6.0), so the opportunity gates never stack on one deal: a request a + * second gate would open while another is pending is refused, not queued. + * Measured on a 17.6.0 boot with this gate armed: a deal raised past + * `LARGE_DEAL_AMOUNT` while its 立项 request waited failed + * `opportunity_approval`'s run with that error (logged, nothing opened); the + * amount approval opened on the next write, which was this flow's own verdict + * stamp, because the amount flow's entry tests the current value. + */ +export const OpportunityQualificationApprovalFlow: Flow = { + name: 'opportunity_qualification_approval', + label: 'Opportunity Qualification Approval', + description: + 'The 立项 sign-off a deal needs before its stage, bid decision or won/lost call may change. Inert unless the install arms the gate on crm_opportunity.qualification_approval_status.', + type: 'record_change', + status: 'active', + // The reading both sibling gates record from `opportunity_approval`'s + // measured failure: a gate that engages only for writers carrying a session + // is not a control. The verdict writes below also land on a column the + // readonly strip protects, which only a platform write reaches. + runAs: 'system', + + variables: [ + { name: 'opportunityId', type: 'text', isInput: true, isOutput: false }, + ], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'crm_opportunity', + triggerType: 'record-after-update', + // TOTALITY (AGENTS.md — validation predicates must be TOTAL): `has()` + // on every read; the absent case reads as "not gated". + // + // Three halves, the status-change gate's own: + // + // - the gate is ARMED: `pending` or `rejected`. `rejected` re-opens on a + // new request because the hook refuses the gated acts in both states — + // if only `pending` entered here, a rejected deal could never qualify. + // - the request is ticked; + // - the request is NEW on this write. TRANSITION, not current value: + // the approval node's `pending` stamp through `approvalStatusField` is + // an update of this record while the request is still ticked, and a + // current-value test re-fires on it. Measured on a 17.6.0 boot with + // this term deleted: one re-entry per request, caught only by the + // engine's self-trigger guard ("the guard as authored does not + // exclude the flow's own write-back"); with it, none. `previous.*` is + // guarded FAIL-CLOSED: no visible prior value, no visible new request. + // + // `mark_qualified` leaves the gate `approved` (out of reach) and + // `clear_request` unticks the request, so neither re-enters. + condition: P`has(record.qualification_approval_status) + && (record.qualification_approval_status == "pending" || record.qualification_approval_status == "rejected") + && has(record.qualification_requested) && record.qualification_requested == true + && has(previous.qualification_requested) && previous.qualification_requested != true`, + }, + }, + { + id: 'get_opportunity', + type: 'get_record', + label: 'Get Opportunity', + config: { objectName: 'crm_opportunity', filter: { id: '{record.id}' }, outputVariable: 'oppRecord' }, + }, + { + id: 'qualification_review', + type: 'approval', + label: 'Qualification Review', + config: { + // The customer's chain is 销售负责人 / 事业部审批岗 → 事业部负责人; the generic + // shape core ships is the manager bench both sibling gates route to. A + // second tier, or a different position, is overlay configuration + // (REQ-0006: the named approval chains are C). + approvers: [{ type: 'position', value: 'sales_manager' }], + // Explicit though it is the schema default, as every sibling authors + // it: an empty bench would leave the request undecidable — here, a + // deal that can never move its stage. + onEmptyApprovers: 'admin_rescue', + behavior: 'first_response', + // ⚠️ `false`, NOT the status-change gate's `true` — the one term this + // node deliberately takes from the LEAD gate instead. Step 11 says it + // in as many words: 「新增商机可跟进」. A deal awaiting 立项 is still being + // worked — the narrative, the customer calendar and the amount are + // what the approver reads — and locking it would stop that work for as + // long as the approver takes. The three acts the gate exists to hold + // are refused by `opportunity_lifecycle` whether or not a request is + // open, so the lock would add nothing but the freeze. + lockRecord: false, + approvalStatusField: 'qualification_approval_status', + }, + }, + { + id: 'mark_qualified', + type: 'update_record', + label: 'Mark Qualified', + config: { + objectName: 'crm_opportunity', + filter: { id: '{record.id}' }, + fields: { qualification_approval_status: 'approved' }, + }, + }, + { + // A rejected request is cleared, not left standing, and the verdict stays + // `rejected` so the hook keeps refusing the gated acts: the way on is a + // new request, which the start condition opens as a fresh approval. + id: 'clear_request', + type: 'update_record', + label: 'Clear Rejected Request', + config: { + objectName: 'crm_opportunity', + filter: { id: '{record.id}' }, + fields: { qualification_approval_status: 'rejected', qualification_requested: false }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'get_opportunity', type: 'default' }, + { id: 'e2', source: 'get_opportunity', target: 'qualification_review', type: 'default' }, + // Approval-node branch labels. + { id: 'e3', source: 'qualification_review', target: 'mark_qualified', type: 'default', label: 'approve' }, + { id: 'e4', source: 'qualification_review', target: 'clear_request', type: 'default', label: 'reject' }, + { id: 'e5', source: 'mark_qualified', target: 'end', type: 'default' }, + { id: 'e6', source: 'clear_request', target: 'end', type: 'default' }, + ], +}; diff --git a/src/sales/objects/opportunity.hook.ts b/src/sales/objects/opportunity.hook.ts index 54c78eeb..ac0454f5 100644 --- a/src/sales/objects/opportunity.hook.ts +++ b/src/sales/objects/opportunity.hook.ts @@ -95,9 +95,17 @@ const opportunityValidationHook: Hook = { // reached a closed stage, and elevation is not anonymity — the freeze // guard below would judge those writes as user edits on a closed record // and re-lock an in-flight approval. + // `qualification_approval_status` and `qualification_requested` (the + // REQ-0006 step-11 立项 gate) join for the same reason and by the same + // mechanism. Their flow stamps the verdict, and on a rejection clears the + // request, under the triggering user; a deal a write this hook does not + // judge (a system write, no user) closed while the 立项 request was open + // would otherwise refuse that stamp and leave the approval decided but + // never recorded. const APPROVAL_FIELDS = new Set([ 'approval_status', 'approved_date', 'status_change_approval_status', 'requested_status', + 'qualification_approval_status', 'qualification_requested', ]); // Stage → forecast category. const STAGE_FORECAST: Record = { @@ -113,6 +121,56 @@ const opportunityValidationHook: Hook = { const { event, input } = ctx; const previous = ctx.previous; + // ─── 立项 (qualification) gate (REQ-0006 step 11) ─────────────────── + // + // 「新增商机可跟进,立项通过后方可更新阶段、投标、赢丢单操作。」 Until 立项 is + // approved, three acts are refused and every other edit stays open: + // • any change of `stage` — 更新阶段, which includes a direct close + // (赢丢单); + // • recording `will_bid` — 投标, the bid decision; + // • a NEW `requested_status` — the won/lost request (赢丢单) the step-14 + // gate opens. So with both gates armed 立项 comes FIRST: an unqualified + // deal cannot even ask for a status change. + // Built on the step-14 gate below and deliberately the same in every + // load-bearing term: + // • a TRANSITION GATE, not an invariant (AGENTS.md metadata semantics + // rule 7) — inert unless the install arms it, since + // `qualification_approval_status` ships `not_required`; + // • the verdict is read INPUT-FIRST, so a write that carries the + // approved verdict is judged as approved. This gate's own flow writes + // only the verdict (and on a rejection the request), so it never needs + // that; it keeps the sibling's reading, and a hand-supplied verdict is + // stripped before this hook runs (see the field); + // • `rejected` refuses as `pending` does — an approver's "no" is not a + // release; the way on is a new request; + // • USER writes only (`ctx.user?.id`), the boundary both other guards in + // this hook draw; + // • RECORD_LOCKED / 409 (`REFUSAL_CODES.locked`). + // It sits ABOVE the step-14 block so an unqualified deal's direct close + // names the gate that comes first. + // + // ⚠️ BOUNDARY, recorded rather than hidden: `beforeInsert` is not judged. + // Creating a deal is 新增商机, which step 11 leaves open (step 8 has the rep + // fill 是否投标 on the new-deal form) — the line the step-14 gate draws too. + if (event === 'beforeUpdate' && previous && ctx.user?.id) { + const qualification = (input.qualification_approval_status ?? previous.qualification_approval_status) as string | undefined; + if (qualification === 'pending' || qualification === 'rejected') { + const held: string[] = []; + if (typeof input.stage === 'string' && input.stage !== previous.stage) held.push('Stage'); + if (typeof input.will_bid === 'boolean' && input.will_bid !== previous.will_bid) held.push('Will Bid'); + if (typeof input.requested_status === 'string' && input.requested_status !== '' && input.requested_status !== previous.requested_status) { + held.push('Requested Status'); + } + if (held.length > 0) { + throw refuse( + `This deal needs qualification approval first: tick Request Qualification Approval. ${held.join(', ')} can change once it is approved.`, + 'RECORD_LOCKED', + 409, + ); + } + } + } + // ─── Status-change gate (REQ-0006 steps 13-14) ────────────────────── // // A TRANSITION GATE, not an invariant (AGENTS.md metadata semantics rule diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index 922fdc72..1a669f0d 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -163,6 +163,66 @@ export const Opportunity = ObjectSchema.create({ group: 'qualification', }), + // ─── The 立项 (qualification) gate: the rep's request ───────────── + // + // REQ-0006 step 11: 「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、 + // 投标、赢丢单操作。」 The rep ASKS for 立项 by ticking this box, and + // `opportunity_qualification_approval` opens one approval when it turns on + // (the TRANSITION, not the current value). A rejection clears it, so + // ticking it again is a fresh request — the role `requested_status` plays + // for the status-change gate, as a boolean because 立项 asks for one thing. + // + // `defaultValue: false` is load-bearing for the start condition: every deal + // born after this column then STORES the key, so `has(previous.…)` holds + // on every driver and the off→on transition is visible. Not readonly: this + // is the one half of the gate a person writes. Inert while the gate is + // off — nothing reads it unless an install arms the verdict below. + qualification_requested: Field.boolean({ + label: 'Request Qualification Approval', + description: 'Tick to send this deal for qualification approval. A rejection clears it; tick it again to ask again.', + defaultValue: false, + group: 'qualification', + }), + + // ─── The 立项 gate's switch AND its verdict column ───────────────── + // + // ⚠️ This `defaultValue` IS the switch, exactly as on + // `status_change_approval_status` below, and shipped it is `not_required`: + // the gate is OFF by default (REQ-0006 *Disposition*: "Both gates must be + // configurable — off by default"). No deal is born `pending`, the flow's + // start condition and the `opportunity_lifecycle` refusal are false for + // every record that has ever existed, and a stage move, Will Bid and a + // direct close work exactly as they do without it. An install ARMS the gate + // by changing this one value to `'pending'`; deals that already exist keep + // `not_required`, so arming it invalidates no record. ⛔ Not the flow's + // `status` — `lead-conversion-approval.flow.ts` records why. + // + // ⚠️ Its OWN column — ⛔ never `approval_status` (the amount-tiered + // flow's; its entry keys on `not_required`, so sharing would re-arm it) and + // ⛔ never `status_change_approval_status` (one gate's verdict would erase + // the other's, and the step-14 hook reads that column input-first). + // + // `readonly: true` on the #1666 grounds: the only writers are the gate's + // own `runAs: 'system'` flow and the insert default, and both survive the + // readonly strip. A user-supplied verdict is stripped BEFORE the + // `beforeUpdate` hooks run — measured on 17.6.0 with a real ObjectQL: a + // user update of `{ stage, qualification_approval_status: 'approved' }` + // reached the hooks as `{ id, stage }` and was refused 409 — so the + // input-first read in `opportunity_lifecycle` cannot be fed an `approved` + // by hand. + qualification_approval_status: Field.select({ + label: 'Qualification Approval', + group: 'qualification', + readonly: true, + defaultValue: 'not_required', + options: [ + { label: 'Not Required', value: 'not_required', default: true }, + { label: 'Pending', value: 'pending', color: '#FFA500' }, + { label: 'Approved', value: 'approved', color: '#00AA00' }, + { label: 'Rejected', value: 'rejected', color: '#FF0000' }, + ], + }), + // Sales Process stage: Field.select({ label: 'Stage', diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 38f7a1cf..588af4d6 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -320,6 +320,15 @@ export const pipeline: Record = { label: 'Status Change Approval', options: { not_required: 'Not Required', pending: 'Pending', approved: 'Approved', rejected: 'Rejected' }, }, + // REQ-0006 step 11 — the 立项 (qualification) approval gate. + qualification_requested: { + label: 'Request Qualification Approval', + help: 'Tick to send this deal for qualification approval. A rejection clears it; tick it again to ask again.', + }, + qualification_approval_status: { + label: 'Qualification Approval', + options: { not_required: 'Not Required', pending: 'Pending', approved: 'Approved', rejected: 'Rejected' }, + }, }, _views: { tender_this_quarter: { diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index 5f33aa51..cd7e8f0d 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -358,6 +358,18 @@ export const pipeline: Record = { approved: 'Aprobada', rejected: 'Rechazada', }, }, + // REQ-0006 paso 11 — la aprobación de calificación (立项). + qualification_requested: { + label: 'Solicitar Aprobación de Calificación', + help: 'Márquelo para enviar este negocio a aprobación de calificación. Un rechazo lo desmarca; vuelva a marcarlo para solicitarla de nuevo.', + }, + qualification_approval_status: { + label: 'Aprobación de Calificación', + options: { + not_required: 'No Requerida', pending: 'Pendiente', + approved: 'Aprobada', rejected: 'Rechazada', + }, + }, }, _views: { tender_this_quarter: { diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 7ac57f03..0c766da7 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -315,6 +315,15 @@ export const pipeline: Record = { label: 'ステータス変更承認', options: { not_required: '承認不要', pending: '承認待ち', approved: '承認済み', rejected: '却下' }, }, + // REQ-0006 ステップ 11 — 案件化(立项)承認。 + qualification_requested: { + label: '案件化承認を申請', + help: 'チェックするとこの商談を案件化承認に回します。却下されるとチェックが外れます。再度チェックすると改めて申請できます。', + }, + qualification_approval_status: { + label: '案件化承認', + options: { not_required: '承認不要', pending: '承認待ち', approved: '承認済み', rejected: '却下' }, + }, }, _views: { tender_this_quarter: { diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index ebfa168e..7b1e82c2 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -330,6 +330,15 @@ export const pipeline: Record = { label: '状态变更审批', options: { not_required: '无需审批', pending: '审批中', approved: '已批准', rejected: '已驳回' }, }, + // REQ-0006 第 11 步 — 立项审批。 + qualification_requested: { + label: '申请立项审批', + help: '勾选后提交立项审批。审批被驳回时会自动取消勾选;再次勾选即可重新申请。', + }, + qualification_approval_status: { + label: '立项审批', + options: { not_required: '无需审批', pending: '审批中', approved: '已批准', rejected: '已驳回' }, + }, }, _views: { tender_this_quarter: { diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index c9c209f7..23ad60a6 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -238,6 +238,9 @@ const ROW_LABEL: Record> = { // REQ-0006: 状态变更, the wording of the field that arms it // (`status_change_approval_status`, 状态变更审批 in the zh-CN pack). opportunity_status_change_approval: { 'zh-Hans': '商机状态变更审批', 'zh-Hant': '商機狀態變更審批' }, + // REQ-0006 step 11: 立项, the wording of the field that arms it + // (`qualification_approval_status`, 立项审批 in the zh-CN pack). + opportunity_qualification_approval: { 'zh-Hans': '商机立项审批', 'zh-Hant': '商機立項審批' }, opportunity_won_alert: { 'zh-Hans': '大额商机赢单提醒', 'zh-Hant': '大額商機贏單提醒' }, case_escalation: { 'zh-Hans': '工单升级流程', 'zh-Hant': '工單升級流程' }, case_escalation_on_create: { diff --git a/test/opportunity-qualification-approval-gate.test.ts b/test/opportunity-qualification-approval-gate.test.ts new file mode 100644 index 00000000..98ec71e8 --- /dev/null +++ b/test/opportunity-qualification-approval-gate.test.ts @@ -0,0 +1,378 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config'; +import opportunityHooks from '../src/sales/objects/opportunity.hook'; +import { OpportunityApprovalFlow } from '../src/sales/flows/opportunity-approval.flow'; +import { OpportunityStatusChangeApprovalFlow } from '../src/sales/flows/opportunity-status-change-approval.flow'; +import { OpportunityQualificationApprovalFlow } from '../src/sales/flows/opportunity-qualification-approval.flow'; +import { hookNamed, makeCtx, makeHarness } from './helpers/hook-harness'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * The 立项 (qualification) gate, REQ-0006 step 11: + * 「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、投标、赢丢单操作。」 + * + * The sibling of `test/opportunity-status-change-approval-gate.test.ts`, and + * built the same way, because the gate is: three surfaces that must agree — + * `crm_opportunity.qualification_approval_status`'s default (the switch), + * `opportunity_qualification_approval`'s start condition, and the + * `beforeUpdate` refusal in `opportunity_lifecycle`. A gate that is off on two + * surfaces and on for the third is worse than one that is simply on. + * + * What this file adds over its sibling is the DESIGN POINT of the card: two + * gates on one record. The matrix block at the bottom drives every + * combination (off/off, on/off, off/on, on/on, plus 立项 approved) through + * the same four acts, and states which gate answers each — so "立项 comes + * first" and "once 立项 is approved the status-change gate behaves exactly as + * today" are executable rather than prose. + */ + +type AnyRec = Record; + +const objects: AnyRec[] = (stack as any).objects ?? []; +const opportunity = objects.find((o) => o.name === 'crm_opportunity') as AnyRec | undefined; + +/** Evaluate a flow condition exactly as the engine does (cf. flow-record-change). */ +function conditionHolds(condition: unknown, vars: Record): boolean { + const h = makeFlowHarness({}, {}); + const engine = h.engine as unknown as { + evaluateCondition(c: unknown, v: Map): boolean; + }; + const expr = typeof condition === 'string' ? { dialect: 'cel', source: condition } : condition; + return engine.evaluateCondition(expr, new Map(Object.entries(vars))); +} + +const FLOW = OpportunityQualificationApprovalFlow; +const startNode = (FLOW.nodes as Rec[]).find((n) => n.id === 'start'); +const startCondition = startNode?.config?.condition; +const reviewNode = (FLOW.nodes as Rec[]).find((n) => n.type === 'approval'); + +/** A request arriving on this write: the box was unticked, now it is ticked. */ +const requesting = (gate: AnyRec) => ({ + record: { id: 'o1', stage: 'prospecting', ...gate, qualification_requested: true }, + previous: { id: 'o1', stage: 'prospecting', ...gate, qualification_requested: false }, +}); + +/** The verdict column in every shape a real record can present it OFF in. */ +const OFF_SHAPES: [string, AnyRec][] = [ + ['the shipped default', { qualification_approval_status: 'not_required' }], + ['a qualified deal', { qualification_approval_status: 'approved' }], + ['a deal older than the column', {}], + ['an explicit null', { qualification_approval_status: null }], +]; +/** …and the two shapes an ARMED gate holds in. */ +const ARMED_SHAPES: [string, AnyRec][] = [ + ['awaiting 立项', { qualification_approval_status: 'pending' }], + ['refused 立项 by an approver', { qualification_approval_status: 'rejected' }], +]; + +describe('the gate ships OFF — the field default is the switch', () => { + it('found the opportunity object and both gate columns, in the qualification group', () => { + expect(opportunity, 'crm_opportunity missing from the stack').toBeTruthy(); + for (const k of ['qualification_approval_status', 'qualification_requested']) { + expect(opportunity?.fields?.[k], `${k} is gone`).toBeTruthy(); + expect(opportunity?.fields?.[k]?.group).toBe('qualification'); + } + }); + + it('defaults to not_required, at FIELD level, and only the platform writes it', () => { + const f = opportunity!.fields.qualification_approval_status as AnyRec; + // `'pending'` here would arm the gate for every new deal of every install — + // the one value this pin exists for. + expect(f.defaultValue).toBe('not_required'); + expect(f.readonly, 'a user-writable verdict is not a verdict').toBe(true); + // The request is the half a person writes, and it is STORED unticked on + // every new deal, so the start condition's `has(previous.…)` holds on + // every driver. + const req = opportunity!.fields.qualification_requested as AnyRec; + expect(req.readonly).not.toBe(true); + expect(req.defaultValue).toBe(false); + }); + + it('is a transition gate, not an invariant: no validation re-states it', () => { + const rules = (opportunity?.validations ?? []) as AnyRec[]; + const restating = rules.filter((r) => /qualification_approval_status|qualification_requested/.test(JSON.stringify(r))); + expect(restating.map((r) => r.name)).toEqual([]); + }); + + it('has its own verdict column — neither sibling gate shares it', () => { + expect(reviewNode?.config?.approvalStatusField).toBe('qualification_approval_status'); + const siblings = [OpportunityApprovalFlow, OpportunityStatusChangeApprovalFlow] + .flatMap((f) => (f.nodes as Rec[]).filter((n) => n.type === 'approval')) + .map((n) => n.config?.approvalStatusField); + expect(siblings).toEqual(['approval_status', 'approval_status', 'status_change_approval_status']); + }); +}); + +describe('opportunity_qualification_approval — start condition', () => { + it('is an update trigger on crm_opportunity, elevated, and leaves the deal OPEN while it waits', () => { + expect(FLOW.name).toBe('opportunity_qualification_approval'); + expect(FLOW.type).toBe('record_change'); + expect(FLOW.runAs).toBe('system'); + expect(startNode?.config?.objectName).toBe('crm_opportunity'); + expect(startNode?.config?.triggerType).toBe('record-after-update'); + expect(reviewNode?.config?.approvers).toEqual([{ type: 'position', value: 'sales_manager' }]); + expect(reviewNode?.config?.onEmptyApprovers).toBe('admin_rescue'); + // 「新增商机可跟进」: the platform lock would refuse every user edit while + // the request is open; the three gated acts are the hook's job either way. + expect(reviewNode?.config?.lockRecord).toBe(false); + }); + + it.each(OFF_SHAPES)('opens NO approval request for %s, even with the box ticked on the write', (_l, shape) => { + expect(conditionHolds(startCondition, requesting(shape))).toBe(false); + }); + + it.each(ARMED_SHAPES)('opens a request for a deal %s when the rep ticks the box', (_l, shape) => { + expect(conditionHolds(startCondition, requesting(shape))).toBe(true); + }); + + it('does not re-open on the approval node\'s own `pending` stamp (TRANSITION, not current value)', () => { + const gate = { qualification_approval_status: 'pending', qualification_requested: true }; + expect(conditionHolds(startCondition, { + record: { id: 'o1', stage: 'prospecting', ...gate }, + previous: { id: 'o1', stage: 'prospecting', ...gate }, + })).toBe(false); + }); + + it('opens nothing on an armed deal while the box stays unticked', () => { + const gate = { qualification_approval_status: 'pending', qualification_requested: false }; + expect(conditionHolds(startCondition, { + record: { id: 'o1', stage: 'prospecting', next_step: 'call back', ...gate }, + previous: { id: 'o1', stage: 'prospecting', ...gate }, + })).toBe(false); + }); + + it('claims no new request when the prior row is invisible (fail-closed, the bulk-update shape)', () => { + expect(conditionHolds(startCondition, { + record: { id: 'o1', qualification_approval_status: 'pending', qualification_requested: true }, + previous: null, + })).toBe(false); + }); + + it('is TOTAL — a deal with neither column reads as "not gated", never as an abort', () => { + expect(conditionHolds(startCondition, { record: { id: 'o1', name: 'Acme' }, previous: { id: 'o1' } })).toBe(false); + }); +}); + +describe('the approval branches leave the gate unable to re-enter, and trip no other gate', () => { + const node = (id: string) => (FLOW.nodes as Rec[]).find((n) => n.id === id); + const edge = (label: string) => (FLOW.edges as Rec[]).find((e) => e.source === reviewNode?.id && e.label === label); + + it('approve stamps `approved` and nothing else — no stage, no request', () => { + expect(edge('approve')?.target).toBe('mark_qualified'); + expect(node('mark_qualified')?.config?.fields).toEqual({ qualification_approval_status: 'approved' }); + }); + + it('reject unticks the request and keeps the verdict `rejected` — still armed, free to ask again', () => { + expect(edge('reject')?.target).toBe('clear_request'); + expect(node('clear_request')?.config?.fields).toEqual({ + qualification_approval_status: 'rejected', qualification_requested: false, + }); + expect(conditionHolds(startCondition, requesting({ qualification_approval_status: 'rejected' }))).toBe(true); + }); +}); + +// ─── the write path ──────────────────────────────────────────────────────── + +const guard = hookNamed(opportunityHooks, 'opportunity_lifecycle'); + +/** `null` = a system write. Not `undefined`: that would select the default. */ +const write = (input: AnyRec, previous: AnyRec, user: { id: string } | null = { id: 'usr_1' }) => + guard.handler( + makeCtx({ + event: 'beforeUpdate', + input: { id: 'opp_1', ...input }, + previous: { id: 'opp_1', name: 'Big Deal', stage: 'qualification', amount: 100, ...previous }, + user: user ?? undefined, + api: makeHarness().api, + }), + ); + +const refusal = (input: AnyRec, previous: AnyRec) => write(input, previous).then(() => null, (e: AnyRec) => e); + +/** The three acts step 11 holds until 立项 — 更新阶段、投标、赢丢单. */ +const HELD_ACTS: [string, AnyRec, string][] = [ + ['a stage move', { stage: 'needs_analysis' }, 'Stage'], + ['a direct close', { stage: 'closed_won', win_reason: 'best_fit' }, 'Stage'], + ['recording the bid decision (yes)', { will_bid: true }, 'Will Bid'], + ['recording the bid decision (no)', { will_bid: false }, 'Will Bid'], + ['a won/lost request', { requested_status: 'closed_lost', loss_reason: 'price' }, 'Requested Status'], +]; + +describe('the write path — opportunity_lifecycle holds three acts until 立项 is approved', () => { + for (const [shapeLabel, shape] of ARMED_SHAPES) { + it.each(HELD_ACTS)(`refuses ${shapeLabel}: %s`, async (_l, input, named) => { + const err = await refusal(input, shape); + expect(err, 'the gate let it through').toBeTruthy(); + // ADR-0112 envelope: the CODE and the STATUS are the contract. + expect(err.code).toBe('RECORD_LOCKED'); + expect(err.status).toBe(409); + // The sentence names the way forward and what was held. + expect(String(err.message)).toContain('Request Qualification Approval'); + expect(String(err.message)).toContain(named); + }); + } + + it.each(OFF_SHAPES)('lets every held act through for %s — today\'s behaviour, unchanged', async (_l, shape) => { + for (const [, input] of HELD_ACTS) await expect(write(input, shape)).resolves.toBeUndefined(); + }); + + it('「新增商机可跟进」: an unqualified deal stays workable short of the three acts', async () => { + const pending = { qualification_approval_status: 'pending', will_bid: true, requested_status: null }; + await expect(write({ + amount: 250, next_step: 'site visit', description: 'kick-off notes', + customer_background: 'listed utility', expected_tender_date: '2030-03-01', controllability: 'high', + // A form save that echoes the unchanged values back is not a move. + stage: 'qualification', will_bid: true, requested_status: null, + }, pending)).resolves.toBeUndefined(); + // …and asking for 立项 is itself an ordinary edit. + await expect(write({ qualification_requested: true }, pending)).resolves.toBeUndefined(); + }); + + it('reads the verdict INPUT-FIRST, as the step-14 gate does', async () => { + // No real user write can carry this: the readonly verdict is stripped from + // a user payload BEFORE beforeUpdate hooks run. The pin is that a write + // which does carry `approved` is judged as approved. + await expect(write({ stage: 'needs_analysis', qualification_approval_status: 'approved' }, + { qualification_approval_status: 'pending' })).resolves.toBeUndefined(); + }); + + it('judges only USER writes — a system write (no user) carries no session to refuse', async () => { + await expect(write({ stage: 'needs_analysis', will_bid: true }, + { qualification_approval_status: 'pending' }, null)).resolves.toBeUndefined(); + }); + + it('the flow\'s own stamps land even on a deal that closed meanwhile (APPROVAL_FIELDS)', async () => { + // A deal can only close before 立项 through a write this hook does not + // judge (no user). The flow stamps the verdict under the triggering user, + // so the closed-deal freeze would judge it as a user edit and refuse it. + for (const stage of ['closed_won', 'closed_lost']) { + const closed = { stage, qualification_approval_status: 'pending', qualification_requested: true }; + await expect(write({ qualification_approval_status: 'approved' }, closed)).resolves.toBeUndefined(); + await expect(write({ qualification_approval_status: 'rejected', qualification_requested: false }, closed)) + .resolves.toBeUndefined(); + } + }); +}); + +// ─── the two gates on one record ─────────────────────────────────────────── + +/** + * Which gate answers which act, for every combination of the two switches. + * `ok` = the write goes through; `立项` = refused by this gate; `status` = + * refused by the step-14 gate. With only one gate armed the other is inert; + * with both, 立项 answers first; once 立项 is approved the status-change + * gate behaves exactly as it does alone. + */ +type Verdict = 'ok' | '立项' | 'status'; +const ACTS: [string, AnyRec][] = [ + ['stage move', { stage: 'needs_analysis' }], + ['bid decision', { will_bid: true }], + ['won/lost request', { requested_status: 'closed_won', win_reason: 'best_fit' }], + ['direct close', { stage: 'closed_won', win_reason: 'best_fit' }], +]; +const MATRIX: [string, AnyRec, Verdict[]][] = [ + ['off/off', { qualification_approval_status: 'not_required', status_change_approval_status: 'not_required' }, + ['ok', 'ok', 'ok', 'ok']], + ['立项 on (pending) / status off', { qualification_approval_status: 'pending', status_change_approval_status: 'not_required' }, + ['立项', '立项', '立项', '立项']], + ['立项 on (rejected) / status off', { qualification_approval_status: 'rejected', status_change_approval_status: 'not_required' }, + ['立项', '立项', '立项', '立项']], + ['立项 approved / status off', { qualification_approval_status: 'approved', status_change_approval_status: 'not_required' }, + ['ok', 'ok', 'ok', 'ok']], + ['立项 off / status on', { qualification_approval_status: 'not_required', status_change_approval_status: 'pending' }, + ['ok', 'ok', 'ok', 'status']], + ['both on, before 立项', { qualification_approval_status: 'pending', status_change_approval_status: 'pending' }, + ['立项', '立项', '立项', '立项']], + ['both on, 立项 approved', { qualification_approval_status: 'approved', status_change_approval_status: 'pending' }, + ['ok', 'ok', 'ok', 'status']], +]; + +const answeredBy = (err: AnyRec | null): Verdict => { + if (!err) return 'ok'; + expect(err.code).toBe('RECORD_LOCKED'); + expect(err.status).toBe(409); + const m = String(err.message); + if (m.includes('qualification approval first')) return '立项'; + if (m.includes('status change needs approval')) return 'status'; + throw new Error(`refused by something else: ${m}`); +}; + +describe('two gates, one deal — the interaction matrix', () => { + it.each(MATRIX)('%s', async (_l, gates, expected) => { + const got: Verdict[] = []; + for (const [, input] of ACTS) got.push(answeredBy(await refusal(input, gates))); + expect(Object.fromEntries(ACTS.map(([a], i) => [a, got[i]]))) + .toEqual(Object.fromEntries(ACTS.map(([a], i) => [a, expected[i]]))); + }); + + it('the step-14 approval\'s own close still lands once 立项 is approved', async () => { + await expect(write( + { stage: 'closed_won', status_change_approval_status: 'approved' }, + { qualification_approval_status: 'approved', status_change_approval_status: 'pending', requested_status: 'closed_won', win_reason: 'best_fit' }, + )).resolves.toBeUndefined(); + }); + + it('the 立项 approval\'s own stamps trip neither the step-14 gate nor the freeze', async () => { + const armed = { qualification_approval_status: 'pending', qualification_requested: true, status_change_approval_status: 'pending' }; + await expect(write({ qualification_approval_status: 'approved' }, armed)).resolves.toBeUndefined(); + await expect(write({ qualification_approval_status: 'rejected', qualification_requested: false }, armed)) + .resolves.toBeUndefined(); + }); + + it('neither flow opens on the other gate\'s request', () => { + // A 立项 tick with only the status-change gate armed opens nothing here… + expect(conditionHolds(startCondition, requesting({ + qualification_approval_status: 'not_required', status_change_approval_status: 'pending', + }))).toBe(false); + // …and a won/lost request with only 立项 armed opens nothing there. + const statusStart = (OpportunityStatusChangeApprovalFlow.nodes as Rec[]).find((n) => n.id === 'start')?.config?.condition; + expect(conditionHolds(statusStart, { + record: { id: 'o1', qualification_approval_status: 'approved', status_change_approval_status: 'not_required', requested_status: 'closed_won' }, + previous: { id: 'o1', qualification_approval_status: 'approved', status_change_approval_status: 'not_required', requested_status: null }, + })).toBe(false); + }); +}); + +/** + * The gate engages for a request written with no session — the reading both + * siblings take from `opportunity-approval.flow.ts`'s measured failure. The + * run is fired with NO trigger user; the counter-proof strips `runAs` back to + * its schema default, direction decided before running it: the stripped run + * must fail at `get_opportunity` with the engine's `[runAs]` refusal. + */ +describe('a request written with no session still reaches the approval', () => { + type Run = { success: boolean; error?: string; summary?: { nodes?: Rec[] } }; + const NAME = 'opportunity_qualification_approval'; + + const deal = { + id: 'o1', name: 'Acme Tender', amount: 50_000, stage: 'prospecting', owner_id: 'rep1', + qualification_approval_status: 'pending', qualification_requested: true, + }; + + async function fire(flow: Rec) { + const h = makeFlowHarness({ [NAME]: flow as never }, { crm_opportunity: [{ ...deal }] }); + return (await (h.engine as unknown as { execute(n: string, c: Rec): Promise }) + .execute(NAME, { params: {}, event: 'record_change', record: deal, previous: { ...deal, qualification_requested: false } })) as Run; + } + const nodeStatus = (r: Run, id: string) => (r.summary?.nodes ?? []).find((n) => n.nodeId === id)?.status; + + it('passes `get_opportunity` and stops only at the approval node', async () => { + const result = await fire(FLOW as unknown as Rec); + expect(String(result.error ?? ''), 'the request is bypassing approval').not.toContain('[runAs]'); + expect(nodeStatus(result, 'get_opportunity')).toBe('success'); + // Harness-only stop: the approval executor ships in + // @objectstack/plugin-approvals, which this in-memory harness does not install. + expect(nodeStatus(result, 'qualification_review')).toBe('failure'); + expect(String(result.error)).toContain("No executor registered for node type 'approval'"); + }); + + it('…and never gets that far once `runAs` is dropped', async () => { + const { runAs: _dropped, ...withoutRunAs } = FLOW as unknown as Rec; + const result = await fire(withoutRunAs); + expect(String(result.error)).toContain('[runAs] refusing a data operation'); + expect(nodeStatus(result, 'get_opportunity')).toBe('failure'); + expect(nodeStatus(result, 'qualification_review'), 'approval was never requested').toBeUndefined(); + }); +}); diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index c0a79854..636b5602 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -138,11 +138,12 @@ describe('every refusal names a code the platform will echo (#1075)', () => { // 18 until REQ-0003 added the account capability gate in // `opportunity.hook.ts`; 19 until #549 added the activated-contract // refusal to `account_protection`; 20 until REQ-0006 added the - // status-change gate to `opportunity_lifecycle`. The number is + // status-change gate to `opportunity_lifecycle`; 21 until REQ-0006 step 11 + // added the 立项 (qualification) gate beside it. 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(21); + expect(sites).toHaveLength(22); }); it('uses only members of the platform ErrorCode enum', () => { diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index 5a4dd609..9f2d79d4 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -96,6 +96,10 @@ const RUNTIME_TEST_FILES = [ // REQ-0006 — the status-change gate: its start condition, both approval // branches, the `opportunity_lifecycle` refusal and the user-less run. 'opportunity-status-change-approval-gate.test.ts', + // REQ-0006 step 11 — the 立项 gate: its start condition, both approval + // branches, the `opportunity_lifecycle` refusal, the two-gate interaction + // matrix and the user-less run. + 'opportunity-qualification-approval-gate.test.ts', // #1828 — the `line_number` assigner on both line items. Same precedent: its // evidence is an ENGINE fact (a hook-written readonly key survives the // non-system strip, a caller-supplied one does not) measured on a real