156 خطوط
9.1 KiB
PHP
156 خطوط
9.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Call;
|
|
use App\Models\Deal;
|
|
use App\Models\DealStage;
|
|
use App\Models\Lead;
|
|
use App\Models\Pipeline;
|
|
use App\Models\QualityReview;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ProfessionalCrmP2Test extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed(RolePermissionSeeder::class);
|
|
}
|
|
|
|
public function test_pipeline_board_is_scoped_and_stage_moves_are_transactional_versioned_and_audited(): void
|
|
{
|
|
[$supervisor, $agent] = $this->teamUsers('Sales');
|
|
[, $otherAgent] = $this->teamUsers('Other');
|
|
$pipeline = Pipeline::where('is_default', true)->firstOrFail();
|
|
$new = DealStage::where('pipeline_id', $pipeline->id)->where('slug', 'new')->firstOrFail();
|
|
$won = DealStage::where('pipeline_id', $pipeline->id)->where('is_won', true)->firstOrFail();
|
|
$deal = $this->deal($agent, $pipeline, $new, 'Scoped opportunity');
|
|
$this->deal($otherAgent, $pipeline, $new, 'Foreign opportunity');
|
|
|
|
$this->actingAs($supervisor)->getJson("/api/pipelines/{$pipeline->id}/board")
|
|
->assertOk()->assertJsonPath('summary.count', 1)->assertJsonFragment(['title' => 'Scoped opportunity'])->assertJsonMissing(['title' => 'Foreign opportunity']);
|
|
|
|
$this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $won->id, 'version' => 1])
|
|
->assertUnprocessable()->assertJsonValidationErrors('reason');
|
|
$this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $won->id, 'version' => 1, 'reason' => 'نیاز مشتری رفع شد', 'final_amount' => 950000])
|
|
->assertOk()->assertJsonPath('status', 'won')->assertJsonPath('version', 2);
|
|
$this->actingAs($agent)->patchJson("/api/deals/{$deal->id}/stage", ['deal_stage_id' => $new->id, 'version' => 1])
|
|
->assertUnprocessable()->assertJsonValidationErrors('version');
|
|
|
|
$this->assertDatabaseHas('deal_stage_histories', ['deal_id' => $deal->id, 'from_stage_id' => $new->id, 'to_stage_id' => $won->id, 'changed_by' => $agent->id]);
|
|
$this->assertDatabaseHas('activity_logs', ['action' => 'deal_stage_changed', 'subject_id' => $deal->id]);
|
|
$this->actingAs($supervisor)->getJson('/api/reports/operations')
|
|
->assertOk()
|
|
->assertJsonMissingPath('summary.won_value')
|
|
->assertJsonStructure(['summary' => ['open_leads', 'issued_invoices', 'invoiced_total', 'outstanding_total']]);
|
|
}
|
|
|
|
public function test_global_search_saved_views_and_preferences_are_scoped_to_user_and_team(): void
|
|
{
|
|
[$supervisor, $agent, $team] = $this->teamUsers('Search', true);
|
|
[, $otherAgent] = $this->teamUsers('Other');
|
|
Lead::create(['first_name' => 'سارا', 'last_name' => 'فروش', 'company' => 'شرکت آلفا', 'phone' => '02111111111', 'assigned_to' => $agent->id, 'team_id' => $team->id]);
|
|
Lead::create(['first_name' => 'سارا', 'last_name' => 'محرمانه', 'company' => 'شرکت دیگر', 'phone' => '02122222222', 'assigned_to' => $otherAgent->id]);
|
|
|
|
$this->actingAs($supervisor)->getJson('/api/global-search?q=سارا')->assertOk()->assertJsonCount(1, 'leads')->assertJsonPath('leads.0.last_name', 'فروش');
|
|
$created = $this->actingAs($supervisor)->postJson('/api/saved-views', [
|
|
'entity_type' => 'deal', 'name' => 'فرصتهای داغ تیم', 'visibility' => 'team', 'team_id' => $team->id,
|
|
'filters' => ['forecast_category' => 'commit'], 'is_default' => true,
|
|
])->assertCreated();
|
|
$this->actingAs($agent)->getJson('/api/saved-views?entity_type=deal')->assertOk()->assertJsonFragment(['id' => $created->json('id'), 'name' => 'فرصتهای داغ تیم']);
|
|
$this->actingAs($agent)->putJson('/api/workspace-preferences', [
|
|
'hidden_widgets' => ['team_performance'], 'widget_order' => ['task_summary'],
|
|
'notifications' => [['notification_type' => 'sla', 'in_app_enabled' => true, 'is_muted' => false]],
|
|
])->assertOk()->assertJsonPath('dashboard.hidden_widgets.0', 'team_performance');
|
|
}
|
|
|
|
public function test_scoring_sla_detection_and_automation_are_deterministic_and_idempotent(): void
|
|
{
|
|
$admin = $this->user('admin');
|
|
$agent = $this->user('agent');
|
|
$lead = Lead::create([
|
|
'first_name' => 'مینا', 'last_name' => 'گرم', 'company' => 'Intent Co', 'phone' => '02133333333',
|
|
'email' => 'mina@example.test', 'city' => 'تهران', 'product_interest' => 'CRM', 'priority' => 4,
|
|
'interest_level' => 'high', 'assigned_to' => $agent->id,
|
|
]);
|
|
Lead::whereKey($lead->id)->update(['created_at' => now()->subHours(3)]);
|
|
$lead->refresh();
|
|
|
|
$this->actingAs($agent)->postJson("/api/leads/{$lead->id}/score")->assertOk()->assertJsonPath('score_level', 'warm');
|
|
$rule = $this->actingAs($admin)->postJson('/api/sla-rules', [
|
|
'name' => 'اولین تماس دو ساعته', 'event' => 'first_contact', 'warning_minutes' => 60, 'breach_minutes' => 120, 'is_active' => true,
|
|
])->assertCreated();
|
|
$this->actingAs($admin)->postJson('/api/sla/detect')->assertOk()->assertJsonPath('created', 1);
|
|
$this->actingAs($admin)->postJson('/api/sla/detect')->assertOk()->assertJsonPath('created', 0);
|
|
$this->assertDatabaseHas('sla_breaches', ['sla_rule_id' => $rule->json('id'), 'breachable_id' => $lead->id, 'status' => 'breached']);
|
|
|
|
$automation = $this->actingAs($admin)->postJson('/api/automations', [
|
|
'name' => 'پیگیری خودکار لید', 'trigger' => 'manual', 'actions' => [['type' => 'create_task']], 'is_active' => true,
|
|
])->assertCreated();
|
|
$payload = ['entity_type' => 'lead', 'entity_id' => $lead->id, 'event_key' => 'test-lead-followup'];
|
|
$this->actingAs($admin)->postJson("/api/automations/{$automation->json('id')}/run", $payload)->assertStatus(202)->assertJsonPath('status', 'completed');
|
|
$this->actingAs($admin)->postJson("/api/automations/{$automation->json('id')}/run", $payload)->assertStatus(202);
|
|
$this->assertDatabaseCount('automation_runs', 1);
|
|
$this->assertDatabaseCount('tasks', 1);
|
|
}
|
|
|
|
public function test_custom_fields_are_typed_and_quality_review_can_only_be_acknowledged_by_its_agent(): void
|
|
{
|
|
$admin = $this->user('admin');
|
|
$agent = $this->user('agent');
|
|
$other = $this->user('agent');
|
|
$lead = Lead::create(['first_name' => 'علی', 'last_name' => 'کیفی', 'company' => 'QA', 'phone' => '02144444444', 'assigned_to' => $agent->id]);
|
|
$field = $this->actingAs($admin)->postJson('/api/custom-fields', [
|
|
'entity_type' => 'lead', 'key' => 'annual_budget', 'label' => 'بودجه سالانه', 'type' => 'number',
|
|
'is_active' => true, 'is_filterable' => true, 'is_searchable' => false, 'is_required' => false,
|
|
])->assertCreated();
|
|
$this->actingAs($agent)->putJson("/api/custom-field-values/lead/{$lead->id}", ['values' => ['annual_budget' => 1250000]])
|
|
->assertOk()->assertJsonPath('0.custom_field_definition_id', $field->json('id'))->assertJsonPath('0.value_number', '1250000.0000');
|
|
|
|
$call = Call::create(['lead_id' => $lead->id, 'user_id' => $agent->id, 'phone' => $lead->phone, 'result' => 'answered']);
|
|
$review = QualityReview::create([
|
|
'call_id' => $call->id, 'reviewer_id' => $admin->id, 'agent_id' => $agent->id, 'version' => 1, 'is_current' => true,
|
|
'overall_score' => 80, 'is_shared_with_agent' => true,
|
|
]);
|
|
$this->actingAs($other)->postJson("/api/quality-reviews/{$review->id}/acknowledge")->assertForbidden();
|
|
$this->actingAs($agent)->postJson("/api/quality-reviews/{$review->id}/acknowledge", ['agent_response' => 'دریافت شد'])
|
|
->assertOk()->assertJsonPath('status', 'acknowledged');
|
|
$this->assertNotNull($review->fresh()->acknowledged_at);
|
|
}
|
|
|
|
private function deal(User $owner, Pipeline $pipeline, DealStage $stage, string $title): Deal
|
|
{
|
|
return Deal::create([
|
|
'title' => $title, 'owner_id' => $owner->id, 'created_by' => $owner->id,
|
|
'pipeline_id' => $pipeline->id, 'deal_stage_id' => $stage->id,
|
|
'estimated_value' => 1000000, 'win_probability' => $stage->probability,
|
|
'sales_stage' => $stage->slug, 'status' => 'open', 'version' => 1,
|
|
]);
|
|
}
|
|
|
|
private function teamUsers(string $name, bool $returnTeam = false): array
|
|
{
|
|
$supervisor = $this->user('supervisor');
|
|
$agent = $this->user('agent');
|
|
$team = Team::create(['name' => $name, 'supervisor_id' => $supervisor->id, 'is_active' => true]);
|
|
$team->members()->attach([$supervisor->id, $agent->id]);
|
|
|
|
return $returnTeam ? [$supervisor, $agent, $team] : [$supervisor, $agent];
|
|
}
|
|
|
|
private function user(string $role): User
|
|
{
|
|
$user = User::factory()->create(['is_active' => true]);
|
|
$user->assignRole($role);
|
|
|
|
return $user;
|
|
}
|
|
}
|