Skip to content

Commit 9c22788

Browse files
authored
Merge pull request #560 from objectstack-ai/claude/hooks-flows-automation-conflict-isglbj
fix(opportunity): derive days_in_stage from a stored stage_entry_date
2 parents 9d4d1d3 + 42a1d33 commit 9c22788

17 files changed

Lines changed: 260 additions & 34 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'hotcrm': patch
3+
---
4+
5+
Make the stalled-deal sweep actually fire (#489). `crm_opportunity.days_in_stage`
6+
was a plain number column that nothing ever incremented — the lifecycle hook
7+
reset it to 0 on a stage change and no sweep raised it — so
8+
`opportunity_stagnation`'s `days_in_stage > 14` filter matched only the rows the
9+
seed had hardcoded, and real deals could rot in a stage forever without a nudge.
10+
11+
Opportunities now carry `stage_entry_date`, a stored date stamped by the
12+
lifecycle hook on insert and on every stage change. `days_in_stage` becomes a
13+
formula counting from it, correct on every read with no nightly full-table pass.
14+
Because a formula is evaluated after the query and is not a real column, the
15+
sweep now predicates on `stage_entry_date < TODAY() − 14` — the same test,
16+
against something the data engine can see — and the "Stale Opportunities" view,
17+
which can express neither a formula predicate nor a relative date, becomes an
18+
open-deals list ordered longest-in-stage first with `days_in_stage` on show.
19+
20+
Two guards in `test/metadata-references.test.ts` keep the class from returning:
21+
no list view may filter or sort on a formula field, and no flow data node may
22+
filter on one. The second instance they caught is fixed here too — the quarterly
23+
forecast view's `attainment_pct` tiebreaker was a dead sort key, now
24+
`closed_amount`.

content/docs/sales/opportunities.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The detail screen has 7 sections:
3131
| --- | --- |
3232
| **Basic Information** | Name, account, primary contact, owner |
3333
| **Financials** | Amount, expected revenue *(auto)*, probability *(auto from stage)* |
34-
| **Sales Process** | Stage, close date, created date, days in current stage |
34+
| **Sales Process** | Stage, close date, created date, stage entry date |
3535
| **Classification** | Type (New Business / Upgrade / Renewal / Expansion), lead source |
3636
| **Competition & Campaigns** | Competitors, source campaign |
3737
| **Notes & Next Steps** | Description, next steps |
@@ -54,7 +54,7 @@ You don't have to remember any of this — the system does it for you:
5454

5555
- **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).
5656
- **Expected revenue** = amount × probability ÷ 100, recalculated whenever amount or probability changes.
57-
- **Days in current stage** is recomputed daily — it surfaces stagnant deals.
57+
- **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.
5858
- When you mark a deal **Closed Won and the amount is over $100K**, sales management gets an email.
5959
- When the amount exceeds **$500K**, the deal enters the [Discount Approval](/docs/revenue/approvals) process automatically.
6060

content/docs/sales/opportunities.zh-Hans.mdx

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

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

content/docs/sales/opportunities.zh-Hant.mdx

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

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

docs/developers/api_reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Source: `src/objects/opportunity.object.ts`
3636

3737
Key fields:
3838

39-
`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`
39+
`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`
4040

4141
### `crm_opportunity_line_item` - Opportunity Line Item
4242

