2026-05-16 19:10:23 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\Deal;
|
|
|
|
|
use App\Models\ImportLog;
|
|
|
|
|
use App\Models\ImportUnknownStatus;
|
|
|
|
|
use App\Models\Reminder;
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Services\Import\CsvLeadsParser;
|
|
|
|
|
use App\Services\Import\HistoricalImportService;
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-05-23 20:20:54 +03:00
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
2026-05-16 19:10:23 +03:00
|
|
|
|
2026-05-23 20:20:54 +03:00
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
|
// HistoricalImportService::importBatch вызывает MonthlyPartitionManager::ensureRange,
|
|
|
|
|
// которая делает CREATE через pgsql_supplier — нужен shared PDO, иначе DDL уйдёт
|
|
|
|
|
// мимо test-транзакции.
|
2026-05-16 19:10:23 +03:00
|
|
|
|
|
|
|
|
beforeEach(function (): void {
|
|
|
|
|
$this->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();
|
2026-05-17 18:18:00 +03:00
|
|
|
expect($deal->status)->toBe('in_progress')
|
2026-05-16 19:10:23 +03:00
|
|
|
->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();
|
2026-05-17 18:18:00 +03:00
|
|
|
expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан
|
2026-05-16 19:10:23 +03:00
|
|
|
->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,
|
2026-05-17 18:18:00 +03:00
|
|
|
'mapped_to_slug' => 'lost',
|
2026-05-16 19:10:23 +03:00
|
|
|
'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);
|
|
|
|
|
|
2026-05-17 18:18:00 +03:00
|
|
|
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost');
|
2026-05-16 19:10:23 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
2026-05-16 20:31:56 +03:00
|
|
|
|
|
|
|
|
test('неизвестные статусы и resolved-маппинг изолированы по тенантам', function (): void {
|
|
|
|
|
// Тенант B уже резолвил «Архив» → closed и накопил 9 вхождений.
|
|
|
|
|
$otherTenant = Tenant::factory()->create();
|
|
|
|
|
ImportUnknownStatus::create([
|
|
|
|
|
'tenant_id' => $otherTenant->id,
|
|
|
|
|
'status_ru' => 'Архив',
|
|
|
|
|
'occurrences' => 9,
|
2026-05-17 18:18:00 +03:00
|
|
|
'mapped_to_slug' => 'lost',
|
2026-05-16 20:31:56 +03:00
|
|
|
'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);
|
|
|
|
|
});
|