diff --git a/.changeset/opportunity-stage-entry-date.md b/.changeset/opportunity-stage-entry-date.md new file mode 100644 index 00000000..52201cf4 --- /dev/null +++ b/.changeset/opportunity-stage-entry-date.md @@ -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`. diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 5be84ce7..409cad7b 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -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 | @@ -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. diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index 0f4652ef..2eba84f6 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -31,7 +31,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 | --- | --- | | **基本信息** | 名称、客户、主要联系人、负责人 | | **财务** | 金额、预期营收 *(自动)*、概率 *(从阶段自动得出)* | -| **销售流程** | 阶段、成交日期、创建日期、当前阶段停留天数 | +| **销售流程** | 阶段、成交日期、创建日期、进入当前阶段日期 | | **分类** | 类型(新业务 / 升级 / 续约 / 扩展)、线索来源 | | **竞争与营销活动** | 竞争对手、来源营销活动 | | **备注与后续步骤** | 描述、后续步骤 | @@ -54,7 +54,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 - 每当你推进交易时,**概率**会从阶段中设定(如果你有特殊情况,*可以*手动覆盖,但下次阶段变化时它会重置)。 - **预期营收** = 金额 × 概率 ÷ 100,每当金额或概率变化时重新计算。 -- **当前阶段停留天数**每天重新计算——它能暴露停滞的交易。 +- **进入当前阶段日期**在交易创建时打上,之后每次移动到新阶段都会重新打上。**当前阶段停留天数**由它推算,因此始终是最新的——这正是暴露停滞交易的依据。 - 当你将一笔交易标记为**赢单且金额超过 $100K** 时,销售管理层会收到邮件。 - 当金额超过 **$500K** 时,交易会自动进入 [折扣审批](/zh-Hans/docs/revenue/approvals) 流程。 diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index bef85a2a..48420bb6 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -31,7 +31,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 | --- | --- | | **基本資訊** | 名稱、客戶、主要聯絡人、負責人 | | **財務** | 金額、預期營收 *(自動)*、機率 *(從階段自動得出)* | -| **銷售流程** | 階段、成交日期、建立日期、當前階段停留天數 | +| **銷售流程** | 階段、成交日期、建立日期、進入當前階段日期 | | **分類** | 類型(新業務 / 升級 / 續約 / 擴展)、潛在客戶來源 | | **競爭與行銷活動** | 競爭對手、來源行銷活動 | | **備註與後續步驟** | 描述、後續步驟 | @@ -54,7 +54,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 - 每當你推進交易時,**機率**會從階段中設定(如果你有特殊情況,*可以*手動覆蓋,但下次階段變化時它會重置)。 - **預期營收** = 金額 × 機率 ÷ 100,每當金額或機率變化時重新計算。 -- **當前階段停留天數**每天重新計算——它能暴露停滯的交易。 +- **進入當前階段日期**在交易建立時打上,之後每次移動到新階段都會重新打上。**當前階段停留天數**由它推算,因此始終是最新的——這正是暴露停滯交易的依據。 - 當你將一筆交易標記為**贏單且金額超過 $100K** 時,銷售管理層會收到郵件。 - 當金額超過 **$500K** 時,交易會自動進入 [折扣審批](/zh-Hant/docs/revenue/approvals) 流程。 diff --git a/docs/developers/api_reference.md b/docs/developers/api_reference.md index 076740d9..d94f2719 100644 --- a/docs/developers/api_reference.md +++ b/docs/developers/api_reference.md @@ -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 diff --git a/src/data/index.ts b/src/data/index.ts index 5b88793e..9eead76f 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -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 @@ -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', @@ -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 @@ -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', @@ -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', @@ -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) ──────────────── { @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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({ @@ -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; diff --git a/src/flows/opportunity-stagnation.flow.ts b/src/flows/opportunity-stagnation.flow.ts index 3b7bcbc6..76e6187a 100644 --- a/src/flows/opportunity-stagnation.flow.ts +++ b/src/flows/opportunity-stagnation.flow.ts @@ -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 @@ -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', diff --git a/src/objects/opportunity.hook.ts b/src/objects/opportunity.hook.ts index 060d6836..71b7a53d 100644 --- a/src/objects/opportunity.hook.ts +++ b/src/objects/opportunity.hook.ts @@ -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. @@ -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); } } }, diff --git a/src/objects/opportunity.object.ts b/src/objects/opportunity.object.ts index 10053ba0..6fc0ec9e 100644 --- a/src/objects/opportunity.object.ts +++ b/src/objects/opportunity.object.ts @@ -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'; @@ -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', @@ -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', }), @@ -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 diff --git a/src/translations/en.ts b/src/translations/en.ts index b6d424a0..dadff8e7 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -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', diff --git a/src/translations/es-ES.ts b/src/translations/es-ES.ts index ce8d0d23..b3f519b6 100644 --- a/src/translations/es-ES.ts +++ b/src/translations/es-ES.ts @@ -301,6 +301,7 @@ export const esES: TranslationData = { competitors: { label: 'Competidores' }, crm_campaign: { label: 'Campaña' }, days_in_stage: { label: 'Días en Etapa Actual' }, + stage_entry_date: { label: 'Fecha de Entrada a la Etapa' }, is_private: { label: 'Privado' }, }, _views: { diff --git a/src/translations/ja-JP.ts b/src/translations/ja-JP.ts index 6ceb86d0..151f549e 100644 --- a/src/translations/ja-JP.ts +++ b/src/translations/ja-JP.ts @@ -301,6 +301,7 @@ export const jaJP: TranslationData = { competitors: { label: '競合他社' }, crm_campaign: { label: 'キャンペーン' }, days_in_stage: { label: '現ステージ滞在日数' }, + stage_entry_date: { label: 'ステージ開始日' }, is_private: { label: '非公開' }, }, _views: { diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index bb7b005f..4507a417 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -738,6 +738,7 @@ export const zhCN: TranslationData = { competitors: { label: '竞争对手' }, crm_campaign: { label: '营销活动' }, days_in_stage: { label: '当前阶段天数' }, + stage_entry_date: { label: '进入当前阶段日期' }, is_private: { label: '私密' }, approval_status: { label: '审批状态', diff --git a/src/views/forecast.view.ts b/src/views/forecast.view.ts index 3f88caca..df53e5d7 100644 --- a/src/views/forecast.view.ts +++ b/src/views/forecast.view.ts @@ -59,9 +59,14 @@ export const ForecastViews = defineView({ filter: [ { field: 'period', operator: 'equals', value: 'quarter' }, ], + // The tiebreaker was `attainment_pct desc`, which is a FORMULA — the + // engine computes it in JS after the query, so the data engine never saw + // that sort key and the ordering within a quarter was arbitrary (same + // class of defect as #489's `days_in_stage` filter). `closed_amount` is + // the stored numerator behind attainment and ranks the same direction. sort: [ { field: 'period_start', order: 'desc' }, - { field: 'attainment_pct', order: 'desc' }, + { field: 'closed_amount', order: 'desc' }, ], }, my_forecast: { diff --git a/src/views/opportunity.view.ts b/src/views/opportunity.view.ts index a9d2ae59..0e82f6ac 100644 --- a/src/views/opportunity.view.ts +++ b/src/views/opportunity.view.ts @@ -186,23 +186,27 @@ export const OpportunityViews = defineView({ sort: [{ field: 'close_date', order: 'asc' }], }, - /** Stale Opportunities — still-open deals, sorted by days_in_stage desc */ + /** Stale Opportunities — still-open deals, longest-parked first */ stale_opportunities: { name: 'stale_opportunities', type: 'grid', - label: '⚠️ Stale Opportunities', + // Honest label: the view cannot express the 14-day cut its automation + // twin uses, for two independent reasons. `days_in_stage` is a FORMULA + // (#489) — evaluated after the query, so the data engine can neither + // filter nor sort on it — and the list data path resolves no date + // macros, so `stage_entry_date < {14_days_ago}` would ship the literal + // token and invert the comparison (same trap as `stale_articles`). + // So: operator-only filter, ordered by the stored `stage_entry_date` + // ascending — longest-parked on top, which is the queue a manager works. + // The `days_in_stage` column puts the `opportunity_stagnation` threshold + // (STALE_THRESHOLD_DAYS = 14) in plain sight on every row. + label: '⚠️ Stale Opportunities · Longest in Stage First', data: { provider: 'object', object: 'crm_opportunity' }, - columns: ['name', 'crm_account', 'stage', 'amount', 'days_in_stage', 'close_date', 'owner'], - // Genuinely stale = open AND parked in-stage beyond the threshold the - // `opportunity_stagnation` flow acts on (STALE_THRESHOLD_DAYS = 14), so - // the view and the automation agree on what "stalled" means. Deals that - // never moved have a null `days_in_stage` and are intentionally excluded - // (nothing has stagnated yet). Most-stagnant first. + columns: ['name', 'crm_account', 'stage', 'amount', 'stage_entry_date', 'days_in_stage', 'close_date', 'owner'], filter: [ { field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] }, - { field: 'days_in_stage', operator: 'greater_than', value: 14 }, ], - sort: [{ field: 'days_in_stage', order: 'desc' }, { field: 'close_date', order: 'asc' }], + sort: [{ field: 'stage_entry_date', order: 'asc' }, { field: 'close_date', order: 'asc' }], }, /** Closing this quarter (commit + best_case) — sales-manager forecast */ @@ -246,6 +250,7 @@ export const OpportunityViews = defineView({ 'type', 'lead_source', 'crm_campaign', + 'stage_entry_date', 'days_in_stage', 'is_private', ], diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index e632a929..a7becc78 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import oppLineItemHooks from '../src/objects/opportunity_line_item.hook'; import quoteLineItemHooks from '../src/objects/quote_line_item.hook'; import leadHooks from '../src/objects/lead.hook'; +import opportunityHooks from '../src/objects/opportunity.hook'; /** * Runtime hook tests — the REAL handler code, executed against a controllable @@ -101,6 +102,58 @@ describe('opportunity_amount_rollup', () => { }); }); +describe('opportunity_lifecycle · stage-age clock', () => { + /** + * `days_in_stage` is a formula over `stage_entry_date` (#489), so this hook + * owns the only clock the `opportunity_stagnation` sweep and the + * `stale_opportunities` view can read. Its predecessor wrote + * `days_in_stage = 0` on a stage change against a counter nothing ever + * incremented — the flow's `days_in_stage > 14` filter matched only seeded + * rows. These pin the stamping contract in both directions. + */ + const lifecycle = hookByName(opportunityHooks, 'opportunity_lifecycle'); + const today = () => new Date().toISOString().slice(0, 10); + + it('starts the clock on insert', async () => { + const input: Rec = { name: 'New Deal', stage: 'prospecting', amount: 1000 }; + await lifecycle.handler({ event: 'beforeInsert', input } as any); + expect(input.stage_entry_date).toBe(today()); + }); + + it('does not overwrite a stage_entry_date the caller supplied', async () => { + const input: Rec = { name: 'Backfilled', stage: 'proposal', amount: 1000, stage_entry_date: '2020-01-01' }; + await lifecycle.handler({ event: 'beforeInsert', input } as any); + expect(input.stage_entry_date).toBe('2020-01-01'); + }); + + it('restarts the clock when the stage changes', async () => { + const input: Rec = { stage: 'negotiation' }; + const previous: Rec = { stage: 'proposal', stage_entry_date: '2020-01-01' }; + await lifecycle.handler({ event: 'beforeUpdate', input, previous } as any); + expect(input.stage_entry_date).toBe(today()); + }); + + it('leaves the clock alone when the stage does not change', async () => { + const input: Rec = { amount: 250000 }; + const previous: Rec = { stage: 'proposal', stage_entry_date: '2020-01-01' }; + await lifecycle.handler({ event: 'beforeUpdate', input, previous } as any); + expect(input.stage_entry_date).toBeUndefined(); + }); + + it('never writes days_in_stage — it is a formula, not a stored counter', async () => { + const insert: Rec = { name: 'D', stage: 'prospecting', amount: 1 }; + await lifecycle.handler({ event: 'beforeInsert', input: insert } as any); + const update: Rec = { stage: 'closed_won' }; + await lifecycle.handler({ + event: 'beforeUpdate', + input: update, + previous: { stage: 'negotiation', stage_entry_date: '2020-01-01' }, + } as any); + expect('days_in_stage' in insert).toBe(false); + expect('days_in_stage' in update).toBe(false); + }); +}); + describe('quote_total_rollup', () => { const rollup = hookByName(quoteLineItemHooks, 'quote_total_rollup'); diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index b445ae35..4cc899d4 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -251,6 +251,65 @@ describe('view field references resolve', () => { }); }); +describe('formula fields are never used as query predicates', () => { + /** + * A `formula` field is not a column. The engine evaluates it in JS over the + * rows a query already returned (`applyFormulaPlan`), so naming one in a + * `filter` or a `sort` addresses something the data engine cannot see — the + * predicate is silently dropped or, worse, inverted. + * + * This is exactly how `opportunity_stagnation` came to fire on nothing + * (#489): `days_in_stage > 14` looked right and matched only the rows the + * seed had hardcoded. Filter and sort on the STORED column the formula reads + * from (`stage_entry_date`) and let the formula stay a display value. + */ + const formulaFieldsOf = (obj: string): Set => { + const fields = objects.find((o) => o.name === obj)?.fields ?? {}; + return new Set(Object.entries(fields).filter(([, f]) => (f as AnyRec)?.type === 'formula').map(([n]) => n)); + }; + + const viewObjectOf = (v: AnyRec): string | undefined => + v.list?.data?.object ?? v.form?.data?.object ?? v.object; + + it('no list view filters or sorts on a formula field', () => { + const bad: string[] = []; + for (const v of views) { + const objectName = viewObjectOf(v); + if (!objectName || !objectNames.has(objectName)) continue; + const formulas = formulaFieldsOf(objectName); + if (formulas.size === 0) continue; + const lists = [v.list, ...Object.values(v.listViews ?? {})].filter(Boolean) as AnyRec[]; + for (const list of lists) { + const where = `${objectName} view "${list.name ?? 'default'}"`; + for (const f of list.filter ?? []) { + if (f?.field && formulas.has(f.field)) bad.push(`${where}: filters on formula "${f.field}"`); + } + for (const s of list.sort ?? []) { + if (s?.field && formulas.has(s.field)) bad.push(`${where}: sorts on formula "${s.field}"`); + } + } + } + expect(bad, `formula fields used as query predicates:\n ${bad.join('\n ')}`).toEqual([]); + }); + + it('no flow data node filters on a formula field', () => { + const flows: AnyRec[] = (stack as any).flows ?? []; + const bad: string[] = []; + for (const flow of flows) { + for (const node of walk(flow.nodes)) { + const objectName = node.config?.objectName ?? node.config?.object; + const filter = node.config?.filter; + if (typeof objectName !== 'string' || !filter || typeof filter !== 'object') continue; + const formulas = formulaFieldsOf(objectName); + for (const key of Object.keys(filter)) { + if (formulas.has(key)) bad.push(`flow "${flow.name}" node "${node.id}": filters ${objectName}.${key} (formula)`); + } + } + } + expect(bad, `formula fields used as flow filters:\n ${bad.join('\n ')}`).toEqual([]); + }); +}); + describe('priority queues sort by urgency, not alphabetically', () => { /** * `priority desc` on the select itself compares the raw option strings, so