b28f858419
Прод-инцидент 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>
292 lines
11 KiB
PHP
292 lines
11 KiB
PHP
<?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, 'Проект не найден');
|
||
});
|