src/data/index.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const celDaysFromNow = (n: number) => cel`daysFromNow(${n})`;
3838
* Two consequences shape everything below:
3939
*
4040
* 1. Seeding historical values into readonly fields (`created_date`,
41-
* `days_in_stage`, `last_contacted_date`, `actual_revenue`) is legitimate
41+
* `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate
4242
* and load-bearing — it is the only way demo reports get history — and the
4343
* platform explicitly preserves explicit seed values.
4444
* 2. BUT nothing recomputes derived fields for seed rows, so every seeded
@@ -325,6 +325,14 @@ const leads = defineSeed(Lead, {
325325
// don't run over seeds, so every row below carries the exact values the hook
326326
// would compute — anything else gets silently rewritten on the first user
327327
// edit (#490).
328+
//
329+
// `stage_entry_date` follows the same rule: the hook stamps it on every insert
330+
// and stage change, so a seeded deal without one is a shape real data never
331+
// has — and it is what the stagnation sweep filters on and what the
332+
// `days_in_stage` FORMULA counts from, so it replaces the old hardcoded
333+
// `days_in_stage` numbers (#489). Open deals carry an age that spans the
334+
// 14-day stale threshold in both directions; settled deals entered their
335+
// closed stage on their close date.
328336
const opportunities = defineSeed(Opportunity, {
329337
mode: 'upsert',
330338
externalId: 'name',
@@ -340,7 +348,7 @@ const opportunities = defineSeed(Opportunity, {
340348
type: 'existing_upgrade',
341349
forecast_category: 'commit',
342350
lead_source: 'web',
343-
days_in_stage: 12,
351+
stage_entry_date: celDaysAgo(12),
344352
description: `Upgrade from Standard to Enterprise edition for the
345353
NA + EMEA teams. Drivers: (1) AI agent governance becomes a hard
346354
requirement after their internal compliance review, (2) advanced
@@ -358,7 +366,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
358366
type: 'new_business',
359367
forecast_category: 'pipeline',
360368
lead_source: 'referral',
361-
days_in_stage: 45,
369+
stage_entry_date: celDaysAgo(45),
362370
},
363371
{
364372
name: 'Wayne Enterprise License',
@@ -371,7 +379,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
371379
type: 'new_business',
372380
forecast_category: 'commit',
373381
lead_source: 'partner',
374-
days_in_stage: 7,
382+
stage_entry_date: celDaysAgo(7),
375383
},
376384
{
377385
name: 'Initech Cloud Migration',
@@ -384,7 +392,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
384392
type: 'existing_upgrade',
385393
forecast_category: 'best_case',
386394
lead_source: 'event',
387-
days_in_stage: 38,
395+
stage_entry_date: celDaysAgo(38),
388396
},
389397
// ─── Closed Won deals (powers KPIs & revenue trends) ────────────────
390398
{
@@ -395,6 +403,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
395403
probability: 100,
396404
expected_revenue: 220000,
397405
close_date: cel`daysAgo(15)`,
406+
stage_entry_date: cel`daysAgo(15)`,
398407
type: 'existing_renewal',
399408
forecast_category: 'closed',
400409
lead_source: 'partner',
@@ -408,6 +417,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
408417
probability: 100,
409418
expected_revenue: 145000,
410419
close_date: cel`daysAgo(50)`,
420+
stage_entry_date: cel`daysAgo(50)`,
411421
type: 'new_business',
412422
forecast_category: 'closed',
413423
lead_source: 'event',
@@ -420,6 +430,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
420430
probability: 100,
421431
expected_revenue: 380000,
422432
close_date: cel`daysAgo(95)`,
433+
stage_entry_date: cel`daysAgo(95)`,
423434
type: 'existing_upgrade',
424435
forecast_category: 'closed',
425436
lead_source: 'web',
@@ -432,6 +443,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
432443
probability: 100,
433444
expected_revenue: 65000,
434445
close_date: cel`daysAgo(140)`,
446+
stage_entry_date: cel`daysAgo(140)`,
435447
type: 'new_business',
436448
forecast_category: 'closed',
437449
lead_source: 'referral',
@@ -444,6 +456,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
444456
probability: 100,
445457
expected_revenue: 90000,
446458
close_date: cel`daysAgo(200)`,
459+
stage_entry_date: cel`daysAgo(200)`,
447460
type: 'new_business',
448461
forecast_category: 'closed',
449462
lead_source: 'web',
@@ -457,6 +470,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
457470
probability: 0,
458471
expected_revenue: 0,
459472
close_date: cel`daysAgo(25)`,
473+
stage_entry_date: cel`daysAgo(25)`,
460474
type: 'existing_upgrade',
461475
forecast_category: 'omitted',
462476
lead_source: 'cold_call',
@@ -470,6 +484,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
470484
probability: 0,
471485
expected_revenue: 0,
472486
close_date: cel`daysAgo(60)`,
487+
stage_entry_date: cel`daysAgo(60)`,
473488
type: 'new_business',
474489
forecast_category: 'omitted',
475490
lead_source: 'advertisement',
@@ -517,6 +532,12 @@ analytics seats for the Ops org, (3) priority support SLA.`,
517532
const close_date = isClosed(stage)
518533
? celDaysAgo(5 + spread)
519534
: celDaysFromNow(7 + spread);
535+
// A settled deal entered its closed stage the day it closed; an open
536+
// one has been parked 3–62 days, straddling STALE_THRESHOLD_DAYS so
537+
// the stagnation sweep and the stale view both have rows on each side.
538+
const stage_entry_date = isClosed(stage)
539+
? celDaysAgo(5 + spread)
540+
: celDaysAgo(3 + (i * 11) % 60);
520541
const amount = 20000 + ((i * 17_393) % 480_000);
521542
const probability = probabilityByStage[stage];
522543
out.push({
@@ -531,7 +552,7 @@ analytics seats for the Ops org, (3) priority support SLA.`,
531552
type: types[i % types.length],
532553
forecast_category: forecastByStage[stage],
533554
lead_source: sources[i % sources.length],
534-
...(isClosed(stage) ? {} : { days_in_stage: 3 + (i * 11) % 60 }),
555+
stage_entry_date,
535556
});
536557
}
537558
return out;

