Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/win-loss-reason-capture-and-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'hotcrm': patch
---

Closing an opportunity now REQUIRES a win or loss reason, and the Sales dashboard finally shows what those reasons say.

`crm_opportunity.win_reason` / `loss_reason` / `loss_details` have existed since
the object was written, and `loss_reason` even carried the comment *"required
when stage moves to closed_\*"* — but nothing required anything. Both columns
were empty on every record, including every seeded one, and no report or
dashboard widget read them. Declared, unenforced, unused.

**Capture.** Both fields carry a `requiredWhen` predicate — `loss_reason` on
`closed_lost`, `win_reason` on `closed_won` — which the engine evaluates inside
`evaluateValidationRules` on insert AND update, and reports against the field so
the form marks the empty picklist. This is server-side: an API call, a data
import or a bulk update that closes a deal without the applicable reason is
rejected exactly like a rep's form is, and the record stays in its previous
stage. Capturing at close time is not a style choice — a closed opportunity is
frozen to its narrative fields, so a reason not recorded in the closing write can
never be added afterwards.

`win_reason` gains one option, **Quote Accepted**. An accepted quote closes its
opportunity automatically (`quote_on_accepted`), and there is no human in that
write to attribute the win; naming the automated path keeps the rule
exception-free and keeps a CPQ close distinguishable from a rep's answer, rather
than stamping a fabricated "Better Product" on it.

**Analytics.** `opportunity_metrics` gains `won_count`, `lost_count`,
`decided_count`, `won_amount`, `lost_amount` (each scoped by its own measure
filter) and `win_rate = won_count / decided_count` as a derived ratio, plus
`win_reason` / `loss_reason` dimensions. The Sales dashboard gains a **Win Rate
(12M)** tile flanked by **Deals Won** and **Deals Lost**, **Win / Loss by Rep**
and **Win / Loss by Lead Source** tables, and a **Why We Lose** loss-reason
breakdown.

The ratio's two halves come from the *measures*, never from a widget filter: a
widget-level `stage` filter narrows numerator and denominator together, which is
how a ratio quietly becomes a division by itself. Every breakdown is a table
showing won, lost and settled counts beside the percentage, so the arithmetic
behind the number is always on screen — the check the quota table shipped
without in #614. Tests perturb one deal at a time (win a lost deal, lose a won
one, add open pipeline) and assert the rate moves, moves the other way, and does
not move, respectively.

**Seeds.** Every settled seeded deal now carries its reason, and three more lost
deals were added so the loss-reason breakdown has five distinct reasons and three
lead sources carry both a win and a loss. Out of the box the demo shows a 62%
win rate over 8 won and 5 lost deals.

**i18n.** `win_reason` and `loss_reason` are now translated in all four locales;
they had been missing from `ja-JP` and `es-ES`, which would have put raw stored
values (`no_budget`, `quote_accepted`) into a picklist a rep is forced to choose
from and into a chart legend.

