diff --git a/app/app/Http/Controllers/Api/Client/ClientSmsController.php b/app/app/Http/Controllers/Api/Client/ClientSmsController.php index 153b9f22..9995955d 100644 --- a/app/app/Http/Controllers/Api/Client/ClientSmsController.php +++ b/app/app/Http/Controllers/Api/Client/ClientSmsController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\Client; use App\Exceptions\Billing\InsufficientBalanceException; use App\Http\Controllers\Controller; +use App\Jobs\EnrichClientSmsContactsOperatorJob; use App\Jobs\SendClientSmsCampaignJob; use App\Models\AdWallet; use App\Models\ClientSmsAutoRule; @@ -20,6 +21,8 @@ use App\Models\LegalEntity; use App\Models\TenantRequisites; use App\Services\Advertising\AdWalletService; use App\Services\ClientSms\ClientSmsAudienceBuilder; +use App\Services\ClientSms\ClientSmsBaseExampleWriter; +use App\Services\ClientSms\ClientSmsPhoneFileReader; use App\Services\ClientSms\ClientSmsPlan; use App\Services\ClientSms\ClientSmsPricing; use App\Services\ClientSms\ClientSmsRecipientSelector; @@ -53,6 +56,7 @@ class ClientSmsController extends Controller private readonly ClientSmsPricing $pricing, private readonly ConsentLetterBuilder $consentLetter, private readonly ConsentDocxWriter $consentDocx, + private readonly ClientSmsPhoneFileReader $fileReader, ) {} public function index(Request $request): JsonResponse @@ -204,12 +208,56 @@ class ClientSmsController extends Controller 'phones.*' => 'string', ]); + return $this->addContacts($tenantId, $data['phones']); + } + + /** Скачать пример Excel-файла базы номеров (чтобы клиент заполнил правильно). */ + public function contactsExample(): Response + { + $xlsx = (new ClientSmsBaseExampleWriter)->build(); + + return response($xlsx, 200, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + 'primer-nomerov.xlsx', + 'primer-nomerov.xlsx', + ), + ]); + } + + /** Загрузка базы номеров Excel-файлом (.xlsx/.xls). Первая колонка — телефоны. */ + public function uploadContactsFile(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls|max:5120', // до 5 МБ + ]); + + $phones = $this->fileReader->read((string) $request->file('file')->getRealPath()); + + return $this->addContacts($tenantId, $phones); + } + + /** + * Добавляет номера в базу: нормализует, копит отброшенные (не молча — вернём + * клиенту, ЧТО именно не распознали), запускает обогащение оператором через ДаДату. + * + * @param array $rawPhones + */ + private function addContacts(int $tenantId, array $rawPhones): JsonResponse + { $added = []; + $rejectedSamples = []; $rejected = 0; - foreach ($data['phones'] as $raw) { + foreach ($rawPhones as $raw) { $normalized = PhoneNormalizer::normalize((string) $raw); if ($normalized === null) { - $rejected++; // номер не распознали — сообщим клиенту, сколько таких + $rejected++; + if (count($rejectedSamples) < 20) { + $rejectedSamples[] = trim((string) $raw); // покажем клиенту образцы отброшенных + } continue; } @@ -222,9 +270,15 @@ class ClientSmsController extends Controller $added[$phone] = $contact; } + if ($added !== []) { + // оператора определяем в фоне — большая база не тормозит загрузку + EnrichClientSmsContactsOperatorJob::dispatch($tenantId); + } + return response()->json([ 'added' => count($added), 'rejected' => $rejected, + 'rejected_samples' => $rejectedSamples, 'contacts' => array_values($added), ]); } diff --git a/app/app/Jobs/EnrichClientSmsContactsOperatorJob.php b/app/app/Jobs/EnrichClientSmsContactsOperatorJob.php new file mode 100644 index 00000000..e3976a0b --- /dev/null +++ b/app/app/Jobs/EnrichClientSmsContactsOperatorJob.php @@ -0,0 +1,107 @@ + $contacts */ + $contacts = $this->tenantTx(fn () => ClientSmsContact::where('tenant_id', $this->tenantId) + ->where(function ($q): void { + $q->whereNull('operator')->orWhere('operator', ''); + }) + ->get() + ->all()); + + $costKopecks = (int) config('services.dadata.call_cost_kopecks', 60); + + foreach ($contacts as $contact) { + if (! $budget->canSpend()) { + break; // дневной лимit ДаДаты выбран — остаток обработаем позже + } + + try { + $response = $client->cleanPhone((string) $contact->phone); + $budget->recordSpend($costKopecks); + } catch (Throwable $e) { + Log::warning('client_sms.operator_enrich_failed', [ + 'tenant_id' => $this->tenantId, + 'contact_id' => $contact->id, + 'error' => $e->getMessage(), + ]); + + continue; // сбой по номеру — оператор остаётся пустым + } + + $operator = (string) ($response->provider ?? ''); + if ($operator === '') { + continue; // ДаДата не вернула оператора — оставляем пустым + } + + $this->tenantTx(function () use ($contact, $operator): void { + ClientSmsContact::where('tenant_id', $this->tenantId) + ->where('id', $contact->id) + ->update(['operator' => $operator]); + }); + } + } + + /** + * Выполняет $fn внутри транзакции с установленным tenant-контекстом (для RLS). + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(callable $fn) + { + return DB::transaction(function () use ($fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Services/ClientSms/ClientSmsBaseExampleWriter.php b/app/app/Services/ClientSms/ClientSmsBaseExampleWriter.php new file mode 100644 index 00000000..1f8ee214 --- /dev/null +++ b/app/app/Services/ClientSms/ClientSmsBaseExampleWriter.php @@ -0,0 +1,42 @@ +getActiveSheet(); + $sheet->setTitle('База'); + + $sheet->setCellValue('A1', 'Телефон'); + $sheet->getStyle('A1')->getFont()->setBold(true); + + // Образцы в разных форматах — показываем, что можно как угодно. + $samples = ['+7 913 519-12-64', '89135191264', '79001234567', '+7 (900) 765-43-21']; + foreach ($samples as $i => $sample) { + $sheet->setCellValueExplicit('A'.($i + 2), $sample, DataType::TYPE_STRING); + } + + $sheet->getColumnDimension('A')->setWidth(26); + + $writer = new Xlsx($ss); + ob_start(); + $writer->save('php://output'); + + return (string) ob_get_clean(); + } +} diff --git a/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php b/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php new file mode 100644 index 00000000..37f5c6dc --- /dev/null +++ b/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php @@ -0,0 +1,52 @@ + сырые значения первой колонки (без пустых и без заголовка) + */ + public function read(string $path): array + { + $reader = IOFactory::createReaderForFile($path); + $reader->setReadDataOnly(true); + $spreadsheet = $reader->load($path); + $sheet = $spreadsheet->getActiveSheet(); + + $values = []; + foreach ($sheet->getRowIterator() as $row) { + $cell = $sheet->getCell('A'.$row->getRowIndex()); + $raw = $cell->getValue(); + + if (is_int($raw) || is_float($raw)) { + $raw = number_format((float) $raw, 0, '', ''); // без 7.9E+10 и разделителей + } + + $raw = trim((string) $raw); + if ($raw !== '') { + $values[] = $raw; + } + } + + // Первая строка — заголовок («Телефон» и т.п.), если содержит буквы. + if ($values !== [] && preg_match('/\p{L}/u', $values[0]) === 1) { + array_shift($values); + } + + return array_values($values); + } +} diff --git a/app/resources/js/api/client-sms.ts b/app/resources/js/api/client-sms.ts index 560fafab..775b4232 100644 --- a/app/resources/js/api/client-sms.ts +++ b/app/resources/js/api/client-sms.ts @@ -121,18 +121,35 @@ export async function fetchContacts(): Promise { return data; } -/** POST /api/sms/contacts — загрузить список телефонов. */ -export async function uploadContacts( - phones: string[], -): Promise<{ added: number; rejected: number; contacts: ClientSmsContact[] }> { +/** Ответ на добавление номеров в базу (вставкой или файлом). */ +export interface ContactsUploadResult { + added: number; + rejected: number; + rejected_samples: string[]; + contacts: ClientSmsContact[]; +} + +/** POST /api/sms/contacts — загрузить список телефонов (вставка текстом). */ +export async function uploadContacts(phones: string[]): Promise { await ensureCsrfCookie(); - const { data } = await apiClient.post<{ added: number; rejected: number; contacts: ClientSmsContact[] }>( - '/api/sms/contacts', - { phones }, - ); + const { data } = await apiClient.post('/api/sms/contacts', { phones }); return data; } +/** POST /api/sms/contacts/file — загрузить базу Excel-файлом (первая колонка — номера). */ +export async function uploadContactsFile(file: File): Promise { + await ensureCsrfCookie(); + const form = new FormData(); + form.append('file', file); + const { data } = await apiClient.post('/api/sms/contacts/file', form); + return data; +} + +/** Адрес примера Excel-файла базы — скачивается по ссылке (cookie SPA GET). */ +export function contactsExampleUrl(): string { + return '/api/sms/contacts/example'; +} + /** DELETE /api/sms/contacts/{id} — удалить контакт. */ export async function deleteContact(id: number): Promise { await ensureCsrfCookie(); diff --git a/app/resources/js/views/advertising/AdvertisingSmsView.vue b/app/resources/js/views/advertising/AdvertisingSmsView.vue index d551638d..9c4c31ed 100644 --- a/app/resources/js/views/advertising/AdvertisingSmsView.vue +++ b/app/resources/js/views/advertising/AdvertisingSmsView.vue @@ -37,6 +37,8 @@ import { saveTemplate, updateTemplate, uploadContacts, + uploadContactsFile, + contactsExampleUrl, type ClientSmsCampaign, type ClientSmsContact, type ClientSmsMessage, @@ -255,6 +257,23 @@ const contacts = ref([]); const contactsLoading = ref(false); const contactsUploadText = ref(''); const contactsUploading = ref(false); +const contactsFile = ref(null); +const contactsUploadingFile = ref(false); +const contactsExampleHref = contactsExampleUrl(); + +/** Единый показ результата добавления номеров (вставкой или файлом) — НЕ молча. */ +function notifyUploadResult(res: { added: number; rejected: number; rejected_samples: string[] }): void { + if (res.rejected > 0) { + const samples = (res.rejected_samples ?? []).slice(0, 5).join(', '); + const tail = samples !== '' ? ` (например: ${samples})` : ''; + notify( + `Добавлено: ${res.added}. Не распознали: ${res.rejected}${tail} — проверьте формат`, + 'error', + ); + } else { + notify(`Добавлено номеров: ${res.added}`, 'success'); + } +} async function loadContacts(): Promise { contactsLoading.value = true; @@ -279,12 +298,7 @@ async function doUploadContacts(): Promise { contactsUploading.value = true; try { const res = await uploadContacts(phones); - notify( - res.rejected > 0 - ? `Добавлено номеров: ${res.added}. Не распознали: ${res.rejected} — проверьте формат` - : `Добавлено номеров: ${res.added}`, - res.rejected > 0 ? 'error' : 'success', - ); + notifyUploadResult(res); contactsUploadText.value = ''; await loadContacts(); } catch (e) { @@ -294,6 +308,27 @@ async function doUploadContacts(): Promise { } } +/** Загрузка базы Excel-файлом. */ +async function doUploadContactsFile(): Promise { + // v-file-input в разных версиях Vuetify отдаёт File или File[] — берём первый. + const file = Array.isArray(contactsFile.value) ? contactsFile.value[0] : contactsFile.value; + if (!file) { + notify('Выберите файл Excel с номерами', 'error'); + return; + } + contactsUploadingFile.value = true; + try { + const res = await uploadContactsFile(file); + notifyUploadResult(res); + contactsFile.value = null; + await loadContacts(); + } catch (e) { + notify(extractErrorMessage(e), 'error'); + } finally { + contactsUploadingFile.value = false; + } +} + async function doDeleteContact(id: number): Promise { try { await deleteContact(id); @@ -726,6 +761,10 @@ defineExpose({ openConfirm, doSend, contacts, + contactsFile, + contactsUploadingFile, + contactsExampleHref, + doUploadContactsFile, templates, loadContacts, loadTemplates, @@ -1112,12 +1151,49 @@ defineExpose({ Загрузить + +
+ + + Загрузить файл + + + Скачать пример файла + +
+

+ В файле — одна колонка с номерами (заголовок «Телефон»), по номеру в строке. + Оператора по каждому номеру определим сами. +

+ Номер - Имя Оператор @@ -1125,7 +1201,6 @@ defineExpose({ {{ c.phone }} - {{ c.name ?? '—' }} {{ c.operator ?? '—' }} - + В базе пока нет номеров diff --git a/app/routes/web.php b/app/routes/web.php index 1b86d528..9f871d41 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -425,6 +425,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/sms')->group(functio Route::post('/sender/disable', 'App\Http\Controllers\Api\Client\ClientSmsController@disableSender'); Route::get('/contacts', 'App\Http\Controllers\Api\Client\ClientSmsController@contacts'); Route::post('/contacts', 'App\Http\Controllers\Api\Client\ClientSmsController@uploadContacts'); + Route::post('/contacts/file', 'App\Http\Controllers\Api\Client\ClientSmsController@uploadContactsFile'); + Route::get('/contacts/example', 'App\Http\Controllers\Api\Client\ClientSmsController@contactsExample'); Route::delete('/contacts/{id}', 'App\Http\Controllers\Api\Client\ClientSmsController@deleteContact')->whereNumber('id'); Route::get('/templates', 'App\Http\Controllers\Api\Client\ClientSmsController@templates'); Route::post('/templates', 'App\Http\Controllers\Api\Client\ClientSmsController@storeTemplate'); diff --git a/app/tests/Feature/ClientSms/ContactsFileUploadTest.php b/app/tests/Feature/ClientSms/ContactsFileUploadTest.php new file mode 100644 index 00000000..17a19f27 --- /dev/null +++ b/app/tests/Feature/ClientSms/ContactsFileUploadTest.php @@ -0,0 +1,73 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @param list $colA */ +function uploadedXlsx(array $colA): UploadedFile +{ + $ss = new Spreadsheet; + $sheet = $ss->getActiveSheet(); + foreach (array_values($colA) as $i => $v) { + $sheet->setCellValueExplicit('A'.($i + 1), $v, DataType::TYPE_STRING); + } + $path = tempnam(sys_get_temp_dir(), 'baza').'.xlsx'; + (new XlsxWriter($ss))->save($path); + + return new UploadedFile($path, 'baza.xlsx', null, null, true); +} + +it('загружает базу из Excel-файла: добавляет номера, сообщает отброшенные', function () { + Bus::fake(); + + $file = uploadedXlsx(['Телефон', '79990000001', '+7 999 000-00-02', 'мусор']); + + $res = $this->post('/api/sms/contacts/file', ['file' => $file], ['Accept' => 'application/json']); + + $res->assertOk() + ->assertJsonPath('added', 2) // 79990000001 + 79990000002 + ->assertJsonPath('rejected', 1); // «мусор» + + expect($res->json('rejected_samples'))->toContain('мусор'); + expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(2); + + Bus::assertDispatched(EnrichClientSmsContactsOperatorJob::class); +}); + +it('отдаёт пример файла для скачивания (Excel)', function () { + $res = $this->get('/api/sms/contacts/example'); + + $res->assertOk(); + expect($res->headers->get('content-type'))->toContain('spreadsheetml'); + expect(substr((string) $res->getContent(), 0, 2))->toBe('PK'); +}); + +it('файл не Excel отклоняется валидацией', function () { + $bad = UploadedFile::fake()->create('spisok.txt', 1, 'text/plain'); + + $this->post('/api/sms/contacts/file', ['file' => $bad], ['Accept' => 'application/json']) + ->assertStatus(422) + ->assertJsonValidationErrors('file'); +}); diff --git a/app/tests/Feature/ClientSms/EndpointTest.php b/app/tests/Feature/ClientSms/EndpointTest.php index 7d022a68..d5b0b4bc 100644 --- a/app/tests/Feature/ClientSms/EndpointTest.php +++ b/app/tests/Feature/ClientSms/EndpointTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Jobs\EnrichClientSmsContactsOperatorJob; use App\Jobs\SendClientSmsCampaignJob; use App\Models\AdWallet; use App\Models\ClientSmsCampaign; @@ -15,6 +16,7 @@ use App\Services\Sms\OperatorNormalizer; use App\Services\Sms\Providers\MtsSmsProvider; use App\Services\Sms\SmsRouter; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Queue; /** @@ -71,6 +73,31 @@ it('uploadContacts сообщает, сколько номеров не расп ->assertJsonPath('rejected', 2); // «мусор», «абвгд» }); +it('uploadContacts возвращает примеры отброшенных номеров (не молча)', function () { + $res = $this->postJson('/api/sms/contacts', [ + 'phones' => ['79990000001', 'мусор', 'абвгд'], + ]); + + $res->assertOk() + ->assertJsonPath('added', 1) + ->assertJsonPath('rejected', 2); + + expect($res->json('rejected_samples'))->toBeArray() + ->toContain('мусор') + ->toContain('абвгд'); +}); + +it('uploadContacts запускает обогащение оператором через ДаДату', function () { + Bus::fake(); + + $this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk(); + + Bus::assertDispatched( + EnrichClientSmsContactsOperatorJob::class, + fn ($job) => (fn () => $this->tenantId)->call($job) === $this->tenant->id, + ); +}); + it('index возвращает баланс и заморозку кошелька (для заслонки «Отправить»)', function () { app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test'); diff --git a/app/tests/Feature/ClientSms/EnrichContactsOperatorTest.php b/app/tests/Feature/ClientSms/EnrichContactsOperatorTest.php new file mode 100644 index 00000000..e974e2b5 --- /dev/null +++ b/app/tests/Feature/ClientSms/EnrichContactsOperatorTest.php @@ -0,0 +1,87 @@ +tenant = Tenant::factory()->create(); + // ДаДата включена (ключ задан) — иначе обогащение не стартует. + config()->set('services.dadata.api_key', 'test-key'); + config()->set('services.dadata.secret', 'test-secret'); + Cache::flush(); // чистый дневной бюджет +}); + +it('проставляет оператора контактам без оператора', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС']], 200)]); + + $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001']); + + (new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle( + app(DaDataPhoneClient::class), + app(DaDataBudgetGuard::class), + ); + + expect($c->fresh()->operator)->toBe('МТС'); +}); + +it('не трогает контакты, у которых оператор уже известен (экономим ДаДату)', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'Билайн']], 200)]); + + $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'operator' => 'mts']); + + (new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle( + app(DaDataPhoneClient::class), + app(DaDataBudgetGuard::class), + ); + + expect($c->fresh()->operator)->toBe('mts'); // не перезаписан + Http::assertNothingSent(); // ДаДату не дёргали +}); + +it('сбой ДаДаты не роняет обогащение — оператор остаётся пустым', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response('boom', 500)]); + + $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000003']); + + (new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle( + app(DaDataPhoneClient::class), + app(DaDataBudgetGuard::class), + ); + + expect($c->fresh()->operator)->toBeNull(); +}); + +it('при исчерпанном дневном бюджете ДаДату не дёргает', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС']], 200)]); + // выбираем весь дневной лимит + config()->set('services.dadata.daily_cap_rub', 1); + app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽ + + $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000004']); + + (new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle( + app(DaDataPhoneClient::class), + app(DaDataBudgetGuard::class), + ); + + expect($c->fresh()->operator)->toBeNull(); + Http::assertNothingSent(); +}); diff --git a/app/tests/Frontend/advertising-sms-view.spec.ts b/app/tests/Frontend/advertising-sms-view.spec.ts index 9dafb83f..27f8d7b2 100644 --- a/app/tests/Frontend/advertising-sms-view.spec.ts +++ b/app/tests/Frontend/advertising-sms-view.spec.ts @@ -22,6 +22,7 @@ const { createClientSmsMock, fetchClientSmsCampaignMock, fetchContactsMock, + uploadContactsFileMock, fetchTemplatesMock, fetchSenderMock, requestSenderMock, @@ -50,6 +51,9 @@ const { createClientSmsMock: vi.fn(() => Promise.resolve({ id: 1, title: 'X', status: 'queued' })), fetchClientSmsCampaignMock: vi.fn(() => Promise.resolve({ campaign: { id: 1 }, messages: [] })), fetchContactsMock: vi.fn(() => Promise.resolve([])), + uploadContactsFileMock: vi.fn(() => + Promise.resolve({ added: 3, rejected: 1, rejected_samples: ['мусор'], contacts: [] }), + ), fetchTemplatesMock: vi.fn(() => Promise.resolve([])), // Этап 2: своё имя + авто-СМС. По умолчанию имени нет. fetchSenderMock: vi.fn(() => @@ -100,7 +104,9 @@ vi.mock('../../resources/js/api/client-sms', () => ({ createClientSms: createClientSmsMock, fetchClientSmsCampaign: fetchClientSmsCampaignMock, fetchContacts: fetchContactsMock, - uploadContacts: vi.fn(() => Promise.resolve({ added: 0, contacts: [] })), + uploadContacts: vi.fn(() => Promise.resolve({ added: 0, rejected: 0, rejected_samples: [], contacts: [] })), + uploadContactsFile: uploadContactsFileMock, + contactsExampleUrl: vi.fn(() => '/api/sms/contacts/example'), deleteContact: vi.fn(() => Promise.resolve()), fetchTemplates: fetchTemplatesMock, saveTemplate: vi.fn(() => Promise.resolve({ id: 1, title: 'T', body: 'B' })), @@ -526,6 +532,47 @@ describe('AdvertisingSmsView.vue', () => { await w.vm.$nextTick(); expect(vm.autoSaveDisabled).toBe(false); }); + + it('в таблице базы только «Номер» и «Оператор» — колонки «Имя» нет', async () => { + const w = factory(); + await flush(); + await w.vm.$nextTick(); + (w.vm as unknown as { tab: string }).tab = 'base'; + await w.vm.$nextTick(); + await flush(); + + const ths = w.findAll('[data-testid="sms-contacts-table"] th').map((t) => t.text()); + expect(ths).toContain('Номер'); + expect(ths).toContain('Оператор'); + expect(ths).not.toContain('Имя'); + }); + + it('в базе есть загрузка файлом и ссылка на пример', async () => { + const w = factory(); + await flush(); + await w.vm.$nextTick(); + (w.vm as unknown as { tab: string }).tab = 'base'; + await w.vm.$nextTick(); + await flush(); + + expect(w.find('[data-testid="sms-contacts-file"]').exists()).toBe(true); + const example = w.find('[data-testid="sms-contacts-example"]'); + expect(example.exists()).toBe(true); + expect(example.attributes('href')).toBe('/api/sms/contacts/example'); + }); + + it('загрузка файлом зовёт uploadContactsFile', async () => { + uploadContactsFileMock.mockClear(); + const w = factory(); + await flush(); + await w.vm.$nextTick(); + const vm = w.vm as unknown as Record; + + (vm as { contactsFile: unknown }).contactsFile = new File(['x'], 'baza.xlsx'); + await (vm.doUploadContactsFile as () => Promise)(); + + expect(uploadContactsFileMock).toHaveBeenCalledOnce(); + }); }); // Дешёвый исходный слой — ключевые строки/testid прямо в .vue. @@ -576,5 +623,8 @@ describe('AdvertisingSmsView.vue (source)', () => { expect(src).toContain('auto-toggle'); expect(src).toContain('auto-body'); expect(src).toContain('auto-save'); + // Моя база: загрузка файлом, пример, таблица + expect(src).toContain('sms-contacts-file'); + expect(src).toContain('sms-contacts-example'); }); }); diff --git a/app/tests/Frontend/client-sms-api.spec.ts b/app/tests/Frontend/client-sms-api.spec.ts index 07e4ace3..ab5e49eb 100644 --- a/app/tests/Frontend/client-sms-api.spec.ts +++ b/app/tests/Frontend/client-sms-api.spec.ts @@ -17,6 +17,8 @@ import { fetchClientSmsCampaign, fetchContacts, uploadContacts, + uploadContactsFile, + contactsExampleUrl, deleteContact, fetchTemplates, saveTemplate, @@ -137,6 +139,24 @@ describe('api/client-sms', () => { expect(res.added).toBe(2); }); + it('uploadContactsFile() POSTs multipart to /api/sms/contacts/file', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { added: 3, rejected: 1, rejected_samples: ['мусор'], contacts: [] }, + }); + const file = new File(['x'], 'baza.xlsx'); + const res = await uploadContactsFile(file); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + const [url, form] = vi.mocked(apiClient.post).mock.calls[0]; + expect(url).toBe('/api/sms/contacts/file'); + expect(form).toBeInstanceOf(FormData); + expect((form as FormData).get('file')).toBe(file); + expect(res.rejected_samples).toEqual(['мусор']); + }); + + it('contactsExampleUrl() возвращает адрес примера', () => { + expect(contactsExampleUrl()).toBe('/api/sms/contacts/example'); + }); + it('deleteContact(id) ensures csrf then DELETEs /api/sms/contacts/{id}', async () => { vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined }); await deleteContact(9); diff --git a/app/tests/Unit/ClientSms/BaseExampleWriterTest.php b/app/tests/Unit/ClientSms/BaseExampleWriterTest.php new file mode 100644 index 00000000..b8ee88a6 --- /dev/null +++ b/app/tests/Unit/ClientSms/BaseExampleWriterTest.php @@ -0,0 +1,31 @@ +build(); + + // .xlsx = ZIP → сигнатура «PK». PDF был бы «%PDF». + expect(substr($bytes, 0, 2))->toBe('PK'); + + // читаем свой же пример своим парсером: заголовок отсеётся, номера останутся + $path = tempnam(sys_get_temp_dir(), 'ex').'.xlsx'; + file_put_contents($path, $bytes); + $rows = (new ClientSmsPhoneFileReader)->read($path); + @unlink($path); + + expect($rows)->not->toBeEmpty(); + // образцы — валидные российские номера + foreach ($rows as $r) { + expect(PhoneNormalizer::normalize($r))->not->toBeNull(); + } +}); diff --git a/app/tests/Unit/ClientSms/PhoneFileReaderTest.php b/app/tests/Unit/ClientSms/PhoneFileReaderTest.php new file mode 100644 index 00000000..6492c79f --- /dev/null +++ b/app/tests/Unit/ClientSms/PhoneFileReaderTest.php @@ -0,0 +1,75 @@ + $colA + * @param list $numericRows + */ +function makeXlsx(array $colA, array $numericRows = []): string +{ + $ss = new Spreadsheet; + $sheet = $ss->getActiveSheet(); + foreach (array_values($colA) as $i => $v) { + $ref = 'A'.($i + 1); + if (in_array($i, $numericRows, true)) { + $sheet->setCellValueExplicit($ref, $v, DataType::TYPE_NUMERIC); + } else { + $sheet->setCellValueExplicit($ref, $v, DataType::TYPE_STRING); + } + } + $path = tempnam(sys_get_temp_dir(), 'xlsxtest').'.xlsx'; + (new XlsxWriter($ss))->save($path); + + return $path; +} + +it('читает номера из первой колонки и пропускает заголовок', function () { + $path = makeXlsx(['Телефон', '+7 913 519-12-64', '89135191264']); + + $rows = (new ClientSmsPhoneFileReader)->read($path); + + expect($rows)->toBe(['+7 913 519-12-64', '89135191264']); + @unlink($path); +}); + +it('без заголовка читает все строки', function () { + $path = makeXlsx(['79001234567', '79007654321']); + + $rows = (new ClientSmsPhoneFileReader)->read($path); + + expect($rows)->toBe(['79001234567', '79007654321']); + @unlink($path); +}); + +it('номер, записанный как число, не уходит в научную нотацию', function () { + $path = makeXlsx(['79135191264'], numericRows: [0]); + + $rows = (new ClientSmsPhoneFileReader)->read($path); + + expect($rows)->toBe(['79135191264']); + @unlink($path); +}); + +it('пропускает пустые ячейки', function () { + $path = makeXlsx(['79001234567', '', ' ', '79007654321']); + + $rows = (new ClientSmsPhoneFileReader)->read($path); + + expect($rows)->toBe(['79001234567', '79007654321']); + @unlink($path); +});