Skip to content

fix(opportunity): derive days_in_stage from a stored stage_entry_date - #560

Merged
yinlianghui merged 1 commit into
mainfrom
claude/hooks-flows-automation-conflict-isglbj
Jul 31, 2026
Merged

fix(opportunity): derive days_in_stage from a stored stage_entry_date#560
yinlianghui merged 1 commit into
mainfrom
claude/hooks-flows-automation-conflict-isglbj

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Description

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 or hook anywhere raised it — so opportunity_stagnation's days_in_stage > 14 filter only ever matched the rows the seed had hardcoded. Real deals could sit in a stage indefinitely without a single nudge.

This is the one item left open on #489 (everything else in that issue shipped in #536).

The fix follows the approach proposed in the issue thread: add a stored stage_entry_date and make days_in_stage a formula that counts from it. Correct on every read, and no nightly full-table pass.

The consequence that shapes the rest of the diff: a formula is evaluated after the query (the engine's applyFormulaPlan walks the rows it already fetched), so it is not a real column and cannot be a filter or sort key. Every consumer that selects rather than displays moves to stage_entry_date.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Related Issues

Related to #489

Changes Made

  • crm_opportunity.stage_entry_date — new stored, indexed, readonly date. The lifecycle hook stamps it on insert and re-stamps it on every stage change. (Before-hook writes survive readonly stripping: only caller-supplied keys are dropped.)
  • days_in_stage → formuladaysBetween(record.stage_entry_date, today()), guarded with has() + null so an unstamped row reads null rather than faulting there by accident.
  • opportunity_stagnation filters stage_entry_date < {TODAY() - 14} — the same test, against something the data engine can see, resolved by the flow template engine (the {TODAY() ± n} token contract_renewal already uses). A null stage_entry_date fails $lt, preserving "nothing has stagnated yet" for unstamped rows.
  • stale_opportunities view drops the threshold filter it could no longer express — the list data path resolves no date macros either, so < {14_days_ago} would ship the literal token and invert the comparison (the trap already documented on stale_articles). It becomes an open-deals list ordered longest-in-stage first, with both stage_entry_date and days_in_stage as columns so the 14-day line is readable per row.
  • Seeds carry stage_entry_date on every opportunity — open deals straddling the threshold in both directions, settled deals entering their closed stage on their close date — replacing the hardcoded days_in_stage numbers.
  • attainment_pct sort removed from the quarterly forecast view. Same defect class, caught by the new guard below: it is a formula, so that tiebreaker was a dead sort key and ordering within a quarter was arbitrary. Now closed_amount, the stored numerator behind attainment.
  • i18n for stage_entry_date in all four locale packs; docs/developers/api_reference.md and the three locales of content/docs/sales/opportunities.mdx updated (the old prose claimed the counter was "recomputed daily").

Testing

  • Unit tests pass (pnpm test — 147 passing)
  • Linting passes (pnpm lint — no new warnings)
  • Build succeeds (pnpm build); pnpm validate clean
  • Manual testing completed
  • New tests added

New tests

  • test/hooks-runtime.test.ts — the lifecycle hook stamps the clock on insert, does not overwrite a caller-supplied value, restarts it on a stage change, leaves it alone otherwise, and never writes days_in_stage.
  • test/metadata-references.test.ts — two class guards: no list view may filter or sort on a formula field, and no flow data node may filter on one. These are what surfaced the attainment_pct sort.

Verified against a booted server (pnpm start, fresh DB, REST + SQLite probes)

  • date columns store YYYY-MM-DD text, so the $lt comparison is lexicographic and chronological — no type-affinity inversion.
  • days_in_stage computes live on read (stage_entry_date: 2026-07-1912) and is correctly absent as a DB column.
  • The sweep's predicate selects exactly the 30 open deals with days_in_stage > 14; the boundary is exact (14 days excluded, 15 included).
  • Driving the flow on a temporary 1-minute cron, sys_automation_run shows query_stalled → 30 loop iterations. The same run on main produces the identical 30 iterations, so the selection is behaviour-preserving.

Additional Notes

While verifying the above, the flow's loop was observed to stop at the check_not_nudged decision node — the idempotency gate never opens, so no notification or follow-up task is created even now that the right rows are selected. This reproduces identically on main and is out of scope here; it is tracked separately in #562, with the root cause and a verified repro (flow conditions stay bare strings in the build artifact and never reach the CEL branch, so existingStallTask == null is compared as the literal string 'existingStallTask').

Two pre-existing campaign_enrollment expression warnings from pnpm validate and some boot-time crm_account update errors are likewise unrelated and unchanged.

`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
or hook anywhere raised it, so `opportunity_stagnation`'s
`days_in_stage > 14` filter matched only the rows the seed had hardcoded —
real deals could sit in a stage indefinitely without ever being nudged.

Add `stage_entry_date`, a stored, indexed date stamped by the lifecycle hook
on insert and re-stamped on every stage change, and turn `days_in_stage` into
a formula counting from it. Correct on every read, with no nightly
full-table pass.

A formula is evaluated after the query (`applyFormulaPlan` walks the returned
rows), so it is not a real column and cannot be a filter or sort key. Every
consumer that selects rather than displays moves to `stage_entry_date`:

- `opportunity_stagnation` filters `stage_entry_date < {TODAY() - 14}` — the
  same test, resolved by the flow template engine. Verified against a booted
  server: the sweep loops over exactly the 30 open deals with
  `days_in_stage > 14`, boundary inclusive-exact, matching the pre-change
  selection on main.
- The `stale_opportunities` view drops the threshold filter it could no
  longer express (the list data path resolves no date macros either) and
  becomes an open-deals list ordered longest-in-stage first, with
  `days_in_stage` on show.

Seeds carry `stage_entry_date` on every opportunity — open deals straddling
the 14-day threshold, settled deals entering their closed stage on the close
date — replacing the hardcoded `days_in_stage` numbers.

Two guards in `test/metadata-references.test.ts` pin the class: 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`.

Refs #489

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJygXUafjc7ckGMmE2AaGz
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Jul 31, 2026 2:44am

Request Review

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 31, 2026
@yinlianghui
yinlianghui marked this pull request as ready for review July 31, 2026 03:10
@yinlianghui
yinlianghui merged commit 9c22788 into main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants