19f319cd5d
- ReportJob Eloquent (schema §13.5 report_jobs): status pending/processing/done/failed,
parameters JSONB (format/date_from/date_to/project_id?/manager_id?), constants
TYPES + FORMATS, helpers isActive/isDone/isFailed.
- ReportJobFactory + states processing/done/failed.
- App\Services\Reports\* пакет: ReportGenerator interface, GenerationResult DTO,
ReportGeneratorRegistry с резолвом по (type,format), DealsExportCsvGenerator
(Excel-friendly CSV: BOM, ; separator, \r\n, escape; deals JOIN projects/users/
supplier_lead_costs за date_from..date_to, soft-deleted скрыты).
- App\Jobs\GenerateReportJob: tries=1 (auto-retry отключён, retry через UI кнопку
CTO-6); меняет status pending → processing → done|failed, заполняет file_path/
file_size/generation_seconds/finished_at/expires_at (=NOW+30д).
- App\Http\Controllers\Api\ReportJobController под auth:sanctum:
- GET /api/reports/jobs?status=&limit=&offset= → jobs+total+counts+quota
- GET /api/reports/jobs/{id}
- POST /api/reports/jobs (квота CTO-7: max 3 active per tenant → 422)
- dispatch GenerateReportJob (sync на dev → файл готов сразу).
- Storage local-disk на dev (storage/app/reports/{tenant_id}/{job_id}.csv);
на prod заменим на s3 (Yandex Object Storage) отдельным коммитом.
- Pest +20 в tests/Feature/Reports/ReportJobControllerTest.php (всего 379/379,
1280 assertions): 401 без auth / GET пустой+only-own+ORDER+filter+counts+limit/
show success+404 own/foreign / store 422 (без полей/неизвестный type/date_to<from)/
dispatch / sync queue → done с file (BOM проверен) / unsupported format → failed/
квота 3 → 422 на 4-м / квота не считает done+failed / квота per-tenant.
- phpstan-baseline регенерирован (+1 ignored для Factory typing).
Этап 1/4 эпика Reports backend (закрыт). Этап 2: 4 типа × 4 формата.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
10 KiB
PHP
264 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\GenerateReportJob;
|
||
use App\Models\Project;
|
||
use App\Models\ReportJob;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Bus;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
Storage::fake('local');
|
||
});
|
||
|
||
function makeReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob
|
||
{
|
||
return ReportJob::create(array_merge([
|
||
'tenant_id' => $tenantId,
|
||
'user_id' => $userId,
|
||
'type' => 'deals_export',
|
||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
'status' => ReportJob::STATUS_PENDING,
|
||
], $overrides));
|
||
}
|
||
|
||
test('GET /api/reports/jobs: 401 без auth', function () {
|
||
auth()->logout();
|
||
$this->getJson('/api/reports/jobs')->assertStatus(401);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: пустой', function () {
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
$response->assertOk();
|
||
expect($response->json('jobs'))->toBe([]);
|
||
expect($response->json('total'))->toBe(0);
|
||
expect($response->json('quota.max_active'))->toBe(3);
|
||
expect($response->json('quota.active'))->toBe(0);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: возвращает только свои', function () {
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($other->id, $otherUser->id);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
expect($response->json('jobs'))->toHaveCount(1);
|
||
expect($response->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: ORDER BY created_at DESC', function () {
|
||
$j1 = makeReportJob($this->tenant->id, $this->user->id);
|
||
DB::table('report_jobs')->where('id', $j1->id)->update(['created_at' => Carbon::now()->subHours(2)]);
|
||
$j2 = makeReportJob($this->tenant->id, $this->user->id);
|
||
DB::table('report_jobs')->where('id', $j2->id)->update(['created_at' => Carbon::now()->subHour()]);
|
||
$j3 = makeReportJob($this->tenant->id, $this->user->id);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
$ids = array_column($response->json('jobs'), 'id');
|
||
expect($ids)->toBe([$j3->id, $j2->id, $j1->id]);
|
||
});
|
||
|
||
test('GET /api/reports/jobs?status=done: фильтр по статусу', function () {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/x/1.csv']);
|
||
|
||
$response = $this->getJson('/api/reports/jobs?status=done');
|
||
expect($response->json('jobs'))->toHaveCount(1);
|
||
expect($response->json('jobs.0.status'))->toBe('done');
|
||
});
|
||
|
||
test('GET /api/reports/jobs: counts по 4 статусам', function () {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_PROCESSING]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
expect($response->json('counts'))->toBe([
|
||
'pending' => 1, 'processing' => 1, 'done' => 1, 'failed' => 1,
|
||
]);
|
||
expect($response->json('quota.active'))->toBe(2); // pending + processing
|
||
});
|
||
|
||
test('GET /api/reports/jobs: limit + offset', function () {
|
||
foreach (range(1, 5) as $_) {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
}
|
||
$response = $this->getJson('/api/reports/jobs?limit=2&offset=1');
|
||
expect($response->json('jobs'))->toHaveCount(2);
|
||
expect($response->json('total'))->toBe(5);
|
||
});
|
||
|
||
test('GET /api/reports/jobs?status=invalid: 422', function () {
|
||
$this->getJson('/api/reports/jobs?status=invalid')->assertStatus(422);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: success + поля', function () {
|
||
$job = makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/1/2.csv', 'file_size' => 512]);
|
||
$response = $this->getJson("/api/reports/jobs/{$job->id}");
|
||
$response->assertOk();
|
||
expect($response->json('job.id'))->toBe($job->id);
|
||
expect($response->json('job.status'))->toBe('done');
|
||
expect($response->json('job.file_size'))->toBe(512);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: 404 unknown', function () {
|
||
$this->getJson('/api/reports/jobs/999999')->assertStatus(404);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: 404 чужой', function () {
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
$job = makeReportJob($other->id, $otherUser->id);
|
||
$this->getJson("/api/reports/jobs/{$job->id}")->assertStatus(404);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 без полей', function () {
|
||
$this->postJson('/api/reports/jobs', [])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 неизвестный type', function () {
|
||
$this->postJson('/api/reports/jobs', [
|
||
'type' => 'unknown', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 date_to < date_from', function () {
|
||
$this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-30', 'date_to' => '2026-04-01'],
|
||
])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: создаёт row + dispatch GenerateReportJob', function () {
|
||
Bus::fake();
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
expect($response->json('job.type'))->toBe('deals_export');
|
||
expect($response->json('job.parameters.format'))->toBe('csv');
|
||
expect($response->json('job.status'))->toBe('pending');
|
||
|
||
Bus::assertDispatched(GenerateReportJob::class, function (GenerateReportJob $job) use ($response) {
|
||
return $job->reportJobId === $response->json('job.id');
|
||
});
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота 3 одновременных → 422 на 4-м', function () {
|
||
Bus::fake();
|
||
foreach (range(1, 3) as $_) {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
}
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
expect($response->json('errors._quota.0'))->toContain('Максимум 3');
|
||
Bus::assertNotDispatched(GenerateReportJob::class);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота не считает done/failed', function () {
|
||
Bus::fake();
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
|
||
makeReportJob($this->tenant->id, $this->user->id); // pending = 1 active
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота изолирована per tenant', function () {
|
||
Bus::fake();
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
foreach (range(1, 3) as $_) {
|
||
makeReportJob($other->id, $otherUser->id); // 3 у чужого тенанта
|
||
}
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): pending → done с file', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
// Партиции deals создаются ahead=2 от текущего месяца — используем NOW для гарантии.
|
||
$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' => '+79991234567',
|
||
'contact_name' => 'Тест',
|
||
'status' => 'new',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$jobId = $response->json('job.id');
|
||
$job = ReportJob::find($jobId);
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toBe("reports/{$this->tenant->id}/{$jobId}.csv");
|
||
expect($job->file_size)->toBeGreaterThan(0);
|
||
expect($job->finished_at)->not->toBeNull();
|
||
expect($job->expires_at)->not->toBeNull();
|
||
|
||
Storage::disk('local')->assertExists($job->file_path);
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
expect($content)->toContain('+79991234567');
|
||
expect($content)->toContain("\u{FEFF}"); // BOM для Excel
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): unsupported format → failed', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'xlsx', // этап 1: только csv
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('failed');
|
||
expect($job->error_message)->toContain('Неподдерживаемая');
|
||
});
|