Files
portal/app/tests/Feature/DealCreateTest.php
T
Дмитрий a1ea003642 phase2(xlsx-export): PhpSpreadsheet 5.7 + format=csv|xlsx на /api/deals/export
Закрыт TODO «реальный XLSX-export» из v1.51. Russian users prefer .xlsx
(1С/Excel) — заменяет CSV как default. CSV остаётся через format=csv.

Backend (DealController::export):
- Body теперь: {tenant_id, ids, format?: 'csv' | 'xlsx'}; default 'csv'.
- buildXlsx: Spreadsheet + setTitle 'Сделки' + setCellValue A1..G1
  headers + bold(A1:G1) + setAutoSize всех колонок A..G. Writer пишет
  через ob_start/php://output для возврата бинарной строки.
- Content-Type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  + Content-Disposition с .xlsx.

Quirk: PhpSpreadsheet 5.x удалил deprecated setCellValueByColumnAndRow —
мигрировал на A1-нотацию (setCellValue('A2', $val)).

Pest +4 (DealCreateTest):
- xlsx binary с Content-Type + magic bytes "PK\x03\x04" + size >2KB.
- IOFactory::createReader('Xlsx') распаковывает: sheet «Сделки» +
  A1='ID' bold + A2/B2/C2 — реальные данные сделки.
- 422 на неизвестный format.
- Default (без format) — backward-compat CSV.

Frontend:
- api/deals.ts разделён: exportDeals (CSV string) + exportDealsXlsx
  (Blob, responseType='blob').
- applyBulkExport(format='xlsx' | 'csv') в DealsView — default 'xlsx'.
  XLSX → triggerBlobDownload (новый helper). CSV → старый CSV-helper.
  На fail — fallback на local CSV.

Vitest +3 (DealsListIntegration):
- xlsx default → exportDealsXlsx + Blob download + toast «XLSX».
- 'csv' → exportDeals + toast «CSV».
- xlsx reject → fallback на local CSV + toast «Backend недоступен».

PHPStan baseline регенерирован (удалена unmatched ignore-запись для
setCellValueByColumnAndRow). cspell-glossary +дефолтит +vnd +spreadsheetml.

Регресс:
- Lint+type-check+format passed.
- Vitest 269/269 за 18.49 сек (+3 от 266).
- Vite build 982 ms.
- Pint + PHPStan passed.
- Pest 197/197 за 26.05 сек (+4 от 193, 784 assertions).

Реестр v1.61→v1.62 / CLAUDE.md v1.52→v1.53.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:08:53 +03:00

