Skip to content

Commit a18776b

Browse files
committed
feat(phase-41): CRM pipeline analytics with funnel, win rate, and velocity
- CrmPipelineController: funnel (stage breakdown + weighted value), win rate, velocity, leaderboard - Funnel computes expected_revenue and probability-weighted deal values per stage - Win rate supports from/to date filter for period analysis - Velocity tracks avg days to close and avg deal value from won deals - Routes: GET /crm/pipeline/{funnel,win-rate,velocity,leaderboard} - 7 feature tests covering all analytics endpoints Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 83c00c7 commit a18776b

4 files changed

Lines changed: 334 additions & 35 deletions

File tree

CLAUDE.md

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -137,41 +137,42 @@ beforeEach(function () {
137137

138138
## Development Phases Completed
139139

140-
| Phase | Description | Status |
141-
| ----- | ------------------------------------------------------------- | ------ |
142-
| 1–8 | Core modules, models, migrations, seeders, Inertia pages ||
143-
| 9 | REST API — 200+ endpoints across 40 modules ||
144-
| 10 | Demo data seeders for all 35 modules ||
145-
| 11 | WebSockets — Laravel Reverb + Echo ||
146-
| 12 | Queue jobs — invoice, low stock, payroll, bulk import ||
147-
| 13 | Mail notifications — invoice, low stock, payroll, approval ||
148-
| 14 | PDF generation — invoices, purchase orders, payslips ||
149-
| 15 | Import/Export — CSV/XLSX for products, contacts, invoices ||
150-
| 16 | Dashboard analytics — module stats + activity feed ||
151-
| 17 | Tenant isolation tests — 22 cross-tenant security tests ||
152-
| 18 | API rate limiting (60/min) + security headers ||
153-
| 19 | Global search — 7 modules, frontend component ||
154-
| 20 | Audit log — migration, trait, observer, API endpoint ||
155-
| 21 | GitHub Actions CI/CD — PHP tests + TS check + ESLint ||
156-
| 22 | Reports API — financial/inventory/HR + CLAUDE.md ||
157-
| 23 | In-app notifications — DB model, API, frontend bell ||
158-
| 24 | Scheduled Report Delivery — ReportSchedule model + job + mail ||
159-
| 25 | Health Checks & Metrics — /api/v1/health + /api/v1/metrics ||
160-
| 26 | Dashboard Widgets — per-user customizable widget layout ||
161-
| 27 | Email Template Management — CRUD + variable preview ||
162-
| 28 | Tenant Feature Flags — per-tenant feature toggle system ||
163-
| 29 | User Preferences — timezone, locale, UI density, etc. ||
164-
| 30 | Activity Feed API — filterable event stream from audit logs ||
165-
| 31 | Unified Calendar API — tasks, leaves, events, invoices ||
166-
| 32 | Financial Forecasting — revenue + cash-flow projections ||
167-
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168-
| 34 | Budget Management REST API — CRUD + activate + variance ||
169-
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170-
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171-
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172-
| 38 | API Token Management — named tokens with abilities and expiry ||
173-
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174-
| 40 | HR Leave Balance API — allocation, team view, year filters ||
140+
| Phase | Description | Status |
141+
| ----- | ---------------------------------------------------------------- | ------ |
142+
| 1–8 | Core modules, models, migrations, seeders, Inertia pages ||
143+
| 9 | REST API — 200+ endpoints across 40 modules ||
144+
| 10 | Demo data seeders for all 35 modules ||
145+
| 11 | WebSockets — Laravel Reverb + Echo ||
146+
| 12 | Queue jobs — invoice, low stock, payroll, bulk import ||
147+
| 13 | Mail notifications — invoice, low stock, payroll, approval ||
148+
| 14 | PDF generation — invoices, purchase orders, payslips ||
149+
| 15 | Import/Export — CSV/XLSX for products, contacts, invoices ||
150+
| 16 | Dashboard analytics — module stats + activity feed ||
151+
| 17 | Tenant isolation tests — 22 cross-tenant security tests ||
152+
| 18 | API rate limiting (60/min) + security headers ||
153+
| 19 | Global search — 7 modules, frontend component ||
154+
| 20 | Audit log — migration, trait, observer, API endpoint ||
155+
| 21 | GitHub Actions CI/CD — PHP tests + TS check + ESLint ||
156+
| 22 | Reports API — financial/inventory/HR + CLAUDE.md ||
157+
| 23 | In-app notifications — DB model, API, frontend bell ||
158+
| 24 | Scheduled Report Delivery — ReportSchedule model + job + mail ||
159+
| 25 | Health Checks & Metrics — /api/v1/health + /api/v1/metrics ||
160+
| 26 | Dashboard Widgets — per-user customizable widget layout ||
161+
| 27 | Email Template Management — CRUD + variable preview ||
162+
| 28 | Tenant Feature Flags — per-tenant feature toggle system ||
163+
| 29 | User Preferences — timezone, locale, UI density, etc. ||
164+
| 30 | Activity Feed API — filterable event stream from audit logs ||
165+
| 31 | Unified Calendar API — tasks, leaves, events, invoices ||
166+
| 32 | Financial Forecasting — revenue + cash-flow projections ||
167+
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168+
| 34 | Budget Management REST API — CRUD + activate + variance ||
169+
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170+
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171+
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172+
| 38 | API Token Management — named tokens with abilities and expiry ||
173+
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174+
| 40 | HR Leave Balance API — allocation, team view, year filters ||
175+
| 41 | CRM Pipeline Analytics — funnel, win rate, velocity, leaderboard ||
175176

176177
## File Locations Reference
177178

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\CRM\Models\CrmLead;
6+
use App\Modules\CRM\Models\CrmStage;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\DB;
10+
11+
class CrmPipelineController extends ApiController
12+
{
13+
public function funnel(Request $request): JsonResponse
14+
{
15+
$tenantId = $this->tenantId($request);
16+
17+
$stages = CrmStage::where('tenant_id', $tenantId)
18+
->where('is_active', true)
19+
->orderBy('sequence')
20+
->withCount(['leads as open_count' => fn ($q) => $q->where('status', 'open')])
21+
->withCount(['leads as total_count'])
22+
->get()
23+
->map(function ($stage) use ($tenantId) {
24+
$revenue = CrmLead::where('tenant_id', $tenantId)
25+
->where('stage_id', $stage->id)
26+
->where('status', 'open')
27+
->sum('expected_revenue');
28+
29+
return [
30+
'stage_id' => $stage->id,
31+
'stage_name' => $stage->name,
32+
'sequence' => $stage->sequence,
33+
'open_deals' => $stage->open_count,
34+
'total_deals' => $stage->total_count,
35+
'expected_revenue' => round($revenue, 2),
36+
'probability' => $stage->probability,
37+
'weighted_value' => round($revenue * ($stage->probability / 100), 2),
38+
];
39+
});
40+
41+
$total = [
42+
'pipeline_value' => $stages->sum('expected_revenue'),
43+
'weighted_value' => $stages->sum('weighted_value'),
44+
'open_deals' => $stages->sum('open_deals'),
45+
];
46+
47+
return $this->success(['stages' => $stages, 'total' => $total]);
48+
}
49+
50+
public function winRate(Request $request): JsonResponse
51+
{
52+
$tenantId = $this->tenantId($request);
53+
$from = $request->get('from', now()->subMonths(12)->toDateString());
54+
$to = $request->get('to', now()->toDateString());
55+
56+
$won = CrmLead::where('tenant_id', $tenantId)->where('status', 'won')
57+
->whereBetween('won_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
58+
->count();
59+
$lost = CrmLead::where('tenant_id', $tenantId)->where('status', 'lost')
60+
->whereBetween('lost_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
61+
->count();
62+
63+
$total = $won + $lost;
64+
$rate = $total > 0 ? round($won / $total * 100, 2) : 0;
65+
66+
$wonRevenue = CrmLead::where('tenant_id', $tenantId)->where('status', 'won')
67+
->whereBetween('won_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
68+
->sum('expected_revenue');
69+
70+
return $this->success([
71+
'won' => $won,
72+
'lost' => $lost,
73+
'total' => $total,
74+
'win_rate' => $rate,
75+
'won_revenue' => round($wonRevenue, 2),
76+
'period' => ['from' => $from, 'to' => $to],
77+
]);
78+
}
79+
80+
public function velocity(Request $request): JsonResponse
81+
{
82+
$tenantId = $this->tenantId($request);
83+
84+
$won = CrmLead::where('tenant_id', $tenantId)
85+
->where('status', 'won')
86+
->whereNotNull('won_at')
87+
->select(['created_at', 'won_at', 'expected_revenue'])
88+
->get();
89+
90+
$avgDays = $won->isNotEmpty()
91+
? $won->avg(fn ($l) => $l->created_at->diffInDays($l->won_at))
92+
: 0;
93+
94+
$avgRevenue = $won->avg('expected_revenue') ?? 0;
95+
96+
return $this->success([
97+
'deals_analyzed' => $won->count(),
98+
'avg_days_to_close' => round($avgDays, 1),
99+
'avg_deal_value' => round($avgRevenue, 2),
100+
'deals_closed_per_month' => $won->count() > 0
101+
? round($won->count() / max(1, now()->diffInMonths($won->min('won_at') ?: now())), 1)
102+
: 0,
103+
]);
104+
}
105+
106+
public function leaderboard(Request $request): JsonResponse
107+
{
108+
$tenantId = $this->tenantId($request);
109+
110+
$leaders = CrmLead::where('tenant_id', $tenantId)
111+
->where('status', 'won')
112+
->whereNotNull('assigned_to')
113+
->select('assigned_to', DB::raw('COUNT(*) as deals_won'), DB::raw('SUM(expected_revenue) as revenue'))
114+
->groupBy('assigned_to')
115+
->orderByDesc('revenue')
116+
->with('assignee:id,name')
117+
->limit(10)
118+
->get()
119+
->map(fn ($row) => [
120+
'user_id' => $row->assigned_to,
121+
'name' => $row->assignee?->name ?? 'Unknown',
122+
'deals_won' => $row->deals_won,
123+
'revenue' => round($row->revenue, 2),
124+
]);
125+
126+
return $this->success($leaders);
127+
}
128+
129+
private function tenantId(Request $request): int
130+
{
131+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
132+
}
133+
}

erp/routes/api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// CRM Pipeline Analytics
452+
Route::prefix('crm/pipeline')->group(function () {
453+
Route::get('/funnel', [\App\Http\Controllers\Api\V1\CrmPipelineController::class, 'funnel']);
454+
Route::get('/win-rate', [\App\Http\Controllers\Api\V1\CrmPipelineController::class, 'winRate']);
455+
Route::get('/velocity', [\App\Http\Controllers\Api\V1\CrmPipelineController::class, 'velocity']);
456+
Route::get('/leaderboard', [\App\Http\Controllers\Api\V1\CrmPipelineController::class, 'leaderboard']);
457+
});
458+
451459
// Leave Balance Management
452460
Route::prefix('leave')->group(function () {
453461
Route::get('/types', [\App\Http\Controllers\Api\V1\LeaveBalanceController::class, 'types']);
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\CRM\Models\CrmLead;
6+
use App\Modules\CRM\Models\CrmStage;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'Pipeline Co', 'slug' => 'pipeline-co-' . uniqid()]);
12+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->user->assignRole('super-admin');
14+
$this->token = $this->user->createToken('test')->plainTextToken;
15+
app()->instance('tenant', $this->tenant);
16+
17+
$this->stage = CrmStage::create([
18+
'tenant_id' => $this->tenant->id,
19+
'name' => 'Proposal',
20+
'sequence' => 20,
21+
'probability' => 50,
22+
'is_active' => true,
23+
]);
24+
});
25+
26+
test('funnel returns stages with deal counts and revenue', function () {
27+
CrmLead::create([
28+
'tenant_id' => $this->tenant->id,
29+
'title' => 'Deal 1',
30+
'stage_id' => $this->stage->id,
31+
'status' => 'open',
32+
'expected_revenue' => 10000,
33+
'created_by' => $this->user->id,
34+
]);
35+
36+
$response = $this->withToken($this->token)
37+
->getJson('/api/v1/crm/pipeline/funnel')
38+
->assertStatus(200)
39+
->assertJsonStructure(['data' => ['stages', 'total']]);
40+
41+
$stages = $response->json('data.stages');
42+
expect($stages)->not->toBeEmpty();
43+
expect($stages[0]['stage_name'])->toBe('Proposal');
44+
expect($stages[0]['expected_revenue'])->toBe(10000);
45+
expect($stages[0]['weighted_value'])->toBe(5000);
46+
});
47+
48+
test('win rate returns correct percentages', function () {
49+
CrmLead::create([
50+
'tenant_id' => $this->tenant->id,
51+
'title' => 'Won Deal',
52+
'stage_id' => $this->stage->id,
53+
'status' => 'won',
54+
'won_at' => now(),
55+
'expected_revenue' => 5000,
56+
'created_by' => $this->user->id,
57+
]);
58+
59+
CrmLead::create([
60+
'tenant_id' => $this->tenant->id,
61+
'title' => 'Lost Deal',
62+
'stage_id' => $this->stage->id,
63+
'status' => 'lost',
64+
'lost_at' => now(),
65+
'expected_revenue' => 3000,
66+
'created_by' => $this->user->id,
67+
]);
68+
69+
$response = $this->withToken($this->token)
70+
->getJson('/api/v1/crm/pipeline/win-rate')
71+
->assertStatus(200);
72+
73+
$data = $response->json('data');
74+
expect($data['won'])->toBe(1);
75+
expect($data['lost'])->toBe(1);
76+
expect($data['win_rate'])->toBe(50);
77+
expect($data['won_revenue'])->toBe(5000);
78+
});
79+
80+
test('velocity returns avg days to close', function () {
81+
CrmLead::create([
82+
'tenant_id' => $this->tenant->id,
83+
'title' => 'Fast Deal',
84+
'stage_id' => $this->stage->id,
85+
'status' => 'won',
86+
'won_at' => now(),
87+
'expected_revenue' => 8000,
88+
'created_by' => $this->user->id,
89+
]);
90+
91+
$this->withToken($this->token)
92+
->getJson('/api/v1/crm/pipeline/velocity')
93+
->assertStatus(200)
94+
->assertJsonStructure(['data' => ['deals_analyzed', 'avg_days_to_close', 'avg_deal_value', 'deals_closed_per_month']]);
95+
});
96+
97+
test('leaderboard shows top performers', function () {
98+
CrmLead::create([
99+
'tenant_id' => $this->tenant->id,
100+
'title' => 'Rep Deal',
101+
'stage_id' => $this->stage->id,
102+
'status' => 'won',
103+
'won_at' => now(),
104+
'assigned_to' => $this->user->id,
105+
'expected_revenue' => 12000,
106+
'created_by' => $this->user->id,
107+
]);
108+
109+
$response = $this->withToken($this->token)
110+
->getJson('/api/v1/crm/pipeline/leaderboard')
111+
->assertStatus(200);
112+
113+
$leaders = $response->json('data');
114+
expect($leaders)->not->toBeEmpty();
115+
expect($leaders[0]['deals_won'])->toBe(1);
116+
expect($leaders[0]['revenue'])->toBe(12000);
117+
});
118+
119+
test('funnel total aggregates pipeline value', function () {
120+
CrmLead::create([
121+
'tenant_id' => $this->tenant->id,
122+
'title' => 'Big Deal',
123+
'stage_id' => $this->stage->id,
124+
'status' => 'open',
125+
'expected_revenue' => 50000,
126+
'created_by' => $this->user->id,
127+
]);
128+
129+
$response = $this->withToken($this->token)
130+
->getJson('/api/v1/crm/pipeline/funnel')
131+
->assertStatus(200);
132+
133+
expect($response->json('data.total.pipeline_value'))->toBe(50000);
134+
expect($response->json('data.total.open_deals'))->toBe(1);
135+
});
136+
137+
test('win rate supports date range filter', function () {
138+
CrmLead::create([
139+
'tenant_id' => $this->tenant->id,
140+
'title' => 'Old Win',
141+
'stage_id' => $this->stage->id,
142+
'status' => 'won',
143+
'won_at' => now()->subYears(2),
144+
'expected_revenue' => 1000,
145+
'created_by' => $this->user->id,
146+
]);
147+
148+
$response = $this->withToken($this->token)
149+
->getJson('/api/v1/crm/pipeline/win-rate?from=' . now()->subMonth()->toDateString() . '&to=' . now()->toDateString())
150+
->assertStatus(200);
151+
152+
expect($response->json('data.won'))->toBe(0);
153+
});
154+
155+
test('requires authentication', function () {
156+
$this->getJson('/api/v1/crm/pipeline/funnel')->assertStatus(401);
157+
});

0 commit comments

Comments
 (0)