Files
portal/app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php
T
Дмитрий b28f858419 fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.

Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
  - итоговая проверка выводила площадку из префикса имени и переставала узнавать
    строку -> ложное missing 11.07 и 12.07;
  - лид от такой строки приходил с project без метки -> webhook не мог определить
    канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.

Что сделано:
  - SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
    остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
    три строки, общего префикса у них нет.
  - VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
    rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
  - Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
    у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
    ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.

Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.

Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.

Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:14:22 +03:00

292 lines
11 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\Exceptions\Supplier\SupplierClientException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/*
* rt-project-* contract tests.
*
* Контракт верифицирован live 2026-05-19 (Playwright MCP recon — см. план
* Task 1: создан LIDPOTOK_TEST_DELETE_ME на crm.bp-gr.ru, записаны сетевые
* запросы, проект удалён). Endpoints:
* POST /admin/visit/rt-project-save (JSON, конверт {status,message,result,id})
* POST /admin/visit/rt-project-delete (JSON, конверт {status,message,result})
* GET /admin/visit/rt-projects-load?src=none
*
* Tests use Http::fake — отделяем контракт SupplierPortalClient от реального портала.
*/
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test-session',
'csrf' => 'test-csrf',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
it('saveProject POSTs to /admin/visit/rt-project-save with JSON body and parses id from envelope', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'lidpotok-test.local',
limit: 100,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
$externalId = app(SupplierPortalClient::class)->saveProject($dto);
expect($externalId)->toBe(12721245);
Http::assertSent(function (Request $request): bool {
$expectsB1 = $request['srcrt'] === true && $request['srcbl'] === false && $request['srcmt'] === false;
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
&& $request->hasHeader('Content-Type', 'application/json')
&& $request['id'] === 0
&& $request['tag'] === '_lidpotok'
&& $request['name'] === 'lidpotok-test.local'
&& $request['content'] === 'lidpotok-test.local'
&& $request['type'] === 'hosts'
&& $expectsB1
&& $request['limit'] === 100
&& $request['workdays'] === ['1', '2', '3', '4', '5', '6', '7']
&& $request['regions_reverse'] === false
&& $request['status'] === true;
});
});
it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (single-true)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721244'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B2',
signalType: 'call',
uniqueKey: '79991112233',
limit: 50,
workdays: [1, 2, 3],
regions: [77],
regionsReverse: true,
status: 'paused',
);
app(SupplierPortalClient::class)->saveProject($dto);
Http::assertSent(function (Request $request): bool {
return $request['type'] === 'calls'
&& $request['srcrt'] === false
&& $request['srcbl'] === true
&& $request['srcmt'] === false
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
&& $request['regions'] === [72]
&& $request['regions_reverse'] === true
&& $request['status'] === false;
});
});
it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint as save)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B3',
signalType: 'sms',
uniqueKey: 'KEYWORD',
limit: 25,
workdays: [1, 5],
regions: [],
regionsReverse: false,
status: 'active',
);
app(SupplierPortalClient::class)->updateProject(12721245, $dto);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
&& $request['id'] === 12721245
&& $request['type'] === 'sms'
&& $request['srcmt'] === true;
});
});
/*
* Прод-инцидент 11–12.07.2026. Кабинет дописывает метку канала «B<n>_» к имени только
* при СОЗДАНИИ строки; при обновлении он сохраняет имя ровно как прислали. Мы слали
* голый uniqueKey → каждый ночной прогон стирал метку. Последствия: (1) итоговая
* проверка переставала узнавать строку → ложное «нет заказа»; (2) поставщик присылал
* лид с project=«79135397707» без метки → webhook не мог определить канал и писал
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
*
* Поэтому при ОБНОВЛЕНИИ имя уходит уже с меткой; при СОЗДАНИИ — голым (там метку
* ставит сам кабинет, и один save с тремя флагами рождает три строки — единого
* префикса для них быть не может).
*/
it('updateProject sends name WITH the B<n>_ channel prefix (portal would otherwise strip it)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '13625654'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B2',
signalType: 'call',
uniqueKey: '79135397707',
limit: 1,
workdays: [1, 2, 3, 4, 5],
regions: [],
regionsReverse: false,
status: 'active',
platforms: ['B2'],
);
app(SupplierPortalClient::class)->updateProject(13625654, $dto);
Http::assertSent(function (Request $request): bool {
return $request['id'] === 13625654
&& $request['name'] === 'B2_79135397707'
// content — по-прежнему голый ключ: это поле сбора (телефон/домен).
&& $request['content'] === '79135397707';
});
});
it('saveProject (create) keeps the name bare — the portal adds the prefix itself', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'call',
uniqueKey: '79135397707',
limit: 1,
workdays: [1, 2, 3, 4, 5],
regions: [],
regionsReverse: false,
status: 'active',
);
app(SupplierPortalClient::class)->saveProject($dto);
Http::assertSent(fn (Request $request): bool => $request['id'] === 0 && $request['name'] === '79135397707');
});
it('deleteProject POSTs to /admin/visit/rt-project-delete with JSON {id:"<string>"}', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null],
200,
),
]);
app(SupplierPortalClient::class)->deleteProject(12721245);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-delete'
&& $request->hasHeader('Content-Type', 'application/json')
&& $request['id'] === '12721245';
});
});
it('listProjects extracts projects[] from the envelope and returns raw rows', function (): void {
// Verified live 2026-05-19: ответ — конверт {projects:[...], tags, users, ...},
// НЕ голый массив. listProjects извлекает projects.
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
'projects' => [
['id' => '12721245', 'tag' => '_lidpotok', 'name' => 'B3_LIDPOTOK', 'type' => 'hosts', 'content' => 'foo.com'],
],
'tags' => [],
'users' => [],
], 200),
]);
$list = app(SupplierPortalClient::class)->listProjects();
expect($list)->toHaveCount(1);
expect($list[0]['id'])->toBe('12721245');
expect($list[0]['name'])->toBe('B3_LIDPOTOK');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'GET'
&& str_contains($request->url(), '/admin/visit/rt-projects-load')
&& $request->data() === ['src' => 'none'];
});
});
it('listProjects returns empty array when envelope has no projects key', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['tags' => []], 200),
]);
expect(app(SupplierPortalClient::class)->listProjects())->toBe([]);
});
it('saveProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'Error', 'message' => 'Лимит недостаточен!', 'result' => null],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'rejected.local',
limit: 1,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
expect(fn () => app(SupplierPortalClient::class)->saveProject($dto))
->toThrow(SupplierClientException::class, 'Лимит недостаточен!');
});
it('deleteProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
['status' => 'Error', 'message' => 'Проект не найден', 'result' => null],
200,
),
]);
expect(fn () => app(SupplierPortalClient::class)->deleteProject(99999999))
->toThrow(SupplierClientException::class, 'Проект не найден');
});