366 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'balance_leads' => 100,
]);
});
test('POST /api/deals создаёт сделку с manual source + project firstOrCreate', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'Окна Москва',
'phone' => '+7 (999) 123-45-67',
'contact_name' => 'Тест Тестов',
'status' => 'new',
]);
$r->assertStatus(201);
expect($r->json('deal.id'))->toBeInt();
expect($r->json('deal.tenant_id'))->toBe($this->tenant->id);
expect($r->json('deal.phone'))->toBe('+7 (999) 123-45-67');
expect($r->json('deal.status'))->toBe('new');
$dealId = $r->json('deal.id');
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$deal = Deal::query()->where('id', $dealId)->first();
expect($deal)->not->toBeNull();
expect($deal->source_crm_id)->toBeNull(); // manual
expect($deal->contact_name)->toBe('Тест Тестов');
// Project создан с type='manual'
$project = Project::find($r->json('deal.project_id'));
expect($project->name)->toBe('Окна Москва');
expect($project->type)->toBe('manual');
});
test('POST /api/deals использует существующий project (не дублирует)', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$existing = Project::create([
'tenant_id' => $this->tenant->id,
'name' => 'Натяжные потолки',
'type' => 'webhook',
]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'Натяжные потолки',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(201);
expect($r->json('deal.project_id'))->toBe($existing->id);
// Проверяем что НЕТ нового project'а с таким же name
$count = Project::where('tenant_id', $this->tenant->id)
->where('name', 'Натяжные потолки')
->count();
expect($count)->toBe(1);
});
test('POST /api/deals пишет ActivityLog с context.source=manual', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(201);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$log = ActivityLog::where('deal_id', $r->json('deal.id'))->first();
expect($log)->not->toBeNull();
expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
expect($log->context)->toBe(['source' => 'manual']);
});
test('POST /api/deals 422 без обязательных полей', function () {
$r = $this->postJson('/api/deals', []);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKeys(['tenant_id', 'project_name', 'phone']);
});
test('POST /api/deals 404 при unknown tenant_id', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => 999999,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(404);
});
test('POST /api/deals дефолтный status = new если не передан', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(201);
expect($r->json('deal.status'))->toBe('new');
});
test('POST /api/deals с manager_id → assigned_at = NOW()', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
'manager_id' => $manager->id,
]);
$r->assertStatus(201);
$deal = Deal::where('id', $r->json('deal.id'))->first();
expect($deal->manager_id)->toBe($manager->id);
expect($deal->assigned_at)->not->toBeNull();
});
test('POST /api/deals manual НЕ списывает баланс tenant\'а', function () {
$balanceBefore = $this->tenant->balance_leads;
$this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
])->assertStatus(201);
$this->tenant->refresh();
expect($this->tenant->balance_leads)->toBe($balanceBefore);
});
test('POST /api/deals manual создаёт SupplierLeadCost если у проекта есть активный supplier', function () {
// Создаём supplier + проект + project_suppliers связку.
$supplierId = DB::table('suppliers')->insertGetId([
'code' => 'test_b1_'.bin2hex(random_bytes(3)),
'name' => 'Test Supplier',
'accepts_types' => '{"websites","calls"}',
'cost_rub' => '15.00',
'channel' => 'sites',
'is_active' => true,
'sort_order' => 1,
'quality_score' => 1.00,
'created_at' => now(),
]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$project = Project::create([
'tenant_id' => $this->tenant->id,
'name' => 'WithSupplier',
'type' => 'manual',
]);
DB::table('project_suppliers')->insert([
'project_id' => $project->id,
'supplier_id' => $supplierId,
'is_active' => true,
'created_at' => now(),
]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'WithSupplier',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(201);
// SupplierLeadCost создан со snapshot cost_rub
$cost = SupplierLeadCost::query()
->where('deal_id', $r->json('deal.id'))
->first();
expect($cost)->not->toBeNull();
expect($cost->supplier_id)->toBe((int) $supplierId);
expect((string) $cost->cost_rub)->toBe('15.00');
expect($cost->supplier_lead_id)->toBeNull(); // manual: нет внешнего id
});
test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'NoSupplier',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(201);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$cost = SupplierLeadCost::query()
->where('deal_id', $r->json('deal.id'))
->count();
expect($cost)->toBe(0);
});
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
// Создаём 2 сделки через store endpoint (получаем реальные id).
$r1 = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r2 = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$r1['id'], $r2['id']],
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))->toContain('text/csv');
expect($r->headers->get('Content-Disposition'))->toContain('deals_export_');
$body = $r->getContent();
// BOM первый символ
expect($body)->toStartWith("\u{FEFF}");
// Headers строка
expect($body)->toContain('ID;Имя;Телефон;Статус');
// Контент сделок
expect($body)->toContain('Алиса');
expect($body)->toContain('Боб');
expect($body)->toContain('+7 (999) 111-11-11');
});
test('POST /api/deals/export 422 без ids', function () {
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
]);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKey('ids');
});
test('POST /api/deals/export 404 unknown tenant', function () {
$r = $this->postJson('/api/deals/export', [
'tenant_id' => 999999,
'ids' => [1, 2, 3],
]);
$r->assertStatus(404);
});
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
]);
$r->assertStatus(200);
expect($r->getContent())->toContain('Алиса');
expect($r->getContent())->not->toContain('Боб');
});
// NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно
// через testing_rls_user (NOLOGIN role без BYPASSRLS) — см.
// `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres
// superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive.
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
'format' => 'xlsx',
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))
->toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
expect($r->headers->get('Content-Disposition'))->toContain('.xlsx');
// XLSX = ZIP-archive, начинается с magic bytes "PK\x03\x04".
$body = $r->getContent();
expect(substr($body, 0, 4))->toBe("PK\x03\x04");
expect(strlen($body))->toBeGreaterThan(2000); // sanity: реальный xlsx > 2 KB
});
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 333-33-33',
'contact_name' => 'Кириллов',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
'format' => 'xlsx',
]);
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
file_put_contents($tmp, $r->getContent());
$reader = IOFactory::createReader('Xlsx');
$book = $reader->load($tmp);
$sheet = $book->getActiveSheet();
expect($sheet->getTitle())->toBe('Сделки');
expect($sheet->getCell('A1')->getValue())->toBe('ID');
expect($sheet->getCell('B1')->getValue())->toBe('Имя');
expect($sheet->getStyle('A1')->getFont()->getBold())->toBeTrue();
// Row 2 — реальная сделка
expect($sheet->getCell('A2')->getValue())->toBe($a['id']);
expect($sheet->getCell('B2')->getValue())->toBe('Кириллов');
expect($sheet->getCell('C2')->getValue())->toBe('+7 (999) 333-33-33');
unlink($tmp);
});
test('POST /api/deals/export 422 на неизвестный format', function () {
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [1],
'format' => 'pdf',
]);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKey('format');
});
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 444-44-44',
'contact_name' => 'Test',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))->toContain('text/csv');
expect($r->headers->get('Content-Disposition'))->toContain('.csv');
});