fix обзвон: знак задания и номер в теле больше не могут разойтись

Портальный клиент голосового робота считал опознавательный знак задания от
ОДНИХ ЦИФР телефона, а в тело задания клал номер КАК ЕСТЬ. Один и тот же
человек, записанный `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и
РАЗНЫЕ тела. Приёмник робота сравнивает тело дословно и на такую пару
отвечает отказом — то есть честный повтор получал отказ вместо звонка.

Вторая половина той же беды: знак и телефон приходили в `pozvonit` ДВУМЯ
независимыми доводами, и ничто не заставляло их сойтись. Работник очереди,
посчитавший знак один раз и перебирающий номера, отправил бы роботу
рассогласованную пару, и человеку не позвонили бы молча.

Что сделано:

- заведено ОДНО место приведения номера — `ObzvonClient::odinVidNomera`;
  оба потребителя берут номер оттуда: и знак, и тело задания;
- `pozvonit` больше НЕ принимает знак снаружи. Он принимает то, из чего знак
  складывается — арендатор, кампания, номер, попытка, — и складывает знак сам.
  Рассогласовать нечего: второго номера в клиенте не существует;
- отказ по номеру теперь считается по признаку "в номере ноль цифр", а не по
  `trim`. Раньше строка вроде "абв" проходила дальше и давала ОДИН И ТОТ ЖЕ
  знак на все такие задания — два разных задания столкнулись бы знаками у
  робота, и одному из людей не позвонили бы вовсе;
- в докблоке `pozvonit` вслух записано следствие решения владельца Р124:
  номер попытки нельзя прибавлять на исходе, где звонок МОГ состояться, —
  иначе знак станет другим и защита не сработает именно в том случае, ради
  которого заведена.

Разбор ответа робота, пределы времени, предел одновременных и проверка
шифрования Р123 не тронуты.

Мерки. Сторожа роняли работой, а не текстом: возвращали беду в код и
смотрели на то, что уходит подставному роботу.

- вернул в тело номер "как есть" — 4 красных из 55;
- вернул знак доводом снаружи — 1 красный из 55, сторож читает доводный ряд
  метода через `ReflectionMethod`, а не текст файла;
- возврат доказан слепком со снятием невидимых знаков конца строки,
  после возврата 55 из 55 зелёных;
- полный прогон на своей базе: 5293 проверки, 5289 зелёных, красных 0,
  ошибок 0, пропущено 4; арифметика сходится;
- `composer stan` — 0 ошибок, `pint --test` — passed.

Открытым оставлено и названо в отчёте: `8...` и `+7...` сегодня разные люди
для портала; `dopolnitelno` обязано быть одинаковым у повтора; правило счёта
попыток не назначено.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md
This commit is contained in:
Дмитрий
2026-08-07 07:43:34 +03:00
parent 4513fe1a58
commit a4ff69aea3
3 changed files with 706 additions and 75 deletions
+75 -24
View File
@@ -61,9 +61,16 @@ use Illuminate\Support\Facades\Log;
* что это то же самое задание, а не новое. Знак даёт {@see self::klyuchZadaniya()}:
* он ОДИНАКОВ у всех повторов одной и той же попытки и РАЗНЫЙ у разных попыток.
*
* 🔴 Клиент не выдумывает знак сам и не подставляет случайный. Случайный знак
* выглядел бы как защита и не защищал бы ничего при каждом повторе он был бы
* новым. Задание без знака = дверь закрыта + запись в журнал уровня «ошибка».
* 🔴 Знак не случайный и не выдуманный: он посчитан от арендатора, кампании,
* номера и числа попытки. Случайный выглядел бы как защита и не защищал бы
* ничего при каждом повторе он был бы новым.
*
* 🔴 И знак не приходит СНАРУЖИ. {@see self::pozvonit()} принимает то, ИЗ ЧЕГО
* знак складывается, и считает его сам, а номер и для знака, и для тела берёт из
* одного места ({@see self::odinVidNomera()}). Пока знак и телефон были двумя
* независимыми доводами, ничто не заставляло их сойтись: рассогласованная пара
* доезжала до робота, робот отвечал отказом, и человеку не звонили МОЛЧА.
* Теперь рассогласовать нечего второго номера в этом клиенте не существует.
*
* 🪤 Чего эта половина НЕ доказывает: что робот знак и вправду читает. Приёмник
* (З-0.6) обязан хранить знаки и на повтор отвечать `{"ok":true, "call_id": <тот
@@ -174,52 +181,96 @@ final class ObzvonClient
*
* 🪤 Телефон входит в знак только хешем: знак ложится в журналы и уедет к
* роботу, а телефон персональные данные человека.
*
* 🔴 Номер приводится к одному виду {@see self::odinVidNomera()} тем же
* самым, каким он поедет роботу в теле задания. Это одно место, а не два.
*/
public static function klyuchZadaniya(int $tenantId, int $campaignId, string $telefon, int $popytkaNo): string
{
return 'z'.substr(hash('sha256', implode('|', [
$tenantId,
$campaignId,
preg_replace('/\D+/', '', $telefon) ?? '',
self::odinVidNomera($telefon),
$popytkaNo,
])), 0, 40);
}
/**
* 🔴 ОДИН ВИД НОМЕРА единственное место во всём клиенте, где номер
* приводится к канону, и оба потребителя берут его отсюда: и знак
* ({@see self::klyuchZadaniya()}), и тело задания ({@see self::pozvonit()}).
*
* Пока мест было два, один и тот же человек, записанный `+7 999 000-00-01`
* и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник робота (З-0.6)
* снимает отпечаток с тела ДОСЛОВНО и на такую пару отвечает 422 то есть
* честный повтор получал отказ вместо звонка.
*
* 🪤 Что этот канон НЕ делает, говорю вслух: он снимает ЗАПИСЬ (плюсы,
* скобки, пробелы, тире), но не решает, один ли человек за `8999…` и
* `+7999…`. Для портала это сегодня РАЗНЫЕ люди разный знак, два звонка.
* Считать ли их одним, решает владелец, и это отдельное решение: ошибись оно
* в другую сторону два РАЗНЫХ человека слились бы в один знак, и второму
* не позвонили бы никогда. Молча угадывать такое нельзя.
*/
private static function odinVidNomera(string $telefon): string
{
return preg_replace('/\D+/', '', $telefon) ?? '';
}
/**
* Попросить робота позвонить по номеру.
*
* 🔴 Один вызов = РОВНО ОДИН запрос к роботу. Сам не повторяет см. шапку.
*
* @param string $klyuchZadaniya знак от {@see self::klyuchZadaniya()}; пустой = не звоним
* 🔴 ЗНАК СЧИТАЕТСЯ ЗДЕСЬ, А НЕ ПРИНИМАЕТСЯ СНАРУЖИ. Раньше знак и телефон
* приходили двумя независимыми доводами, и ничто не заставляло их сойтись:
* работник очереди, посчитавший знак один раз и перебирающий номера, отправил
* бы роботу рассогласованную пару робот ответил бы 422, а человеку не
* позвонили бы МОЛЧА. Теперь рассогласовать нечего: наружу принимается ровно
* то, ИЗ ЧЕГО знак складывается, и складывает его сам клиент.
*
* 🪤 Что отсюда следует для правила счёта попыток (решение владельца Р124).
* Робот помнит знаки сутки и различает попытки по `$popytkaNo`. Значит на
* ПОВТОРЕ после исхода {@see ObzvonOtvet::zvonokMogSostoyatsya()} = «да»
* (робот молчит, 5xx, ответ без условленного слова) номер попытки обязан
* остаться ТЕМ ЖЕ иначе знак станет другим и защита не сработает именно
* в том случае, ради которого заведена. Прибавлять попытку можно только
* после исхода, где звонка ТОЧНО не было (дверь закрыта, занято, отказ),
* либо после состоявшегося и завершённого разговора. Сам клиент этого
* правила не знает и знать не должен он считает знак от того, что ему
* сказали. Довод виден в каждом вызове нарочно: правило будет исполняться
* на глазах, а не прятаться внутри.
*
* @param array<string, mixed> $dopolnitelno что ещё сказать роботу (сценарий, номер «от кого»)
*
* 🪤 `$dopolnitelno` тоже входит в отпечаток тела у робота. Значит оно
* обязано быть ОДИНАКОВЫМ у повтора той же попытки: время «сейчас», случайное
* число или поле, добавленное выкатом между попыткой и повтором, дадут
* честному повтору 422. Порядок ключей роли не играет приёмник сортирует.
*/
public function pozvonit(string $klyuchZadaniya, string $telefon, array $dopolnitelno = []): ObzvonOtvet
public function pozvonit(int $tenantId, int $campaignId, string $telefon, int $popytkaNo, array $dopolnitelno = []): ObzvonOtvet
{
$klyuchZadaniya = trim($klyuchZadaniya);
$telefon = trim($telefon);
// 🔴 Один и тот же канон и для знака, и для тела: роботу уезжает ровно
// тот номер, из которого посчитан знак. Другого номера здесь просто нет.
$nomer = self::odinVidNomera($telefon);
if ($klyuchZadaniya === '') {
// Не «на всякий случай», а по существу: без знака робот не отличит
// повтор от нового задания, и первая же потеря ответа обернётся
// вторым звонком человеку. Молчать об этом нельзя — это ошибка кода.
Log::error('обзвон: задание без опознавательного знака — звонить отказано', [
'ruchka' => 'pozvonit',
]);
return ObzvonOtvet::dverZakryta('задание без опознавательного знака');
}
if ($telefon === '') {
if ($nomer === '') {
// Сюда попадает и пустая строка, и строка вовсе без цифр («абв»).
// Второе важно отдельно: раньше такие строки давали ОДИН знак на
// всех (цифр ноль — хешу нечего различать), и два разных задания
// столкнулись бы знаками у робота.
Log::error('обзвон: задание без номера — звонить отказано', [
'zadanie' => $klyuchZadaniya,
'ruchka' => 'pozvonit',
]);
return ObzvonOtvet::dverZakryta('задание без номера');
}
return $this->obratitsya('pozvonit', $klyuchZadaniya, array_merge($dopolnitelno, [
'zadanie' => $klyuchZadaniya,
'telefon' => $telefon,
$znak = self::klyuchZadaniya($tenantId, $campaignId, $nomer, $popytkaNo);
return $this->obratitsya('pozvonit', $znak, array_merge($dopolnitelno, [
'zadanie' => $znak,
'telefon' => $nomer,
]));
}
+246 -51
View File
@@ -15,10 +15,10 @@ use Illuminate\Support\Facades\Log;
*
* 🔴 Чего эти сторожа НЕ доказывают, говорю сразу и вслух:
*
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Приёмника на машине
* робота ещё нет вовсе (З-0.6). Здесь доказано только то, что портал знак
* вырабатывает устойчиво, шлёт его в двух заголовках и без знака звонить
* отказывается. Защита от двойного звонка ЗАЯВЛЕНА, но не замкнута.
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Здесь доказано
* только то, что портал знак вырабатывает устойчиво, складывает его САМ (а
* значит рассогласовать его с номером нечем), шлёт одно и то же и в теле, и в
* двух заголовках. Замыкает защиту приёмник З-0.6, и он не здесь.
* 2. **Что предел времени и вправду обрывает медленное чтение.** Подставной робот
* подменяет собой весь перевозчик Guzzle настоящие пределы времени при
* подставном роботе не срабатывают в принципе. Здесь доказано поведение НА
@@ -49,11 +49,44 @@ function klientObzvona(array $peremeny = []): ObzvonClient
);
}
/**
* Одно пробное задание ровно то, ИЗ ЧЕГО клиент сам сложит знак.
*
* 🔴 Знака среди доводов нет и быть не может: `pozvonit` его не принимает.
* Раньше сторожа звали `pozvonit(znakZadaniya(), '+79991234567')` то есть
* знак был посчитан от одного написания номера, а в тело ехало другое. Ровно
* та рассогласованная пара, ради которой всё это переделано, жила прямо здесь.
*/
function zadanieProbnoe(array $peremeny = []): array
{
return array_merge([
'tenantId' => 1,
'campaignId' => 7,
'telefon' => '+7 999 123-45-67',
'popytkaNo' => 1,
], $peremeny);
}
/** Тот знак, какой клиент обязан сложить сам для {@see zadanieProbnoe()}. */
function znakZadaniya(): string
{
return ObzvonClient::klyuchZadaniya(1, 7, '+7 999 123-45-67', 1);
}
/**
* Тела ВСЕХ заданий, ушедших роботу на ручку `pozvonit`, по порядку.
*
* 🪤 Сторожа ниже смотрят на то, что и вправду уехало в сеть, а не на текст
* исходника: два помощника этой смены уже платили за сторожа, находившего своё
* же пояснение в файле и зеленевшего при сломанном коде.
*/
function telaUshedshih(): array
{
return Http::recorded(fn (Request $z) => str_ends_with($z->url(), '/pozvonit'))
->map(fn (array $para) => $para[0]->data())
->all();
}
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 1 плана: робот молчит — портал не падает
// ─────────────────────────────────────────────────────────────────────────────
@@ -61,7 +94,7 @@ function znakZadaniya(): string
it('робот молчит — портал не падает, а говорит «не знаю»', function () {
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->poyasnenie)->toContain('робот молчит');
@@ -70,7 +103,7 @@ it('робот молчит — портал не падает, а говори
it('🔴 молчание робота НЕ значит «звонка не было» — повторять вслепую нельзя', function () {
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
// Ровно тот случай, ради которого исходов пять: робот мог принять задание и
// уже звонить, а ответ потерялся по дороге. Скажи клиент «не вышло» — живому
@@ -90,7 +123,7 @@ it('🔴 клиент НЕ повторяет запрос сам — на мо
throw new ConnectionException('оборвано');
});
klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
klientObzvona()->pozvonit(...zadanieProbnoe());
expect($skolko)->toBe(1);
});
@@ -99,7 +132,7 @@ it('о молчании робота пишется строка уровня «
$zhurnal = Log::spy();
Http::fake(fn () => throw new ConnectionException('оборвано'));
klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
klientObzvona()->pozvonit(...zadanieProbnoe());
// 🔴 На бою LOG_LEVEL=warning: всё ниже предупреждения не доезжает НИКОГДА
// (оплачено 04.08.2026 — починка выглядела сделанной и была мёртвой).
@@ -117,7 +150,7 @@ it('выход за предел времени — «не знаю», рабо
// Именно так выглядит выход за предел времени в Guzzle: ConnectionException.
Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 15001 ms'));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->poyasnenie)->toContain('timed out');
@@ -142,7 +175,7 @@ it('🔴 пределов времени ДВА, и оба не нулевые',
it('пустой адрес — наружу не ходим, звонка точно не было', function () {
Http::fake();
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
@@ -152,7 +185,7 @@ it('пустой адрес — наружу не ходим, звонка то
it('пустой ключ — наружу не ходим', function () {
Http::fake();
$otvet = klientObzvona(['key' => ''])->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona(['key' => ''])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->poyasnenie)->toContain('тайный ключ');
@@ -163,7 +196,7 @@ it('закрытая дверь не сыплет исключениями и н
Http::fake();
$klient = klientObzvona(['endpoint' => '']);
expect($klient->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeFalse()
expect($klient->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeFalse()
->and($klient->sostoyanie('c-1')->poluchenLi())->toBeFalse()
->and($klient->ostanovit('c-1')->poluchenLi())->toBeFalse();
});
@@ -176,7 +209,7 @@ it('🔴 ответ 200 и мусором — это НЕ успех', function
// Тот самый урок: «ответ меньше 500 = жив» физически не мог покраснеть.
Http::fake(fn () => Http::response(['ерунда' => 'какая-то'], 200));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->poluchenLi())->toBeFalse();
});
@@ -186,7 +219,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н
// Записать мусор в «отказ» значит вернуть дыру с двойным звонком.
Http::fake(fn () => Http::response('совсем не json', 200));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
@@ -195,7 +228,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н
it('робот сказал «нет» — это отказ, и звонка точно не было', function () {
Http::fake(fn () => Http::response(['ok' => false, 'reason' => 'кампания не найдена'], 200));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
@@ -205,7 +238,7 @@ it('робот сказал «нет» — это отказ, и звонка т
it('робот сломался 5xx — «не знаю», а не «не было»', function () {
Http::fake(fn () => Http::response(['ok' => false], 500));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
@@ -214,7 +247,7 @@ it('робот сломался 5xx — «не знаю», а не «не был
it('робот ответил 4xx — понял и отказался, звонка не было', function () {
Http::fake(fn () => Http::response(['ok' => false], 422));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
@@ -223,7 +256,7 @@ it('робот ответил 4xx — понял и отказался, звон
it('робот сказал условленное слово — задание принято, внешний номер запомнен', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42'], 200));
$otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
->and($otvet->vneshniyIdZvonka)->toBe('rob-42');
@@ -254,22 +287,11 @@ it('🔴 в знаке задания нет телефона человека
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))->not->toContain('9991234567');
});
it('🔴 задание без знака — не звоним вовсе, и это ошибка в журнале', function () {
$zhurnal = Log::spy();
Http::fake();
$otvet = klientObzvona()->pozvonit('', '+79991234567');
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
$zhurnal->shouldHaveReceived('error');
});
it('знак задания уходит роботу двумя заголовками, а ключ — своим', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$znak = znakZadaniya();
klientObzvona()->pozvonit($znak, '+79991234567');
klientObzvona()->pozvonit(...zadanieProbnoe());
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $znak)
&& $z->hasHeader('Idempotency-Key', $znak)
@@ -279,12 +301,185 @@ it('знак задания уходит роботу двумя заголов
it('задание без номера — не звоним', function () {
Http::fake();
$otvet = klientObzvona()->pozvonit(znakZadaniya(), ' ');
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => ' ']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 номер без единой цифры — не звоним, и это ошибка в журнале', function () {
// 🪤 Раньше сюда проваливалось всё, где нет цифр: `trim` такую строку не
// ловил, а знак от неё получался ОДИН И ТОТ ЖЕ у любых таких заданий —
// цифр ноль, хешу нечего различать. Два разных задания столкнулись бы
// знаками у робота, и одному из людей не позвонили бы вовсе.
$zhurnal = Log::spy();
Http::fake();
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
$zhurnal->shouldHaveReceived('error');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 ЗНАК И ТЕЛО ЗАДАНИЯ БОЛЬШЕ НЕ МОГУТ РАЗОЙТИСЬ
//
// Что было замерено до починки: знак считался от ОДНИХ ЦИФР телефона, а в тело
// задания клался номер КАК ЕСТЬ. Один и тот же человек, записанный
// `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник
// робота (З-0.6) снимает отпечаток с тела дословно и на такую пару отвечает 422
// — честный повтор получал отказ вместо звонка.
//
// И вторая половина той же беды: знак приходил ОТДЕЛЬНЫМ доводом от телефона.
// Работник очереди, посчитавший знак один раз и перебирающий номера, отправил
// бы роботу рассогласованную пару, и человеку не позвонили бы МОЛЧА.
//
// 🪤 Сторожа ниже смотрят на то, что ушло в сеть подставному роботу, — на
// работу, а не на слова в исходнике.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 А (контроль): одно задание дважды подряд — знак совпал и тело совпало', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe());
$klient->pozvonit(...zadanieProbnoe());
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
});
it('🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают', function () {
// 🔴 Тот самый нож. До починки совпадал только знак: тело ехало «как есть»,
// и робот видел два разных задания под одним знаком → 422 честному повтору.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
});
it('🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе', function () {
// Проверяем не текст файла, а САМ ДОВОДНЫЙ ряд метода: если кто-нибудь
// вернёт знак отдельным доводом, сторож покраснеет.
$dovody = array_map(
fn (ReflectionParameter $p) => $p->getName(),
(new ReflectionMethod(ObzvonClient::class, 'pozvonit'))->getParameters(),
);
expect($dovody)->toBe(['tenantId', 'campaignId', 'telefon', 'popytkaNo', 'dopolnitelno']);
});
it('🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле', function () {
// Главный сторож починки. Он проверяет САМО СВОЙСТВО, ради которого всё
// сделано: для КАЖДОГО ушедшего задания знак обязан быть тем самым, какой
// считается от номера, уехавшего в этом же теле. Разойдись они хоть на один
// знак — робот ответит 422, и человеку не позвонят.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$vsyakie = [
['telefon' => '+7 999 000-00-01'],
['telefon' => '79990000001'],
['telefon' => ' 8 (999) 000 00 01 '],
['tenantId' => 42, 'telefon' => '+7-999-000-00-01'],
['campaignId' => 99, 'telefon' => '+7 999 000 00 01'],
['popytkaNo' => 3, 'telefon' => '+7 999 000-00-01'],
];
$klient = klientObzvona();
foreach ($vsyakie as $peremeny) {
$klient->pozvonit(...zadanieProbnoe($peremeny));
}
$tela = telaUshedshih();
expect($tela)->toHaveCount(count($vsyakie));
foreach ($tela as $nomer => $telo) {
$zadanie = zadanieProbnoe($vsyakie[$nomer]);
// 🪤 Первая половина обязательна отдельно, и вот почему. `klyuchZadaniya`
// сама приводит номер к одному виду — значит одной этой сверки МАЛО:
// положи клиент в тело номер с плюсами и скобками, знак всё равно сошёлся
// бы, а робот сравнивает тело ДОСЛОВНО и ответил бы 422. Замерено: без
// этой строки сторож оставался зелёным при возвращённой беде.
expect($telo['telefon'])->toMatch('/^\d+$/');
expect($telo['zadanie'])->toBe(ObzvonClient::klyuchZadaniya(
$zadanie['tenantId'],
$zadanie['campaignId'],
$telo['telefon'], // 🔴 именно тот номер, что уехал роботу
$zadanie['popytkaNo'],
));
}
});
it('🔴 Г: вторая попытка по тому же номеру — знак ДРУГОЙ, звонок законный', function () {
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 1]));
$otvet = $klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 2]));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
->and($otvet->poluchenLi())->toBeTrue();
});
it('🔴 знак в заголовках — тот же самый, что в теле', function () {
// Приёмник сверяет тело с заголовком и на расхождении отвечает 400. Разойдись
// они здесь — не дозвонились бы вовсе, и по коду 400 причину не узнать.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$telo = telaUshedshih()[0];
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $telo['zadanie'])
&& $z->hasHeader('Idempotency-Key', $telo['zadanie']));
});
it('🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
});
it('🔴 «что ещё сказать роботу» не может перебить знак или номер', function () {
// Отпечаток тела у робота снимается со ВСЕГО тела. Дай мы `dopolnitelno`
// перебить `zadanie` или `telefon` — рассогласование вернулось бы с другого
// конца, уже никем не охраняемое.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe([
'dopolnitelno' => ['zadanie' => 'z-chuzhoy', 'telefon' => '+70000000000', 'skript' => 'privet'],
]));
$telo = telaUshedshih()[0];
expect($telo['zadanie'])->toBe(znakZadaniya())
->and($telo['telefon'])->toBe('79991234567')
->and($telo['skript'])->toBe('privet');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Адрес с опечаткой уводит тайный ключ чужому
// ─────────────────────────────────────────────────────────────────────────────
@@ -293,7 +488,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл
Http::fake();
$otvet = klientObzvona(['endpoint' => 'https://rrobot.test/obzvon'])
->pozvonit(znakZadaniya(), '+79991234567');
->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->poyasnenie)->toContain('неразрешённый хост');
@@ -303,7 +498,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл
it('🔴 пустой список разрешённых хостов закрывает всё, а не открывает', function () {
Http::fake();
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
@@ -312,7 +507,7 @@ it('🔴 пустой список разрешённых хостов закр
it('адрес без хоста вовсе — дверь закрыта', function () {
Http::fake();
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
@@ -335,7 +530,7 @@ it('🔴 ж: разрешённый хост, но http без шифра — к
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
@@ -351,7 +546,7 @@ it('🔴 з: тот же хост по https — работает и после
$otvet = klientObzvona([
'endpoint' => 'https://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
@@ -364,7 +559,7 @@ it('🔴 и: свой компьютер, http на localhost — работае
$otvet = klientObzvona([
'endpoint' => 'http://localhost:8080/lena',
'razreshyonnyeHosty' => ['localhost'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
@@ -376,7 +571,7 @@ it('🔴 и: свой компьютер, http на 127.0.0.1 — работае
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
@@ -388,7 +583,7 @@ it('🔴 к: тот же местный адрес НА БОЕВОМ — две
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
@@ -414,7 +609,7 @@ it('🔴 APP_ENV=local на боевой машине НЕ открывает ht
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
@@ -429,7 +624,7 @@ it('🔴 «своя машина» — не список слов: похоже
$otvet = klientObzvona([
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
@@ -444,7 +639,7 @@ it('🔴 вся петля 127.0.0.0/8 — своя машина, а сосед
expect(klientObzvona([
'endpoint' => 'http://127.0.1.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.1.1'],
])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
])->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
});
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
@@ -455,7 +650,7 @@ it('🔴 адрес, похожий на петлю, но ею не являющ
$otvet = klientObzvona([
'endpoint' => 'http://10.0.127.1:8080/lena',
'razreshyonnyeHosty' => ['10.0.127.1'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
@@ -469,7 +664,7 @@ it('🔴 запрет http на боевом виден в журнале, и у
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(znakZadaniya(), '+79991234567');
])->pozvonit(...zadanieProbnoe());
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
@@ -522,7 +717,7 @@ it('🔴 все места заняты — исход «занято», нар
$chuzhie[] = $zamok;
}
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
@@ -545,7 +740,7 @@ it('освободилось место — задание проходит', fu
$zanyatye[1]->release();
expect(klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())
expect(klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe())->poluchenLi())
->toBeTrue();
$zanyatye[0]->release();
@@ -559,7 +754,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места,
$nachalo = microtime(true);
$otvet = klientObzvona(['concurrency' => 1, 'waitSeconds' => 1])
->pozvonit(znakZadaniya(), '+79991234567');
->pozvonit(...zadanieProbnoe());
$proshlo = microtime(true) - $nachalo;
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
@@ -571,7 +766,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места,
it('место возвращается после успешного обращения', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
@@ -584,7 +779,7 @@ it('🔴 место возвращается, даже когда робот м
// Иначе одна беда навсегда съедала бы место, и обзвон затухал бы по одному.
Http::fake(fn () => throw new ConnectionException('оборвано'));
klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567');
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
@@ -601,7 +796,7 @@ it('три умения зовут три разные ручки робота',
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(znakZadaniya(), '+79991234567');
$klient->pozvonit(...zadanieProbnoe());
$klient->sostoyanie('rob-42');
$klient->ostanovit('rob-42', 'человек попросил');
@@ -647,7 +842,7 @@ it('клиент из настроек берёт адрес, ключ и пре
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
expect(ObzvonClient::izNastroek()->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
expect(ObzvonClient::izNastroek()->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Key', 'iz-nastroek'));
});
@@ -0,0 +1,385 @@
# Отчёт помощника — знак задания и тело задания больше не могут разойтись
**Дата:** 07.08.2026
**Ветка:** `feat/prospects-manual-testing-kp`
**Мои файлы:** `app/app/Services/Obzvon/ObzvonClient.php`,
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php`
---
## 0. Моя ошибка первой
**Мой главный сторож был слеп ровно к той половине беды, ради которой я его
писал, и я узнал об этом только потому, что и вправду сломал починку.**
Я написал сторожа «чем клиента ни зови — знак в теле всегда сходится с телефоном
в теле»: он берёт ушедшее роботу тело и проверяет, что
`zadanie === klyuchZadaniya(арендатор, кампания, telefon_из_тела, попытка)`.
Выглядело как главная проверка всей работы. Потом я вернул беду обратно (положил
в тело номер «как есть») — и **этот сторож остался ЗЕЛЁНЫМ**. Причина простая и
обидная: `klyuchZadaniya` сама приводит номер к одному виду, поэтому от
`+7 999 000-00-01` она даёт тот же знак, что и от `79990000001`. Сторож сверял
согласие «с точностью до нормализации», а робот сравнивает тело **дословно**.
Починил: добавил в того же сторожа отдельную строку `expect($telo['telefon'])
->toMatch('/^\d+$/')` — тело обязано нести уже приведённый номер. После этого при
той же самой поломке сторож покраснел (замер — §3, поломка №1, было 3 красных,
стало 4). Если бы я не ломал починку руками, а поверил зелёному, я бы сдал работу
с дырявым главным сторожем.
---
## 1. Разбор задания
### 1.1. Картина беды владельца — подтверждаю обе половины, замерено
1. **Знак и телефон приходили двумя независимыми доводами.** Так и было:
`pozvonit(string $klyuchZadaniya, string $telefon, ...)`. Ничто их не
связывало.
2. **Знак считался от одних цифр, а в тело клался номер как есть.** Так и было:
`klyuchZadaniya``preg_replace('/\D+/', '', $telefon)`, `pozvonit` — просто
`trim($telefon)`.
Причём **рассогласованная пара жила прямо в сторожах**: почти все они звали
`pozvonit(znakZadaniya(), '+79991234567')`, где `znakZadaniya()` считался от
`'+7 999 123-45-67'`. То есть знак от одного написания, тело от другого. Сегодня
это никого не роняло только потому, что приёмника рядом нет.
### 1.2. Что я проверил в приёмнике (читал, не правил)
Читал `bots/lena-golos/priyomnik.py`. Важное для этой работы:
- отпечаток тела снимается **вычитанием**: в него входит всё, кроме `zadanie`
(`POLYA_VNE_OTPECHATKA`) — значит и `telefon`, и всё `dopolnitelno`;
- **порядок ключей роли не играет** — приёмник сортирует
(`json.dumps(..., sort_keys=True)`). Значит порядок ключей на портале
выравнивать не надо, и я этого не делал;
- на расхождение тела — **422**, то есть 4xx, то есть для портала «звонка не
было». Приёмник сам говорит вслух: «правильное место починки — портал».
Вывод: портал обязан гарантировать, что **у повтора той же попытки тело
побайтово то же**. Одного согласия «знак сходится» мало.
### 1.3. 🔴 Второе место того же класса, о котором в задании не сказано
**Строка вовсе без цифр давала один и тот же знак на все такие задания.**
`trim` ловил `' '`, но не ловил `'абв'`, `'---'` или `'+()'`: они проходили
дальше, а `klyuchZadaniya` от них получала **пустую строку цифр** — то есть знак
зависел только от арендатора, кампании и попытки. Два разных задания с
разными «мусорными» номерами **столкнулись бы знаками у робота**, и второму
человеку не позвонили бы вовсе (робот ответил бы «повтор» либо 422).
Закрыл заодно: теперь отказ идёт по признаку «в номере ноль цифр», а не по
`trim`. Сторож — «🔴 номер без единой цифры — не звоним, и это ошибка в журнале».
### 1.4. 🔴 Третье место того же класса — НЕ закрывал, потому что это не моё решение
`8 999 000-00-01` и `+7 999 000-00-01` — почти наверняка **один и тот же
человек**, но цифры у них разные (`89990000001` против `79990000001`), значит
знак разный, значит **два звонка одному человеку**. Это ровно та беда, что в
задании, только с другого конца.
Я это **не чиню намеренно** и говорю почему. Правило «какие записи номера значат
одного человека» — решение владельца, а не помощника:
- ошибись оно в одну сторону — человеку позвонят дважды (сегодняшнее состояние);
- ошибись в другую — **два разных человека сольются в один знак**, и второму не
позвонят НИКОГДА. Второе хуже и тише.
Кроме того, `8` — приставка не всемирная: слепое `8 → 7` сломает любой не-российский
номер. Написал вслух в §7 как открытый вопрос владельцу.
### 1.5. 🔴 Четвёртое место — `dopolnitelno` входит в отпечаток тела
`dopolnitelno` (сценарий, номер «от кого», имя собеседника) уезжает в том же
теле и **целиком входит в отпечаток робота**. Значит у повтора той же попытки
оно обязано быть **тем же**: время «сейчас», случайное число или поле,
добавленное выкатом между попыткой и повтором, дадут честному повтору 422.
Чинить это в клиенте нечем — содержимое даёт зовущий. Что сделал: написал это
🪤-предупреждением прямо в докблоке `pozvonit`, чтобы будущий работник очереди
прочёл его в момент, когда пишет вызов. Порядок ключей выравнивать не надо —
приёмник сортирует (§1.2).
### 1.6. Проверка владельца: «не сломает ли смена подписи `pozvonit`?»
**Не ошиблись — боевых зовущих ноль.** Подробный замер поимённо — §6.
---
## 2. Какой путь выбран и почему
**Выбран путь первый: портал считает знак САМ внутри `pozvonit`, а наружу
принимает то, из чего знак складывается.**
Новая подпись:
```php
public function pozvonit(
int $tenantId, int $campaignId, string $telefon, int $popytkaNo,
array $dopolnitelno = [],
): ObzvonOtvet
```
Плюс появилось **одно** место приведения номера — `private static
odinVidNomera()`, и оба потребителя берут его оттуда: и `klyuchZadaniya()`, и
тело задания в `pozvonit()`.
### 2.1. Решающий довод, которого в задании нет
Второй путь — «знак остаётся доводом, но `pozvonit` проверяет, что он сходится»
**всё равно требует сменить подпись**. Чтобы сверить знак, надо его
пересчитать, а для этого нужны арендатор, кампания и номер попытки. Их сегодня в
`pozvonit` нет. То есть второй путь платит **ту же самую цену** (смена подписи),
а взамен даёт:
- **пять доводов вместо четырёх**, причём пятый — производная от остальных
четырёх, и единственное, чем он может отличаться, — быть неверным;
- **новый исход отказа** «знак не сходится → дверь закрыта». А чем кончается
«дверь закрыта»? Человеку не позвонили. То есть второй путь не убирает вред, а
делает его громким. Громкий лучше тихого, но «невозможно» лучше громкого.
Раз цена одинаковая, а выигрыш только у первого — выбор не близкий.
### 2.2. Что путь 1 стоит, говорю вслух
- Клиент связи теперь знает доменные слова «арендатор», «кампания», «попытка».
Лёгкий запах слоёв. Но `klyuchZadaniya()` **уже живёт в этом же классе и уже
знает ровно эти четыре вещи** — новых знаний в класс не вошло, они лишь
перестали ходить вокруг него.
- Зовущий больше не может подставить произвольный знак. Если когда-нибудь
понадобится знак из внешнего источника — это будет отдельное решение владельца,
и оно упрётся в сторожа «В» (§3), а не проскочит молча.
- Зовущему по-прежнему есть чем записать знак в свою таблицу:
`ObzvonClient::klyuchZadaniya()` остался открытым, и он **обязан** совпасть с
отправленным — обе величины выходят из одной функции от одних доводов.
### 2.3. 🔴 Р124 — что я об этом думаю (не переоткрываю, называю следствие)
Владелец назвал следствие сам, и оно верное. Добавлю точную границу правила,
которое будет назначаться позже:
> **Номер попытки нельзя прибавлять на исходе, где `zvonokMogSostoyatsya()`
> вернул «да».** Это «робот молчит», 5xx и «ответ без условленного слова ok».
> Прибавь портал попытку здесь — знак станет другим, робот не узнает повтор и
> наберёт номер второй раз, то есть защита не сработает **именно в том
> единственном случае, ради которого заведена**.
>
> Прибавлять попытку можно только после исхода, где звонка **точно** не было
> (дверь закрыта, занято, отказ), либо после состоявшегося и завершённого
> разговора, который решили повторить.
Моя работа этого правила **не делает невозможным** — наоборот. Пока знак приходил
готовым доводом, правило пряталось внутри того, кто его считал. Теперь
`popytkaNo`**отдельный видимый довод в каждом вызове**, и правило будет
исполняться на глазах, а не по памяти. Это записано 🪤-абзацем в докблоке
`pozvonit`, чтобы будущий работник очереди прочёл его в ту же секунду, когда
пишет вызов.
Ещё одна граница Р124, которую стоит держать в уме: робот помнит знаки **сутки**.
Повтор той же попытки, дошедший позже суток (застрявшая джоба, ручной перезапуск
назавтра), робот уже не узнает и наберёт номер снова. Это окно робота, оно
названо в самом Р124, и портальной починкой не закрывается.
---
## 3. Чем ронял сторожей и что они сказали
Ронял **работой, а не текстом**: возвращал беду в код и смотрел на то, что
уходит подставному роботу. Слепков по тексту исходника я в новых сторожах не
делал вовсе — предупреждение из задания принято.
### Поломка №1 — вернул в тело номер «как есть»
`'telefon' => $nomer``'telefon' => trim($telefon)` (ровно прежнее поведение).
Первый замер — **3 красных из 55**. Главный сторож остался зелёным, это моя
ошибка (§0). После усиления сторожа тем же ломом — **4 красных из 55**, дословно:
```
🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают
Failed asserting that two strings are identical.
- '+7 999 000-00-01'
+ '79990000001'
🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле
Failed asserting that '+7 999 000-00-01' matches PCRE pattern "/^\d+$/".
🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак
Failed asserting that two strings are identical.
- '79990000001'
+ '+7 (999) 000-00-01'
🔴 «что ещё сказать роботу» не может перебить знак или номер
Failed asserting that two strings are identical.
- '79991234567'
+ '+7 999 123-45-67'
```
### Поломка №2 — вернул знак доводом снаружи
Добавил шестым доводом `string $klyuchZadaniya = ''` (то есть приоткрыл ту самую
дверь, через которую и приходила рассогласованная пара). **1 красный из 55**,
дословно:
```
🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе
Failed asserting that two arrays are identical.
@@ @@
2 => 'telefon',
3 => 'popytkaNo',
4 => 'dopolnitelno',
+ 5 => 'klyuchZadaniya',
]
```
Этот сторож читает **сам доводный ряд метода** через `ReflectionMethod`, а не
текст файла. Слово `klyuchZadaniya` в файле встречается многократно (в том числе
в моих же пояснениях) — текстовый сторож зеленел бы всегда.
### Возврат — доказан слепком со снятием `\r`
После возврата обеих поломок:
```
tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin
→ cad7cd0d6288ff279159b9197f799a44e276a2d0
tr -d '\r' < app/tests/Feature/Obzvon/ObzvonClientShovTest.php | git hash-object --stdin
→ 1208cbda5ccb80eb2a9aa2f4bc59601194e39eb7
```
Проба по своим сторожам после возврата: **55 проверок, 55 зелёных, 137 условий,
красных 0.** Ни одной строки поломок в файле не осталось — проверено поиском по
обеим внесённым приметам, найдено 0.
### Прочие мерки по своим файлам
- `vendor/bin/pint --test` по обоим файлам — **passed**.
- `composer stan` (Larastan, весь `app/`) — **0 ошибок**.
---
## 4. Числа полного прогона
**Своя база:** `liderra_testing_znak_telo` (создана, накатана `migrate --force`).
Ни одна из занятых баз не тронута. Прогон один, других проб на этой базе
параллельно не запускал.
Команда:
```
DB_DATABASE=liderra_testing_znak_telo php -d memory_limit=-1 vendor/bin/pest
```
| Что | Число |
|---|---|
| Всего проверок | **5293** |
| Зелёных | **5289** |
| **Красных** | **0** |
| Ошибок | **0** |
| Пропущено | 4 |
| «Сомнительных» (risky) | 4 |
| Условий проверено | 21 798 |
| Время | 1 214 с (≈20 мин) |
**Арифметика сходится:** 5289 + 0 + 0 + 4 = **5293** = всего. Считал не по коду
возврата, а по числам в самом выводе — красных, спрятавшихся в поле «ошибок»,
нет: поле пустое.
**Сверка с входным замером.** На входе владелец назвал 5242 проверки, красных 0.
Стало 5293 — **+51**. Из них **+8 мои** (в моём файле сторожей было 47 `it(`,
стало 55 — замерено `git show HEAD:… | grep -c`), остальные **+43** принесли
соседние смены, работающие в этой же ветке.
**4 пропущенных и 4 «сомнительных» — не мои и не новые.** Ровно те же числа
лежат в выводах прогонов предыдущих смен этого дня (5207/5203/4/4 и
5242/5238/4/4). Мой прогон их не изменил.
🔴 **Красноты в `Render`/`Autopodbor` не было** — сосед, правящий дверь боевого
рендера, ничего мне не уронил, и мне нечего было бы расследовать.
---
## 5. Соседи
моего имени нет ни в одном выводе ни одного сторожа
чужие файлы в дереве не тронуты
Правил ровно три файла, все три — мои по заданию:
`app/app/Services/Obzvon/ObzvonClient.php`,
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php` и этот отчёт. Запрещённых
файлов не касался: ни `SelfRenderClient.php`, ни `config/services.php`, ни
`.env.example` (два моих сторожа их только ЧИТАЮТ — так было и до меня), ни
папки `bots/`, ни схемы, ни миграций. Правил только средствами правки
Edit/Write, ни одной строки через оболочку.
---
## 6. Зовущие поимённо
**Спросил у прибора, что он считает** — и это важно, потому что упоминаний и
вправду сильно больше, чем вызовов.
Прибор: `rg` (через поиск по дереву), запрос `pozvonit|klyuchZadaniya|ObzvonClient`,
без `vendor/` и `node_modules/`. Он считает **упоминания в тексте**, а не вызовы:
в его выдаче лежат и докблоки, и комментарии, и `README`, и питон робота, где
`pozvonit` — это имя ручки HTTP, а не метод PHP. Поэтому глазами разбирал каждую
строку.
### Зовущие `ObzvonClient::pozvonit()` — вызовы, поимённо
| Файл | Что это |
|---|---|
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | **единственный зовущий**, мой собственный файл сторожей |
**Боевых зовущих — НОЛЬ.** Замер владельца подтверждаю: смена подписи не сломала
ничего, кроме собственных сторожей, которые я в этой же работе и переписал.
### Зовущие `ObzvonClient::klyuchZadaniya()` — вызовы, поимённо
| Файл | Что это |
|---|---|
| `app/app/Services/Obzvon/ObzvonClient.php` | сам себя — изнутри `pozvonit()` (это и есть починка) |
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | сторожа |
### Упоминания, которые вызовами НЕ являются (чтобы не сосчитать их зовущими)
| Файл | Почему не вызов |
|---|---|
| `bots/lena-golos/priyomnik.py` | питон робота; `pozvonit` там — имя HTTP-ручки, `ObzvonClient` — в пояснениях |
| `bots/lena-golos/proba-priyomnika.py` | то же, ~45 упоминаний имени ручки |
| `bots/lena-golos/README.md` | описание шва |
| `app/config/services.php` | комментарии-указатели |
| `app/.env.example` | комментарий про хвост ручки |
| `app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php` | `{@see}` в докблоке (сосед, не тронут) |
| `app/app/Services/Obzvon/ObzvonOtvet.php` | `{@see}` в докблоке |
| `docs/superpowers/priyomka/stroyka-6/*.md` | приёмочные листы |
Если считать «упоминания», зовущих выходит **больше десяти**. Вызовов — **два
файла на оба метода**, и оба мои. Именно на этой разнице сегодня обжёгся
надзиратель, о чём предупреждало задание.
---
## 7. Что осталось незакрытым
1. 🔴 **`8…` и `+7…` — сегодня разные люди для портала.** Один живой человек,
записанный двумя приставками, получит **два знака и два звонка**. Не чинил
намеренно (§1.4): решение «какие записи значат одного человека» — владельца, и
ошибка в противоположную сторону (слияние двух РАЗНЫХ людей в один знак)
тише и хуже. **Вопрос владельцу.**
2. 🔴 **`dopolnitelno` обязано быть одинаковым у повтора** (§1.5). Клиент это
гарантировать не может — содержимое даёт зовущий. Предупреждение написано в
докблоке `pozvonit`; сторожа на это нет и быть не может, пока нет зовущего.
3. 🔴 **Правило счёта попыток не назначено** (Р124). Моя работа его не делает
невозможным и выносит `popytkaNo` на видное место (§2.3), но само правило —
за владельцем, и **до него защита от двойного звонка не замкнута**.
4. 🔴 **Окно памяти робота — сутки.** Повтор той же попытки позже суток робот не
узнает и наберёт снова. Портальной починкой не лечится.
5. 🪤 **Защита по-прежнему не замкнута в бою**: доказано только то, что портал
шлёт согласованную пару. Что робот и вправду узнаёт повтор — проверяется на
стороне З-0.6, живого обмена между половинами не было.
6. **Зовущего у `pozvonit` до сих пор нет ни одного.** Работник очереди — отдельная
задача; пока он не написан, правильность применения (тот же `popytkaNo` на
повторе, то же `dopolnitelno`) ничем не охраняется.