tenant = Tenant::factory()->create(['balance_leads' => 5]); $this->user = User::factory()->for($this->tenant)->create(); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $this->service = app(HistoricalImportService::class); }); function importLog(Tenant $tenant, User $user, bool $dryRun = false): ImportLog { return ImportLog::create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, 'filename' => 'leads.csv', 'file_path' => 'imports/x.csv', 'dry_run' => $dryRun, ]); } function parseFixture(string $body): array { $header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя'; return (new CsvLeadsParser)->parse($header."\n".$body)->rows; } test('импортирует исторические лиды, создавая партиции под старые даты', function (): void { $rows = parseFixture( '5001,Окна,окна,79161112233,2023/07/10 10:00:00,,Комментарий,Переговоры,Иван' ); $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); expect($result->added)->toBe(1) ->and($result->updated)->toBe(0); $deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail(); expect($deal->status)->toBe('in_progress') ->and($deal->phone)->toBe('79161112233') ->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10'); }); test('баланс лидов не списывается, фиксируется транзакция historical_import', function (): void { $rows = parseFixture( '5002,Окна,окна,79161112234,2023/07/11 10:00:00,,,Новые,' ); $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); expect($this->tenant->fresh()->balance_leads)->toBe(5); // не изменился $tx = DB::table('balance_transactions') ->where('tenant_id', $this->tenant->id) ->where('type', 'historical_import') ->first(); expect($tx)->not->toBeNull() ->and((int) $tx->amount_leads)->toBe(0); }); test('повторный импорт того же файла не создаёт дублей (idempotent UPDATE)', function (): void { $rows = parseFixture( '5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Старый,Новые,' ); $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); $rows2 = parseFixture( '5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Обновлённый,Оплачено,Пётр' ); $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows2); expect($result->added)->toBe(0) ->and($result->updated)->toBe(1) ->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1); $deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail(); expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан ->and($deal->contact_name)->toBe('Пётр') ->and($deal->comment)->toBe('Обновлённый'); }); test('непустое «Напоминание» создаёт строку reminders', function (): void { $rows = parseFixture( '5004,Окна,окна,79161112236,2023/09/01 10:00:00,2023/09/05 14:00:00,,Новые,' ); $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); $deal = Deal::query()->where('source_crm_id', 5004)->firstOrFail(); $reminder = Reminder::query()->where('deal_id', $deal->id)->firstOrFail(); expect($reminder->remind_at->format('Y-m-d H:i'))->toBe('2023-09-05 14:00') ->and($reminder->created_by)->toBe($this->user->id); }); test('неизвестный статус → сделка new + запись в import_unknown_statuses', function (): void { $log = importLog($this->tenant, $this->user); $rows = parseFixture( "5005,Окна,окна,79161112237,2023/10/01 10:00:00,,,Архив,\n". '5006,Окна,окна,79161112238,2023/10/02 10:00:00,,,Архив,' ); $result = $this->service->import($this->tenant->id, $this->user->id, $log, $rows); expect($result->unknownStatuses)->toBe(['Архив' => 2]) ->and(Deal::query()->where('source_crm_id', 5005)->firstOrFail()->status)->toBe('new'); $unknown = ImportUnknownStatus::query()->where('status_ru', 'Архив')->firstOrFail(); expect($unknown->occurrences)->toBe(2) ->and($unknown->mapped_to_slug)->toBeNull(); }); test('resolved-маппинг tenant-а применяется к ранее неизвестному статусу', function (): void { ImportUnknownStatus::create([ 'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 1, 'mapped_to_slug' => 'lost', 'resolved_at' => now(), ]); $rows = parseFixture( '5007,Окна,окна,79161112239,2023/11/01 10:00:00,,,Архив,' ); $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost'); }); test('dry_run не пишет сделки, но считает проекцию', function (): void { $rows = parseFixture( '5008,Окна,окна,79161112240,2023/12/01 10:00:00,,,Новые,' ); $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user, dryRun: true), $rows); expect($result->added)->toBe(1) ->and(Deal::query()->where('source_crm_id', 5008)->exists())->toBeFalse(); }); test('неизвестные статусы и resolved-маппинг изолированы по тенантам', function (): void { // Тенант B уже резолвил «Архив» → closed и накопил 9 вхождений. $otherTenant = Tenant::factory()->create(); ImportUnknownStatus::create([ 'tenant_id' => $otherTenant->id, 'status_ru' => 'Архив', 'occurrences' => 9, 'mapped_to_slug' => 'lost', 'resolved_at' => now(), ]); // Тенант A (this) импортирует лид со статусом «Архив». Под BYPASSRLS queue // worker'ом без явного where(tenant_id) сервис подхватил бы маппинг тенанта B // и инкрементировал бы его строку — это и проверяется. $rows = parseFixture( '6001,Окна,окна,79161119999,2023/06/01 10:00:00,,,Архив,' ); $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows); // Сделка тенанта A — 'new': маппинг тенанта B НЕ применён. expect(Deal::query()->where('source_crm_id', 6001)->firstOrFail()->status)->toBe('new'); // У тенанта A — собственная запись import_unknown_statuses, occurrences=1. $ownRow = ImportUnknownStatus::query() ->where('tenant_id', $this->tenant->id) ->where('status_ru', 'Архив') ->firstOrFail(); expect($ownRow->occurrences)->toBe(1); // Строка тенанта B не тронута (occurrences остался 9). $otherRow = ImportUnknownStatus::query() ->where('tenant_id', $otherTenant->id) ->where('status_ru', 'Архив') ->firstOrFail(); expect($otherRow->occurrences)->toBe(9); });