Fixes #593.
4 changes: 3 additions & 1 deletion content/docs/analytics/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ The sales team's daily operational dashboard.
- **Pipeline by Stage** — funnel chart showing $ by stage.
- **Forecast vs Quota** — gauge.
- **Closed Won this Quarter** — running total with target line.
- **Win Rate (last 90 days)** — percentage with trend.
- **Win Rate (12M)** — deals won as a share of deals *settled* (won + lost) in the last 12 months, shown next to **Deals Won** and **Deals Lost** so the two numbers behind the percentage are always on screen. Open pipeline is not in the denominator.
- **Win / Loss by Rep** and **Win / Loss by Lead Source** — the same ratio broken down, as tables: won, lost, settled, win rate and won revenue per row.
- **Why We Lose** — lost deals by loss reason. Every closed-lost deal carries a reason (it is [required at close](/docs/sales/opportunities)), so this chart has no "unattributed" slice — an empty chart means no losses in the window, not an unfilled field.
- **Average Deal Size** — current quarter vs last quarter.
- **Average Sales Cycle** — days from creation to close (won deals).
- **Top 10 Open Deals** — sortable table.
Expand Down
4 changes: 3 additions & 1 deletion content/docs/analytics/dashboards.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ HotCRM 内置四个仪表盘。每个都为特定角色而构建,回答该角
- **Pipeline by Stage** — 显示各阶段 $ 的漏斗图。
- **Forecast vs Quota** — 仪表盘图。
- **Closed Won this Quarter** — 带目标线的累计总额。
- **Win Rate (last 90 days)** — 带趋势的百分比。
- **Win Rate (12M)** — 近 12 个月内**已结案**(赢单 + 丢单)商机中的赢单占比,旁边同时展示 **Deals Won** 与 **Deals Lost**,让分子分母始终可见。进行中的管道不计入分母。
- **Win / Loss by Rep** 与 **Win / Loss by Lead Source** — 同一比值的拆分表:每行给出赢单数、丢单数、已结案数、赢率与赢单金额。
- **Why We Lose** — 按丢单原因统计的丢单数。每条丢单商机都必须填写原因([关单时强制](/docs/sales/opportunities)),因此图中不存在“未归因”切片——图为空表示该时间窗内没有丢单,而不是没人填字段。
- **Average Deal Size** — 本季度对比上季度。
- **Average Sales Cycle** — 从创建到成交的天数(赢单交易)。
- **Top 10 Open Deals** — 可排序的表格。
Expand Down
4 changes: 3 additions & 1 deletion content/docs/analytics/dashboards.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ HotCRM 內建四個儀表板。每個都為特定角色而建構,回答該角
- **Pipeline by Stage** — 顯示各階段 $ 的漏斗圖。
- **Forecast vs Quota** — 量表圖。
- **Closed Won this Quarter** — 帶目標線的累計總額。
- **Win Rate (last 90 days)** — 帶趨勢的百分比。
- **Win Rate (12M)** — 近 12 個月內**已結案**(贏單 + 丟單)商機中的贏單占比,旁邊同時顯示 **Deals Won** 與 **Deals Lost**,讓分子分母始終可見。進行中的管道不計入分母。
- **Win / Loss by Rep** 與 **Win / Loss by Lead Source** — 同一比值的拆分表:每列給出贏單數、丟單數、已結案數、贏率與贏單金額。
- **Why We Lose** — 按丟單原因統計的丟單數。每筆丟單商機都必須填寫原因([結案時強制](/docs/sales/opportunities)),因此圖中不存在「未歸因」切片——圖為空表示該時間窗內沒有丟單,而不是沒人填欄位。
- **Average Deal Size** — 本季對比上一季。
- **Average Sales Cycle** — 從建立到成交的天數(贏單交易)。
- **Top 10 Open Deals** — 可排序的表格。
Expand Down
22 changes: 21 additions & 1 deletion content/docs/sales/opportunities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ Every opportunity moves through these stages. The system tracks where you are an

You can move from any open stage directly to **Closed Lost** (deals can be lost at any point), but you can't skip forward — you have to walk through the funnel one stage at a time. The system blocks invalid jumps to keep your forecast honest.

## Closing a deal — the reason is required

Closing an opportunity takes one extra field, and the system will not save the record without it:

| Closing as | You must pick |
| --- | --- |
| 🟢 **Closed Won** | **Win Reason** — Better Product, Better Price, Existing Relationship, Better Support, Best Fit / Features, or Other |
| 🔴 **Closed Lost** | **Loss Reason** — Price Too High, Lost to Competitor, No Budget, No Decision, Bad Timing, Missing Features, or Other |

**Win/Loss Details** is the free-text box next to it — use it for the sentence a teammate would actually need six months later ("marketing is locked into a 2-year HubSpot contract, revisit at renewal").

Two things to know:

- **This is checked on the server**, not just in the form. An import, an API call or a bulk update that closes a deal without the applicable reason is rejected the same way a rep's form is.
- **Closed deals are locked.** Once a deal is closed, only the narrative fields (description, next steps) stay editable — so the reason cannot be filled in afterwards. Pick it when you close.

