From 5521d172b62af2043a990d052d9f89605df99e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 12:21:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(reports):=20SourcesSummaryProvider=20?= =?UTF-8?q?=E2=80=94=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=BE=20utm=5Fsource=20?= =?UTF-8?q?(F1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Providers/SourcesSummaryProvider.php | 69 +++++++++++++ .../Reports/ReportGeneratorRegistry.php | 5 +- app/phpstan-baseline.neon | 16 +++- .../Reports/ReportJobControllerTest.php | 34 +++++++ .../Reports/SourcesSummaryProviderTest.php | 96 +++++++++++++++++++ 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 app/app/Services/Reports/Providers/SourcesSummaryProvider.php create mode 100644 app/tests/Feature/Reports/SourcesSummaryProviderTest.php diff --git a/app/app/Services/Reports/Providers/SourcesSummaryProvider.php b/app/app/Services/Reports/Providers/SourcesSummaryProvider.php new file mode 100644 index 00000000..3eab73e1 --- /dev/null +++ b/app/app/Services/Reports/Providers/SourcesSummaryProvider.php @@ -0,0 +1,69 @@ +parameters ?? []; + $dateFrom = Carbon::parse($params['date_from'])->startOfDay(); + $dateTo = Carbon::parse($params['date_to'])->endOfDay(); + + return DB::transaction(function () use ($job, $dateFrom, $dateTo): array { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id); + + $rows = DB::table('deals') + ->where('tenant_id', $job->tenant_id) + ->whereNull('deleted_at') + ->where('is_test', false) + ->whereBetween('received_at', [$dateFrom, $dateTo]) + ->groupBy('utm_source') + ->orderByRaw('COUNT(*) DESC') + ->orderBy('utm_source') + ->selectRaw( + "utm_source, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'paid') AS paid" + ) + ->get(); + + return $rows->map(function ($row): array { + $source = $row->utm_source !== null && trim((string) $row->utm_source) !== '' + ? (string) $row->utm_source + : 'Прямые / без метки'; + $total = (int) $row->total; + $paid = (int) $row->paid; + $conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0; + + return [$source, $total, $paid, $conversion]; + })->all(); + }); + } + + public function slug(): string + { + return 'sources'; + } +} diff --git a/app/app/Services/Reports/ReportGeneratorRegistry.php b/app/app/Services/Reports/ReportGeneratorRegistry.php index 19c64045..2deb9443 100644 --- a/app/app/Services/Reports/ReportGeneratorRegistry.php +++ b/app/app/Services/Reports/ReportGeneratorRegistry.php @@ -13,6 +13,7 @@ use App\Services\Reports\Formatters\XlsxFormatter; use App\Services\Reports\Providers\DealsExportProvider; use App\Services\Reports\Providers\ManagersSummaryProvider; use App\Services\Reports\Providers\ReportDataProvider; +use App\Services\Reports\Providers\SourcesSummaryProvider; use InvalidArgumentException; /** @@ -29,6 +30,7 @@ class ReportGeneratorRegistry public function __construct( private readonly DealsExportProvider $dealsExport, private readonly ManagersSummaryProvider $managersSummary, + private readonly SourcesSummaryProvider $sourcesSummary, private readonly CsvFormatter $csv, private readonly XlsxFormatter $xlsx, private readonly JsonFormatter $json, @@ -40,6 +42,7 @@ class ReportGeneratorRegistry return match ($type) { 'deals_export' => $this->dealsExport, 'managers_summary' => $this->managersSummary, + 'sources_summary' => $this->sourcesSummary, default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"), }; } @@ -62,7 +65,7 @@ class ReportGeneratorRegistry } // Этап 2b: deals_export + managers_summary. - $supportedTypes = ['deals_export', 'managers_summary']; + $supportedTypes = ['deals_export', 'managers_summary', 'sources_summary']; if (! in_array($type, $supportedTypes, true)) { return false; } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index dbf55772..85c6618d 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1065,7 +1065,7 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 28 + count: 30 path: tests/Feature/Reports/ReportJobControllerTest.php - @@ -1089,7 +1089,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound - count: 12 + count: 13 path: tests/Feature/Reports/ReportJobControllerTest.php - @@ -1134,6 +1134,18 @@ parameters: count: 12 path: tests/Feature/Reports/ReportLifecycleTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 8 + path: tests/Feature/Reports/SourcesSummaryProviderTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 12 + path: tests/Feature/Reports/SourcesSummaryProviderTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#' identifier: property.notFound diff --git a/app/tests/Feature/Reports/ReportJobControllerTest.php b/app/tests/Feature/Reports/ReportJobControllerTest.php index b1f92940..9c15d8c4 100644 --- a/app/tests/Feature/Reports/ReportJobControllerTest.php +++ b/app/tests/Feature/Reports/ReportJobControllerTest.php @@ -372,3 +372,37 @@ test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', fu expect($content)->toContain('Менеджер'); expect($content)->toContain('Иван Петров'); }); + +test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', function () { + config()->set('queue.default', 'sync'); + + $now = Carbon::now()->startOfMonth()->addDays(10); + $project = Project::factory()->create(['tenant_id' => $this->tenant->id]); + DB::table('deals')->insert([ + 'tenant_id' => $this->tenant->id, + 'project_id' => $project->id, + 'phone' => '+79990002233', + 'status' => 'paid', + 'utm_source' => 'yandex', + 'received_at' => $now, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]); + + $response = $this->postJson('/api/reports/jobs', [ + 'type' => 'sources_summary', 'format' => 'csv', + 'parameters' => [ + 'date_from' => $now->copy()->startOfMonth()->toDateString(), + 'date_to' => $now->copy()->endOfMonth()->toDateString(), + ], + ]); + + $response->assertStatus(201); + $job = ReportJob::find($response->json('job.id')); + expect($job->status)->toBe('done'); + expect($job->file_path)->toEndWith('.csv'); + + $content = Storage::disk('local')->get($job->file_path); + expect($content)->toContain('Источник'); + expect($content)->toContain('yandex'); +}); diff --git a/app/tests/Feature/Reports/SourcesSummaryProviderTest.php b/app/tests/Feature/Reports/SourcesSummaryProviderTest.php new file mode 100644 index 00000000..b423ad84 --- /dev/null +++ b/app/tests/Feature/Reports/SourcesSummaryProviderTest.php @@ -0,0 +1,96 @@ +tenant = Tenant::factory()->create(); + $this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]); +}); + +function seedSourceDeal(int $tenantId, int $projectId, array $overrides = []): void +{ + DB::table('deals')->insert(array_merge([ + 'tenant_id' => $tenantId, + 'project_id' => $projectId, + 'phone' => '+7999'.random_int(1000000, 9999999), + 'status' => 'new', + 'received_at' => Carbon::now()->startOfMonth()->addDays(10), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ], $overrides)); +} + +function sourcesJob(int $tenantId): ReportJob +{ + return new ReportJob([ + 'tenant_id' => $tenantId, + 'type' => 'sources_summary', + 'parameters' => [ + 'format' => 'csv', + 'date_from' => Carbon::now()->startOfMonth()->toDateString(), + 'date_to' => Carbon::now()->endOfMonth()->toDateString(), + ], + ]); +} + +test('headers: 4 колонки', function () { + expect((new SourcesSummaryProvider)->headers()) + ->toBe(['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)']); +}); + +test('slug = sources', function () { + expect((new SourcesSummaryProvider)->slug())->toBe('sources'); +}); + +test('агрегирует сделки по utm_source', function () { + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']); + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']); + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']); + + $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id)); + + expect($rows)->toHaveCount(2); + // ORDER BY COUNT(*) DESC → yandex (2) первый. + expect($rows[0])->toBe(['yandex', 2, 1, 50.0]); + expect($rows[1])->toBe(['vk', 1, 1, 100.0]); +}); + +test('сделки без utm_source → «Прямые / без метки»', function () { + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => null]); + + $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id)); + + expect($rows)->toHaveCount(1); + expect($rows[0][0])->toBe('Прямые / без метки'); +}); + +test('исключает soft-deleted и тестовые сделки', function () { + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex']); + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'deleted_at' => Carbon::now()]); + seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'is_test' => true]); + + $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id)); + + expect($rows)->toHaveCount(1); + expect($rows[0][1])->toBe(1); +}); + +test('изолирует по tenant_id', function () { + $other = Tenant::factory()->create(); + $otherProject = Project::factory()->create(['tenant_id' => $other->id]); + seedSourceDeal($other->id, $otherProject->id, ['utm_source' => 'yandex']); + + $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id)); + + expect($rows)->toBe([]); +});