feat(reports): SourcesSummaryProvider — агрегат сделок по utm_source (F1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user