If the deal closes because the customer accepted a quote, HotCRM closes it for you and records the win reason as **Quote Accepted** — see [Quotes](/docs/sales/quotes).

Those two fields are what the Sales dashboard's **win rate** and **Why We Lose** widgets are built from; see [Dashboards](/docs/analytics/dashboards).

## What an opportunity record stores

The detail screen has 7 sections:
Expand All @@ -32,7 +52,7 @@ The detail screen has 7 sections:
| **Basic Information** | Name, account, primary contact, owner |
| **Financials** | Amount, expected revenue *(auto)*, probability *(auto from stage)* |
| **Sales Process** | Stage, close date, created date, stage entry date |
| **Classification** | Type (New Business / Upgrade / Renewal / Expansion), lead source |
| **Classification** | Type (New Business / Upgrade / Renewal / Expansion), lead source, win reason, loss reason, win/loss details |
| **Competition & Campaigns** | Competitors, source campaign |
| **Notes & Next Steps** | Description, next steps |
| **Forecast & Metrics** | Line item totals, approval status |
Expand Down
22 changes: 21 additions & 1 deletion content/docs/sales/opportunities.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶

你可以从任何开放阶段直接移动到**输单**(交易随时可能失去),但你不能向前跳过——你必须一次一个阶段地走过漏斗。系统会拦截无效跳转,以保持你的预测真实可靠。

## 关单必须填写原因

关闭商机时必须多填一个字段,否则记录保存不了:

| 关单方式 | 必填字段 |
| --- | --- |
| 🟢 **赢单** | **赢单原因** —— 产品更优 / 价格更优 / 客户关系 / 支持更好 / 最佳契合 / 其他 |
| 🔴 **输单** | **丢单原因** —— 价格过高 / 输给竞争对手 / 无预算 / 未决策 / 时机不合适 / 功能缺失 / 其他 |

旁边的 **赢/丢单详情** 是自由文本框,写下半年后同事真正需要的那句话("对方市场部被 HubSpot 两年合同锁定,续约时再谈")。

两点须知:

- **这是服务端校验**,不只是表单提示。导入、API 调用或批量更新在缺少对应原因时同样会被拒绝。
- **已关单的商机是锁定的**。关单之后只有叙述性字段(描述、后续步骤)还能编辑,原因无法事后补填——请在关单那一刻就选好。

如果商机是因为客户接受报价而成交,HotCRM 会自动关单并把赢单原因记为 **报价被接受**,详见[报价](/docs/sales/quotes)。

这两个字段正是销售仪表盘 **Win Rate** 与 **Why We Lose** 组件的数据来源,详见[仪表盘](/docs/analytics/dashboards)。

## 商机记录存储的内容

详情界面有 7 个区块:
Expand All @@ -32,7 +52,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶
| **基本信息** | 名称、客户、主要联系人、负责人 |
| **财务** | 金额、预期营收 *(自动)*、概率 *(从阶段自动得出)* |
| **销售流程** | 阶段、成交日期、创建日期、进入当前阶段日期 |
| **分类** | 类型(新业务 / 升级 / 续约 / 扩展)、线索来源 |
| **分类** | 类型(新业务 / 升级 / 续约 / 扩展)、线索来源、赢单原因、丢单原因、赢/丢单详情 |
| **竞争与营销活动** | 竞争对手、来源营销活动 |
| **备注与后续步骤** | 描述、后续步骤 |
| **预测与指标** | 行项目合计、审批状态 |
Expand Down
22 changes: 21 additions & 1 deletion content/docs/sales/opportunities.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階

你可以從任何開放階段直接移動到**輸單**(交易隨時可能失去),但你不能向前跳過——你必須一次一個階段地走過漏斗。系統會攔截無效跳轉,以保持你的預測真實可靠。

## 結案必須填寫原因

關閉商機時必須多填一個欄位,否則記錄無法儲存:

