feat(reports): SourcesSummaryProvider — агрегат сделок по utm_source (F1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-16 12:21:51 +03:00
parent 05b8051303
commit 5521d172b6
5 changed files with 217 additions and 3 deletions
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* sources_summary агрегат сделок по источнику (utm_source) за период (audit F1).
*
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
* paid / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
*/
class SourcesSummaryProvider implements ReportDataProvider
{
public function headers(): array
{
return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
}
public function rows(ReportJob $job): array
{
$params = $job->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';
}
}
@@ -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;
}
+14 -2
View File
@@ -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
@@ -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');
});
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Services\Reports\Providers\SourcesSummaryProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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([]);
});