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; } }