a1ea003642
Закрыт 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>
366 lines
13 KiB
PHP
366 lines
13 KiB
PHP
<?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');
|
||
});
|