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
24 changes: 24 additions & 0 deletions .changeset/opportunity-stage-entry-date.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'hotcrm': patch
---

Make the stalled-deal sweep actually fire (#489). `crm_opportunity.days_in_stage`
was a plain number column that nothing ever incremented — the lifecycle hook
reset it to 0 on a stage change and no sweep raised it — so
`opportunity_stagnation`'s `days_in_stage > 14` filter matched only the rows the
seed had hardcoded, and real deals could rot in a stage forever without a nudge.

Opportunities now carry `stage_entry_date`, a stored date stamped by the
lifecycle hook on insert and on every stage change. `days_in_stage` becomes a
formula counting from it, correct on every read with no nightly full-table pass.
Because a formula is evaluated after the query and is not a real column, the
sweep now predicates on `stage_entry_date < TODAY() − 14` — the same test,
against something the data engine can see — and the "Stale Opportunities" view,
which can express neither a formula predicate nor a relative date, becomes an
open-deals list ordered longest-in-stage first with `days_in_stage` on show.

Two guards in `test/metadata-references.test.ts` keep the class from returning:
no list view may filter or sort on a formula field, and no flow data node may
filter on one. The second instance they caught is fixed here too — the quarterly
forecast view's `attainment_pct` tiebreaker was a dead sort key, now
`closed_amount`.
4 changes: 2 additions & 2 deletions content/docs/sales/opportunities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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, days in current stage |
| **Sales Process** | Stage, close date, created date, stage entry date |
| **Classification** | Type (New Business / Upgrade / Renewal / Expansion), lead source |
| **Competition & Campaigns** | Competitors, source campaign |
| **Notes & Next Steps** | Description, next steps |
Expand All @@ -54,7 +54,7 @@ You don't have to remember any of this — the system does it for you:

- **Probability** is set from the stage whenever you move the deal (you *can* override it manually if you have a special situation, but it'll reset next time the stage changes).
- **Expected revenue** = amount × probability ÷ 100, recalculated whenever amount or probability changes.
- **Days in current stage** is recomputed daily — it surfaces stagnant deals.
- **Stage entry date** is stamped the moment a deal is created and re-stamped every time you move it to a new stage. **Days in current stage** counts from it, so it is always up to date — that is what surfaces stagnant deals.
- When you mark a deal **Closed Won and the amount is over $100K**, sales management gets an email.
- When the amount exceeds **$500K**, the deal enters the [Discount Approval](/docs/revenue/approvals) process automatically.

Expand Down
4 changes: 2 additions & 2 deletions content/docs/sales/opportunities.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶
| --- | --- |
| **基本信息** | 名称、客户、主要联系人、负责人 |
| **财务** | 金额、预期营收 *(自动)*、概率 *(从阶段自动得出)* |
| **销售流程** | 阶段、成交日期、创建日期、当前阶段停留天数 |
| **销售流程** | 阶段、成交日期、创建日期、进入当前阶段日期 |
| **分类** | 类型(新业务 / 升级 / 续约 / 扩展)、线索来源 |
| **竞争与营销活动** | 竞争对手、来源营销活动 |
| **备注与后续步骤** | 描述、后续步骤 |
Expand All @@ -54,7 +54,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶

- 每当你推进交易时,**概率**会从阶段中设定(如果你有特殊情况,*可以*手动覆盖,但下次阶段变化时它会重置)。
- **预期营收** = 金额 × 概率 ÷ 100,每当金额或概率变化时重新计算。
- **当前阶段停留天数**每天重新计算——它能暴露停滞的交易
- **进入当前阶段日期**在交易创建时打上,之后每次移动到新阶段都会重新打上。**当前阶段停留天数**由它推算,因此始终是最新的——这正是暴露停滞交易的依据
- 当你将一笔交易标记为**赢单且金额超过 $100K** 时,销售管理层会收到邮件。
- 当金额超过 **$500K** 时,交易会自动进入 [折扣审批](/zh-Hans/docs/revenue/approvals) 流程。

Expand Down
4 changes: 2 additions & 2 deletions content/docs/sales/opportunities.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階
| --- | --- |
| **基本資訊** | 名稱、客戶、主要聯絡人、負責人 |
| **財務** | 金額、預期營收 *(自動)*、機率 *(從階段自動得出)* |
| **銷售流程** | 階段、成交日期、建立日期、當前階段停留天數 |
| **銷售流程** | 階段、成交日期、建立日期、進入當前階段日期 |
| **分類** | 類型(新業務 / 升級 / 續約 / 擴展)、潛在客戶來源 |
| **競爭與行銷活動** | 競爭對手、來源行銷活動 |
| **備註與後續步驟** | 描述、後續步驟 |
Expand All @@ -54,7 +54,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階

- 每當你推進交易時,**機率**會從階段中設定(如果你有特殊情況,*可以*手動覆蓋,但下次階段變化時它會重置)。
- **預期營收** = 金額 × 機率 ÷ 100,每當金額或機率變化時重新計算。
- **當前階段停留天數**每天重新計算——它能暴露停滯的交易
- **進入當前階段日期**在交易建立時打上,之後每次移動到新階段都會重新打上。**當前階段停留天數**由它推算,因此始終是最新的——這正是暴露停滯交易的依據
- 當你將一筆交易標記為**贏單且金額超過 $100K** 時,銷售管理層會收到郵件。
- 當金額超過 **$500K** 時,交易會自動進入 [折扣審批](/zh-Hant/docs/revenue/approvals) 流程。

Expand Down
2 changes: 1 addition & 1 deletion docs/developers/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Source: `src/objects/opportunity.object.ts`

Key fields:

`name`, `crm_account`, `primary_contact`, `owner`, `amount`, `expected_revenue`, `stage`, `probability`, `close_date`, `created_date`, `type`, `lead_source`, `competitors`, `crm_campaign`, `days_in_stage`, `next_step`, `is_private`, `forecast_category`, `approval_status`, `approved_date`, `win_reason`, `loss_reason`, `loss_details`
`name`, `crm_account`, `primary_contact`, `owner`, `amount`, `expected_revenue`, `stage`, `probability`, `close_date`, `created_date`, `stage_entry_date`, `type`, `lead_source`, `competitors`, `crm_campaign`, `days_in_stage`, `next_step`, `is_private`, `forecast_category`, `approval_status`, `approved_date`, `win_reason`, `loss_reason`, `loss_details`

### `crm_opportunity_line_item` - Opportunity Line Item

Expand Down
33 changes: 27 additions & 6 deletions src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const celDaysFromNow = (n: number) => cel`daysFromNow(${n})`;
* Two consequences shape everything below:
*
* 1. Seeding historical values into readonly fields (`created_date`,
* `days_in_stage`, `last_contacted_date`, `actual_revenue`) is legitimate
* `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate
* and load-bearing — it is the only way demo reports get history — and the
* platform explicitly preserves explicit seed values.
* 2. BUT nothing recomputes derived fields for seed rows, so every seeded
Expand Down Expand Up @@ -325,6 +325,14 @@ const leads = defineSeed(Lead, {
// don't run over seeds, so every row below carries the exact values the hook
// would compute — anything else gets silently rewritten on the first user
// edit (#490).
//
// `stage_entry_date` follows the same rule: the hook stamps it on every insert
// and stage change, so a seeded deal without one is a shape real data never
// has — and it is what the stagnation sweep filters on and what the
// `days_in_stage` FORMULA counts from, so it replaces the old hardcoded
// `days_in_stage` numbers (#489). Open deals carry an age that spans the
// 14-day stale threshold in both directions; settled deals entered their
// closed stage on their close date.
const opportunities = defineSeed(Opportunity, {
mode: 'upsert',
externalId: 'name',
Expand All @@ -340,7 +348,7 @@ const opportunities = defineSeed(Opportunity, {
type: 'existing_upgrade',
forecast_category: 'commit',
lead_source: 'web',
days_in_stage: 12,
stage_entry_date: celDaysAgo(12),
description: `Upgrade from Standard to Enterprise edition for the
NA + EMEA teams. Drivers: (1) AI agent governance becomes a hard
requirement after their internal compliance review, (2) advanced
Expand All @@ -358,7 +366,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
type: 'new_business',
forecast_category: 'pipeline',
lead_source: 'referral',
days_in_stage: 45,
stage_entry_date: celDaysAgo(45),
},
{
name: 'Wayne Enterprise License',
Expand All @@ -371,7 +379,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
type: 'new_business',
forecast_category: 'commit',
lead_source: 'partner',
days_in_stage: 7,
stage_entry_date: celDaysAgo(7),
},
{
name: 'Initech Cloud Migration',
Expand All @@ -384,7 +392,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
type: 'existing_upgrade',
forecast_category: 'best_case',
lead_source: 'event',
days_in_stage: 38,
stage_entry_date: celDaysAgo(38),
},
// ─── Closed Won deals (powers KPIs & revenue trends) ────────────────
{
Expand All @@ -395,6 +403,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 100,
expected_revenue: 220000,
close_date: cel`daysAgo(15)`,
stage_entry_date: cel`daysAgo(15)`,
type: 'existing_renewal',
forecast_category: 'closed',
lead_source: 'partner',
Expand All @@ -408,6 +417,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 100,
expected_revenue: 145000,
close_date: cel`daysAgo(50)`,
stage_entry_date: cel`daysAgo(50)`,
type: 'new_business',
forecast_category: 'closed',
lead_source: 'event',
Expand All @@ -420,6 +430,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 100,
expected_revenue: 380000,
close_date: cel`daysAgo(95)`,
stage_entry_date: cel`daysAgo(95)`,
type: 'existing_upgrade',
forecast_category: 'closed',
lead_source: 'web',
Expand All @@ -432,6 +443,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 100,
expected_revenue: 65000,
close_date: cel`daysAgo(140)`,
stage_entry_date: cel`daysAgo(140)`,
type: 'new_business',
forecast_category: 'closed',
lead_source: 'referral',
Expand All @@ -444,6 +456,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 100,
expected_revenue: 90000,
close_date: cel`daysAgo(200)`,
stage_entry_date: cel`daysAgo(200)`,
type: 'new_business',
forecast_category: 'closed',
lead_source: 'web',
Expand All @@ -457,6 +470,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 0,
expected_revenue: 0,
close_date: cel`daysAgo(25)`,
stage_entry_date: cel`daysAgo(25)`,
type: 'existing_upgrade',
forecast_category: 'omitted',
lead_source: 'cold_call',
Expand All @@ -470,6 +484,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
probability: 0,
expected_revenue: 0,
close_date: cel`daysAgo(60)`,
stage_entry_date: cel`daysAgo(60)`,
type: 'new_business',
forecast_category: 'omitted',
lead_source: 'advertisement',
Expand Down Expand Up @@ -517,6 +532,12 @@ analytics seats for the Ops org, (3) priority support SLA.`,
const close_date = isClosed(stage)
? celDaysAgo(5 + spread)
: celDaysFromNow(7 + spread);
// A settled deal entered its closed stage the day it closed; an open
// one has been parked 3–62 days, straddling STALE_THRESHOLD_DAYS so
// the stagnation sweep and the stale view both have rows on each side.
const stage_entry_date = isClosed(stage)
? celDaysAgo(5 + spread)
: celDaysAgo(3 + (i * 11) % 60);
const amount = 20000 + ((i * 17_393) % 480_000);
const probability = probabilityByStage[stage];
out.push({
Expand All @@ -531,7 +552,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
type: types[i % types.length],
forecast_category: forecastByStage[stage],
lead_source: sources[i % sources.length],
...(isClosed(stage) ? {} : { days_in_stage: 3 + (i * 11) % 60 }),
stage_entry_date,
});
}
return out;
Expand Down
14 changes: 11 additions & 3 deletions src/flows/opportunity-stagnation.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ type Flow = Automation.Flow;
/**
* Opportunity Stagnation — scheduled "deal-rot" detector.
*
* The opportunity carries `days_in_stage`, but nothing acted on it, so deals
* could sit untouched in a stage indefinitely. This daily sweep finds open
* The opportunity carries `stage_entry_date` (stamped by the lifecycle hook on
* insert and on every stage change), but nothing acted on it, so deals could
* sit untouched in a stage indefinitely. This daily sweep finds open
* opportunities stalled longer than the threshold, nudges the owner and books
* a follow-up task so the deal re-enters the working set. A deal with an
* open stall task is skipped (idempotency), so each stall episode produces
Expand Down Expand Up @@ -38,9 +39,16 @@ export const OpportunityStagnationFlow: Flow = {
id: 'query_stalled', type: 'get_record', label: 'Find Stalled Deals',
config: {
objectName: 'crm_opportunity',
// Predicate on the STORED `stage_entry_date`, not on `days_in_stage`:
// the latter is a formula, computed after the query, so as a filter key
// it addressed a column that does not exist (#489). `entry < today − N`
// is the same test as `days_in_stage > N`, resolved by the flow
// template engine (same `{TODAY() ± n}` token as contract-renewal).
// A row with a null `stage_entry_date` does not satisfy `$lt` and is
// skipped, preserving "nothing has stagnated yet" for unstamped rows.
filter: {
stage: { $nin: ['closed_won', 'closed_lost'] },
days_in_stage: { $gt: STALE_THRESHOLD_DAYS },
stage_entry_date: { $lt: `{TODAY() - ${STALE_THRESHOLD_DAYS}}` },
},
limit: 500,
outputVariable: 'oppList',
Expand Down
21 changes: 17 additions & 4 deletions src/objects/opportunity.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { HookApi } from './_hook-api';
* Opportunity lifecycle hook.
*
* - Re-derives `expected_revenue` from `amount * stageProbability` when either changes.
* - Stamps `stage_entry_date` on insert and on every stage change — the stored
* clock behind the `days_in_stage` formula and the stagnation sweep (#489).
* - Freezes most fields after stage is closed (won/lost) — only narrative fields editable.
* - On `closed_won`: stamps `close_date=today`, promotes the parent account to `customer`,
* and asynchronously schedules an "Activate customer" task.
Expand Down Expand Up @@ -115,16 +117,27 @@ const opportunityValidationHook: Hook = {
if (stageChanged) input.forecast_category = STAGE_FORECAST[stage];
}

// Start the stage-age clock at creation. Without this the row lands with a
// null `stage_entry_date`, `days_in_stage` reads null instead of 0, and the
// deal is invisible to the stagnation sweep until its first stage change —
// exactly backwards, since a deal that never moves is the stalled one.
if (event === 'beforeInsert' && !input.stage_entry_date) {
input.stage_entry_date = new Date().toISOString().slice(0, 10);
}

if (event === 'beforeUpdate' && previous) {
// Stamp close_date when transitioning into closed_won
if (input.stage === 'closed_won' && previous.stage !== 'closed_won' && !input.close_date) {
input.close_date = new Date().toISOString().slice(0, 10);
}
// Reset the stage-age counter on any stage change so a deal that
// advances stops matching the stagnation sweep. (Readonly fields are
// writable from before-hooks via input mutation.)
// Restart the stage-age clock on any stage change so a deal that
// advances stops matching the stagnation sweep. `days_in_stage` is a
// formula over this column, so this one write IS the reset — it used to
// set `days_in_stage = 0` against a counter nothing ever incremented.
// (Readonly fields are writable from before-hooks via input mutation:
// readonly stripping only drops keys the CALLER supplied.)
if (typeof input.stage === 'string' && input.stage !== previous.stage) {
input.days_in_stage = 0;
input.stage_entry_date = new Date().toISOString().slice(0, 10);
}
}
},
Expand Down
40 changes: 37 additions & 3 deletions src/objects/opportunity.object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { P, cel } from '@objectstack/spec';
import { F, P, cel } from '@objectstack/spec';
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';
Expand Down Expand Up @@ -126,6 +126,20 @@ export const Opportunity = ObjectSchema.create({
group: 'sales_process',
}),

// Stage-age clock (#489). This is the STORED half of the pair: a real,
// indexed date column, so it is what automation and views may filter and
// sort on. `days_in_stage` below is a formula derived from it.
// `opportunity_lifecycle` stamps it on insert and re-stamps it on every
// stage change; readonly because nothing but that hook should move it
// (before-hook writes survive readonly stripping — only caller-supplied
// keys are dropped).
stage_entry_date: Field.date({
label: 'Stage Entry Date',
description: 'Date this opportunity entered its current stage.',
readonly: true,
group: 'sales_process',
}),

// Additional Classification
type: Field.select({
label: 'Opportunity Type',
Expand Down Expand Up @@ -167,9 +181,27 @@ export const Opportunity = ObjectSchema.create({
}),

// Sales cycle metrics
days_in_stage: Field.number({
//
// FORMULA, not a stored counter (#489). As a plain number column nothing
// ever raised it: the hook reset it to 0 on a stage change and no sweep or
// hook anywhere incremented it, so `days_in_stage > 14` matched only the
// rows the seed hardcoded — the `opportunity_stagnation` flow never fired
// on real data. Deriving it from `stage_entry_date` is correct on every
// read and costs no nightly full-table pass.
//
// The trade-off: formulas are evaluated AFTER the query (the engine's
// `applyFormulaPlan` walks the returned rows), so this is not a real
// column and CANNOT appear in a filter or a sort. Anything that needs to
// *select* stalled deals predicates on `stage_entry_date` instead — see
// `opportunity-stagnation.flow.ts` and the `stale_opportunities` view.
//
// `has()` + null guard: `daysBetween(null, …)` faults and the whole field
// silently evaluates to null, so an unstamped row is spelled out as null
// rather than arriving there by accident.
days_in_stage: Field.formula({
label: 'Days in Current Stage',
readonly: true,
expression: F`has(record.stage_entry_date) && record.stage_entry_date != null ? daysBetween(record.stage_entry_date, today()) : null`,
returnType: 'number',
group: 'crm_forecast',
}),

Expand Down Expand Up @@ -267,6 +299,8 @@ export const Opportunity = ObjectSchema.create({
{ fields: ['owner'] },
{ fields: ['stage'] },
{ fields: ['close_date'] },
// The stagnation sweep filters on this every morning.
{ fields: ['stage_entry_date'] },
],

// Enable advanced features
Expand Down
1 change: 1 addition & 0 deletions src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const en: TranslationData = {
competitors: { label: 'Competitors' },
crm_campaign: { label: 'Campaign' },
days_in_stage: { label: 'Days in Current Stage' },
stage_entry_date: { label: 'Stage Entry Date' },
is_private: { label: 'Private' },
approval_status: {
label: 'Approval Status',
Expand Down
Loading
Loading