Deal health, forecasts, and quotas
Detect rotting deals, score deal health, and calculate pipeline forecasts, stage velocity, and quota attainment.
The sales package calculates its metrics on demand. Nothing is cached or stored, and no scheduled job is involved: each method or action below runs its queries when you call it. None of the calculations apply team scoping, so filter by pipeline or owner yourself where that matters.
Rotting deals
A stage's rot_after_days sets how long an open deal may sit in it. Two methods on Deal use it:
daysInCurrentStage(): int: whole days since the deal entered its current stage (from the newest stage history row, orcreated_atif there is none).isRotten(): bool:truewhen the deal is open, its stage has arot_after_daysgreater than zero, anddaysInCurrentStage()is at least that number.
if ($deal->isRotten()) {
$deal->logTask(
title: "Unblock {$deal->name}",
dueAt: now()->addDay(),
);
}There is no built-in command that flags or notifies on rotting deals. Rotting status feeds into the health score, the forecast's stale_deals_count, and stage velocity's stale_deal_count.
Health score
$deal->getHealthScore() (backed by Odden\Sales\Actions\CalculateDealHealthScoreAction) returns a score from 0 to 100 with an explanation:
$health = $deal->getHealthScore();
$health['score']; // int
$health['status']; // 'strong', 'moderate', 'at_risk', or 'stalled'
$health['badge_color']; // 'success', 'info', 'warning', or 'danger'
$health['badge_label']; // Display label, for example "At Risk (42)" with a leading emoji
$health['factors']; // list of ['name', 'points', 'positive', 'description']
$health['recommendations']; // list of stringsWon deals always score 100 (strong) and lost deals 0 (stalled). Open deals start at 50 and are adjusted:
| Factor | Points |
|---|---|
| Two or more associated contacts | +20 |
| One associated contact | +10 |
| No associated contacts | −15 |
| Latest activity 5 days ago or less | +20 |
| Latest activity 6 to 12 days ago | +5 |
| Latest activity more than 12 days ago | −25 |
| No activities | −10 |
| Deal is rotting | −30 |
| Deal is not rotting | +10 |
Latest quote is accepted |
+30 |
Latest quote is sent |
+15 |
| Expected close date is today or earlier | −15 |
| Expected close date is in the future | +5 |
The result for open deals is clamped to 5–99. Status is strong at 75 or more, moderate at 50 or more, at_risk at 30 or more, and stalled below that.
Any activity on the deal counts toward recency, including activities the package logs itself, such as stage automation tasks and quote portal views.
The package also ships a Blade partial, odden-sales::deals.health-score-modal, which renders this array (passed as $health). The Odden Filament panel uses it on the deal page. It uses <x-filament::icon> components and Tailwind classes, so it only renders in an app with Filament installed; outside Filament, build your own view from the array.
Pipeline forecast
$pipeline->forecast() returns metrics for one pipeline. Call Odden\Sales\Actions\CalculatePipelineForecastAction::execute(?int $pipelineId = null) directly with null to cover all pipelines.
use Odden\Sales\Actions\CalculatePipelineForecastAction;
$forecast = $pipeline->forecast();
$all = app(CalculatePipelineForecastAction::class)->execute();| Key | Meaning |
|---|---|
open_value |
Sum of amount for open deals. |
open_count |
Number of open deals. |
weighted_forecast |
Sum of each open deal's amount × stage probability / 100. |
won_value |
Sum of amount for won deals. |
won_count |
Number of won deals. |
lost_count |
Number of lost deals. |
win_rate |
won / (won + lost) × 100, one decimal. 0.0 when nothing is closed. |
average_deal_size |
Average amount across all deals, including open and lost ones. |
lost_reasons |
Count of lost deals per lost_reason, with unspecified for empty reasons. |
stale_deals_count |
Number of open deals that are rotting. |
All values are all-time; there is no date filter. Soft-deleted deals are excluded.
Stage velocity
Odden\Sales\Actions\CalculateStageVelocityAction::execute(?int $pipelineId = null) reports time spent per stage:
use Odden\Sales\Actions\CalculateStageVelocityAction;
$velocity = app(CalculateStageVelocityAction::class)->execute($pipeline->id);
$velocity['average_sales_cycle_days']; // float
foreach ($velocity['stages'] as $stageId => $metrics) {
$metrics['stage_name'];
$metrics['probability'];
$metrics['average_dwell_days']; // Average time in the stage for deals that have left it
$metrics['transition_count']; // How many times deals entered the stage
$metrics['stale_deal_count']; // Open deals in the stage past rot_after_days
}average_sales_cycle_days is the average number of days from created_at to closed_at for won deals. stages is keyed by stage ID.
Quotas
An Odden\Sales\Models\SalesQuota is a revenue target for one user:
| Attribute | Type | Notes |
|---|---|---|
user_id |
int | The rep. |
pipeline_id |
int, nullable | Limit the quota to one pipeline, or null for all. |
period_type |
QuotaPeriod |
Monthly, Quarterly, or Yearly. Defaults to monthly. Informational only. |
period_start, period_end |
date | The period that is measured. |
target_amount |
decimal | |
currency |
string(3) | Defaults to USD. Not used in calculations. |
Odden\Sales\Actions\CalculateQuotaAttainmentAction::execute(SalesQuota $quota) measures it:
use Odden\Sales\Actions\CalculateQuotaAttainmentAction;
use Odden\Sales\Enums\QuotaPeriod;
use Odden\Sales\Models\SalesQuota;
$quota = SalesQuota::create([
'user_id' => $rep->id,
'pipeline_id' => $pipeline->id,
'period_type' => QuotaPeriod::Quarterly,
'period_start' => now()->startOfQuarter(),
'period_end' => now()->endOfQuarter(),
'target_amount' => 40000,
]);
$attainment = app(CalculateQuotaAttainmentAction::class)->execute($quota);| Key | Meaning |
|---|---|
target_amount |
The quota's target. |
won_amount |
Sum of amount for won deals owned by the user with closed_at inside the period. |
attainment_percent |
won_amount / target × 100, one decimal. |
gap_to_target |
target − won_amount, never below zero. |
open_pipeline_amount |
Sum of amount for the user's open deals with expected_close_date inside the period. |
coverage_ratio |
(won_amount + open_pipeline_amount) / target, two decimals. |
Deals count toward the user in owner_id. Amounts are summed without currency conversion. Quotas are also used by quota-weighted lead routing.