| 結案方式 | 必填欄位 |
| --- | --- |
| 🟢 **贏單** | **贏單原因** —— 產品更優 / 價格更優 / 客戶關係 / 支援更好 / 最佳契合 / 其他 |
| 🔴 **輸單** | **丟單原因** —— 價格過高 / 輸給競爭對手 / 無預算 / 未決策 / 時機不合適 / 功能缺失 / 其他 |

旁邊的 **贏/丟單詳情** 是自由文字框,寫下半年後同事真正需要的那句話(「對方行銷部被 HubSpot 兩年合約鎖定,續約時再談」)。

兩點須知:

- **這是伺服器端驗證**,不只是表單提示。匯入、API 呼叫或批次更新在缺少對應原因時同樣會被拒絕。
- **已結案的商機是鎖定的**。結案之後只有敘述性欄位(描述、後續步驟)還能編輯,原因無法事後補填——請在結案當下就選好。

如果商機是因為客戶接受報價而成交,HotCRM 會自動結案並把贏單原因記為 **報價被接受**,詳見[報價](/docs/sales/quotes)。

這兩個欄位正是銷售儀表板 **Win Rate** 與 **Why We Lose** 元件的資料來源,詳見[儀表板](/docs/analytics/dashboards)。

## 商機記錄儲存的內容

詳情介面有 7 個區塊:
Expand All @@ -32,7 +52,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階
| **基本資訊** | 名稱、客戶、主要聯絡人、負責人 |
| **財務** | 金額、預期營收 *(自動)*、機率 *(從階段自動得出)* |
| **銷售流程** | 階段、成交日期、建立日期、進入當前階段日期 |
| **分類** | 類型(新業務 / 升級 / 續約 / 擴展)、潛在客戶來源 |
| **分類** | 類型(新業務 / 升級 / 續約 / 擴展)、潛在客戶來源、贏單原因、丟單原因、贏/丟單詳情 |
| **競爭與行銷活動** | 競爭對手、來源行銷活動 |
| **備註與後續步驟** | 描述、後續步驟 |
| **預測與指標** | 行項目合計、審批狀態 |
Expand Down
18 changes: 15 additions & 3 deletions e2e/opportunity-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,17 @@ test.describe('opportunity_lifecycle hook (through the real kernel)', () => {
const id = rec.id as string;
created.push(id);

const updated = await patchOpportunity(api, id, { stage: 'closed_won' });
// `win_reason` is `requiredWhen` stage is closed_won (#593) — the server
// rejects the close without it with a 400 VALIDATION_FAILED, which is what
// `opportunity-win-loss-capture.spec.ts` asserts on purpose. Here it is
// supplied so this file keeps testing the hook it is about.
const updated = await patchOpportunity(api, id, {
stage: 'closed_won',
win_reason: 'best_fit',
});

expect(updated.stage).toBe('closed_won');
expect(updated.win_reason).toBe('best_fit');
expect(updated.probability).toBe(100);
expect(updated.expected_revenue).toBe(25_000);
expect(updated.forecast_category).toBe('closed');
Expand Down Expand Up @@ -124,9 +132,13 @@ test.describe('opportunity_lifecycle hook (through the real kernel)', () => {
const id = rec.id as string;
created.push(id);

const updated = await patchOpportunity(api, id, { stage: 'closed_lost' });
const updated = await patchOpportunity(api, id, {
stage: 'closed_lost',
loss_reason: 'no_budget', // requiredWhen closed_lost (#593)
});

expect(updated.stage).toBe('closed_lost');
expect(updated.loss_reason).toBe('no_budget');
expect(updated.probability).toBe(0);
expect(updated.expected_revenue).toBe(0);
expect(updated.forecast_category).toBe('omitted');
Expand Down Expand Up @@ -159,7 +171,7 @@ test.describe('opportunity_lifecycle hook (through the real kernel)', () => {
});
const id = rec.id as string;
created.push(id);
await patchOpportunity(api, id, { stage: 'closed_won' });
await patchOpportunity(api, id, { stage: 'closed_won', win_reason: 'better_price' });

// Business field — the freeze guard must reject it.
const rejected = await api.patch(`${BASE}/${id}`, { data: { amount: 999 } });
Expand Down
Loading
Loading