src/flows/opportunity-stagnation.flow.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ type Flow = Automation.Flow;
66
/**
77
* Opportunity Stagnation — scheduled "deal-rot" detector.
88
*
9-
* The opportunity carries `days_in_stage`, but nothing acted on it, so deals
10-
* could sit untouched in a stage indefinitely. This daily sweep finds open
9+
* The opportunity carries `stage_entry_date` (stamped by the lifecycle hook on
10+
* insert and on every stage change), but nothing acted on it, so deals could
11+
* sit untouched in a stage indefinitely. This daily sweep finds open
1112
* opportunities stalled longer than the threshold, nudges the owner and books
1213
* a follow-up task so the deal re-enters the working set. A deal with an
1314
* open stall task is skipped (idempotency), so each stall episode produces
@@ -38,9 +39,16 @@ export const OpportunityStagnationFlow: Flow = {
3839
id: 'query_stalled', type: 'get_record', label: 'Find Stalled Deals',
3940
config: {
4041
objectName: 'crm_opportunity',
42+
// Predicate on the STORED `stage_entry_date`, not on `days_in_stage`:
43+
// the latter is a formula, computed after the query, so as a filter key
44+
// it addressed a column that does not exist (#489). `entry < today − N`
45+
// is the same test as `days_in_stage > N`, resolved by the flow
46+
// template engine (same `{TODAY() ± n}` token as contract-renewal).
47+
// A row with a null `stage_entry_date` does not satisfy `$lt` and is
48+
// skipped, preserving "nothing has stagnated yet" for unstamped rows.
4149
filter: {
4250
stage: { $nin: ['closed_won', 'closed_lost'] },
43-
days_in_stage: { $gt: STALE_THRESHOLD_DAYS },
51+
stage_entry_date: { $lt: `{TODAY() - ${STALE_THRESHOLD_DAYS}}` },
4452
},
4553
limit: 500,
4654
outputVariable: 'oppList',

src/objects/opportunity.hook.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { HookApi } from './_hook-api';
77
* Opportunity lifecycle hook.
88
*
99
* - Re-derives `expected_revenue` from `amount * stageProbability` when either changes.
10+
* - Stamps `stage_entry_date` on insert and on every stage change — the stored
11+
* clock behind the `days_in_stage` formula and the stagnation sweep (#489).
1012
* - Freezes most fields after stage is closed (won/lost) — only narrative fields editable.
1113
* - On `closed_won`: stamps `close_date=today`, promotes the parent account to `customer`,
1214
* and asynchronously schedules an "Activate customer" task.
@@ -115,16 +117,27 @@ const opportunityValidationHook: Hook = {
115117
if (stageChanged) input.forecast_category = STAGE_FORECAST[stage];
116118
}
117119

120+
// Start the stage-age clock at creation. Without this the row lands with a
121+
// null `stage_entry_date`, `days_in_stage` reads null instead of 0, and the
122+
// deal is invisible to the stagnation sweep until its first stage change —
123+
// exactly backwards, since a deal that never moves is the stalled one.
124+
if (event === 'beforeInsert' && !input.stage_entry_date) {
125+
input.stage_entry_date = new Date().toISOString().slice(0, 10);
126+
}
127+
118128
if (event === 'beforeUpdate' && previous) {
119129
// Stamp close_date when transitioning into closed_won
120130
if (input.stage === 'closed_won' && previous.stage !== 'closed_won' && !input.close_date) {
121131
input.close_date = new Date().toISOString().slice(0, 10);
122132
}
123-
// Reset the stage-age counter on any stage change so a deal that
124-
// advances stops matching the stagnation sweep. (Readonly fields are
125-
// writable from before-hooks via input mutation.)
133+
// Restart the stage-age clock on any stage change so a deal that
134+
// advances stops matching the stagnation sweep. `days_in_stage` is a
135+
// formula over this column, so this one write IS the reset — it used to
136+
// set `days_in_stage = 0` against a counter nothing ever incremented.
137+
// (Readonly fields are writable from before-hooks via input mutation:
138+
// readonly stripping only drops keys the CALLER supplied.)
126139
if (typeof input.stage === 'string' && input.stage !== previous.stage) {
127-
input.days_in_stage = 0;
140+
input.stage_entry_date = new Date().toISOString().slice(0, 10);
128141
}
129142
}
130143
},

src/objects/opportunity.object.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { P, cel } from '@objectstack/spec';
1+
import { F, P, cel } from '@objectstack/spec';
22
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
33

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

129+
// Stage-age clock (#489). This is the STORED half of the pair: a real,
130+
// indexed date column, so it is what automation and views may filter and
131+
// sort on. `days_in_stage` below is a formula derived from it.
132+
// `opportunity_lifecycle` stamps it on insert and re-stamps it on every
133+
// stage change; readonly because nothing but that hook should move it
134+
// (before-hook writes survive readonly stripping — only caller-supplied
135+
// keys are dropped).
136+
stage_entry_date: Field.date({
137+
label: 'Stage Entry Date',
138+
description: 'Date this opportunity entered its current stage.',
139+
readonly: true,
140+
group: 'sales_process',
141+
}),
142+
129143
// Additional Classification
130144
type: Field.select({
131145
label: 'Opportunity Type',
@@ -167,9 +181,27 @@ export const Opportunity = ObjectSchema.create({
167181
}),
168182

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

@@ -267,6 +299,8 @@ export const Opportunity = ObjectSchema.create({
267299
{ fields: ['owner'] },
268300
{ fields: ['stage'] },
269301
{ fields: ['close_date'] },
302+
// The stagnation sweep filters on this every morning.
303+
{ fields: ['stage_entry_date'] },
270304
],
271305

272306
// Enable advanced features

src/translations/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ export const en: TranslationData = {
323323
competitors: { label: 'Competitors' },
324324
crm_campaign: { label: 'Campaign' },
325325
days_in_stage: { label: 'Days in Current Stage' },
326+
stage_entry_date: { label: 'Stage Entry Date' },
326327
is_private: { label: 'Private' },
327328
approval_status: {
328329
label: 'Approval Status',

0 commit comments

Comments
 (0)