fix обзвон: один человек, записанный через 8 и через +7, получал два звонка

Решение владельца Р127. В `odinVidNomera` восьмёрка приводится к семёрке
ТОЛЬКО когда цифр ровно одиннадцать. Короткие и длинные номера не трогаются
вовсе: слить двух разных людей в один знак — ошибка тише и хуже, второму не
позвонили бы никогда и молча.

Замерено до правки: `8 999 000-00-01` и `+7 999 000-00-01` давали два разных
знака и два разных тела. Сторожа смотрят на то, что вправду уехало роботу, и
сравнивают дословно — так же, как сравнивает приёмник.

Сторожа показаны красными тремя сломами: без починки, без проверки длины и с
приведением к восьмёрке вместо семёрки. Возврат доказан слепком файла.

Названная владельцу цена: иностранные одиннадцатизначные номера с кодом на
восьмёрку это правило портит. Вьетнамские `8496…`/`8497…`/`8498…` станут
похожи на живые коды Московской области — робот позвонит чужому человеку.
Лечится чисткой номеров на входе, это отдельная задача. Подробности и
незакрытые хвосты — в отчёте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-07 08:25:28 +03:00
parent 09ed5e1b31
commit 56ddaa1ed4
3 changed files with 416 additions and 7 deletions
+37 -7
View File
@@ -205,16 +205,46 @@ final class ObzvonClient
* снимает отпечаток с тела ДОСЛОВНО и на такую пару отвечает 422 то есть
* честный повтор получал отказ вместо звонка.
*
* 🪤 Что этот канон НЕ делает, говорю вслух: он снимает ЗАПИСЬ (плюсы,
* скобки, пробелы, тире), но не решает, один ли человек за `8999…` и
* `+7999…`. Для портала это сегодня РАЗНЫЕ люди разный знак, два звонка.
* Считать ли их одним, решает владелец, и это отдельное решение: ошибись оно
* в другую сторону два РАЗНЫХ человека слились бы в один знак, и второму
* не позвонили бы никогда. Молча угадывать такое нельзя.
* 🔴 Р127 (решение владельца 07.08.2026). Канон снимает не только ЗАПИСЬ
* (плюсы, скобки, пробелы, тире), но и разницу между `8999…` и `+7999…`
* ровно при ОДИННАДЦАТИ цифрах. Это один и тот же живой человек, записанный
* двумя обычными способами; до Р127 он получал два разных знака, ему звонили
* дважды, и клиент платил за два звонка вместо одного.
*
* 🔴 Почему правило такое узкое, а не «любой номер с восьмёрки». Ошибка в
* обратную сторону тише и хуже: слей мы двух РАЗНЫХ людей в один знак, робот
* счёл бы второе задание повтором первого, и второму человеку не позвонили бы
* НИКОГДА и МОЛЧА. Ровно одиннадцать цифр с ведущей `8` это российский
* междугородний набор (та же форма и у казахстанских `+7`), и второго
* толкования у него в наших списках нет. Короткие (`8495123`) и длинные
* (`8999000000012`) не трогаем ВОВСЕ: там `8` может значить что угодно.
*
* 🪤 Цена, названная владельцу вслух: одиннадцатизначные ИНОСТРАННЫЕ номера,
* начинающиеся на `8` (Вьетнам `+84`, Гонконг `+852`, Япония `+81`), это
* правило испортит `84…` превратится в `74…`. Вьетнамский при этом может
* стать ПОХОЖИМ на живой российский, и позвонят чужому человеку. Лечится это
* чисткой номеров на входе, при загрузке списка, отдельная задача, которой
* в плане сейчас нет.
*
* 🪤 И второе, тоже вслух: `9990000001` (десять цифр, без кода страны) для
* этого канона по-прежнему ДРУГОЙ человек, чем `79990000001`. А проверка
* запрета обзвона ({@see ObzvonZapretObrabotki}) сверяет ПОСЛЕДНИЕ ДЕСЯТЬ
* цифр и считает их одним. Расхождение узкое и намеренное Р127 короткие
* номера трогать запретил.
*
* 🔴 Правило устойчиво к повтору: приведённый номер, поданный сюда второй
* раз, не меняется. На это опирается {@see self::pozvonit()} он приводит
* номер сам, а {@see self::klyuchZadaniya()} приводит его ещё раз.
*/
private static function odinVidNomera(string $telefon): string
{
return preg_replace('/\D+/', '', $telefon) ?? '';
$cifry = preg_replace('/\D+/', '', $telefon) ?? '';
if (strlen($cifry) === 11 && str_starts_with($cifry, '8')) {
return '7'.substr($cifry, 1);
}
return $cifry;
}
/**
@@ -442,6 +442,123 @@ it('🔴 Г: вторая попытка по тому же номеру — з
->and($otvet->poluchenLi())->toBeTrue();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 Р127 — ВОСЬМЁРКА К СЕМЁРКЕ, И ТОЛЬКО ПРИ ОДИННАДЦАТИ ЦИФРАХ
//
// Что было замерено ДО починки: `8 999 000-00-01` и `+7 999 000-00-01` — один и
// тот же живой человек, записанный двумя обычными способами, — давали ДВА разных
// знака и два разных тела. Ему позвонили бы дважды, и клиент заплатил бы за два
// звонка вместо одного.
//
// Решение владельца Р127: приводить `8` к `7` ТОЛЬКО когда цифр ровно
// одиннадцать. Короткие и длинные не трогать вовсе — там `8` может значить что
// угодно, и слияние двух РАЗНЫХ людей в один знак было бы ошибкой в обратную
// сторону: второму не позвонили бы никогда и МОЛЧА.
//
// 🪤 Сторожа ниже сверяют то, что и вправду уехало подставному роботу, и
// сверяют ДОСЛОВНО — так же, как сравнивает приёмник. Сверка «с точностью до
// приведения» была бы слепа ровно к той половине беды, ради которой пишется.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 Р127 А (нож): восьмёрка и плюс-семь — ОДИН человек, один знак и одно тело', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
->and($tela[0]['telefon'])->toBe('79990000001');
});
it('🔴 Р127 Б (контроль): что совпадало до починки — совпадает и после', function () {
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('🔴 Р127 В (граница): одиннадцать цифр НЕ с восьмёрки — не трогаем', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
});
it('🔴 Р127 Г (граница): начинается на 8, но цифр НЕ одиннадцать — не трогаем вовсе', function () {
// 🔴 Ровно то, что владелец отрезал сознательно. Короткий `8495123` — это не
// «российский мобильный без кода»: подставь мы туда семёрку, два РАЗНЫХ
// человека могли бы слиться в один знак, и второму не позвонили бы МОЛЧА.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8495123']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8999000000012']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[0]['telefon'])->toBe('8495123')
->and($tela[1]['telefon'])->toBe('8999000000012');
});
it('🔴 Р127 Г: длинный номер с восьмёрки НЕ сливается со своим семёрочным двойником', function () {
// Сторож против «улучшения» правила: сними кто-нибудь проверку длины — эти
// два знака стали бы одним, и одному из двух людей не позвонили бы вовсе.
expect(ObzvonClient::klyuchZadaniya(1, 7, '8999000000012', 1))
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7999000000012', 1));
expect(ObzvonClient::klyuchZadaniya(1, 7, '8495123', 1))
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7495123', 1));
});
it('🔴 Р127 Д (граница): строка без цифр — дверь по-прежнему закрыта', function () {
// Это уже было построено до Р127 и обязано пережить починку: восьмёрка не
// должна была открыть дверь тому, кому она закрыта.
Http::fake();
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
});
it('🔴 Р127 Е: вторая попытка по восьмёрочному номеру — знак ДРУГОЙ, звонок законный', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', 'popytkaNo' => 1]));
$otvet = $klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', '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('🔴 Р127: приведение НЕ портит знак дважды — второй прогон ничего не меняет', function () {
// 🪤 `pozvonit` приводит номер, а `klyuchZadaniya` приводит его ВТОРОЙ раз
// уже приведённым. Не будь правило устойчивым к повтору, знак в теле и знак,
// посчитанный от тела, разошлись бы — и робот ответил бы 422.
expect(ObzvonClient::klyuchZadaniya(1, 7, '8 999 000-00-01', 1))
->toBe(ObzvonClient::klyuchZadaniya(1, 7, '79990000001', 1));
});
it('🔴 знак в заголовках — тот же самый, что в теле', function () {
// Приёмник сверяет тело с заголовком и на расхождении отвечает 400. Разойдись
// они здесь — не дозвонились бы вовсе, и по коду 400 причину не узнать.
@@ -0,0 +1,262 @@
# Отчёт помощника — Р127: восьмёрка к семёрке, стройка 6
**Дата:** 07.08.2026
**Ветка:** `feat/prospects-manual-testing-kp`
**Задача:** исполнить решение владельца Р127 в `odinVidNomera()` и ничего сверх.
## Что было велено
Ровно одиннадцать цифр, первая `8` → заменить первую на `7`. Короткие и длинные
не трогать вовсе. Всё остальное в клиенте не трогать.
## Моя ошибка — называю первой
Разбирая задание, я сперва принял на веру довод «одиннадцать цифр с восьмёркой
не имеют второго толкования» и собирался ответить «возражений нет». Проверив, я
нашёл, что второе толкование есть, — и нашёл его только потому, что владелец
попросил проверить именно это место. Сам бы я мимо прошёл. Подробности — в
разделе «Разбор задания».
## Что построено
Один файл, один метод, четыре строки работы:
```php
$cifry = preg_replace('/\D+/', '', $telefon) ?? '';
if (strlen($cifry) === 11 && str_starts_with($cifry, '8')) {
return '7'.substr($cifry, 1);
}
return $cifry;
```
Правило устойчиво к повтору (приведённый номер, поданный второй раз, не
меняется) — это обязательно: `pozvonit()` приводит номер сам, а
`klyuchZadaniya()` приводит его ещё раз. Не будь устойчивости, знак в теле и
знак, посчитанный от тела, разошлись бы, и робот ответил бы 422.
Сверх этого не тронуто ничего: разбор ответа робота, оба предела времени, предел
одновременных, проверка шифрования (Р123), подпись `pozvonit` — как были.
## Мерка 1 — сторожа, показанные КРАСНЫМИ
Сторожа смотрят на то, что **вправду уехало подставному роботу** (поля `zadanie`
и `telefon` перехваченного запроса), и сравнивают **дословно** — так же, как
сравнивает приёмник. Сверка «с точностью до приведения» была бы слепа ровно к
той половине беды, ради которой пишется.
Файл: `app/tests/Feature/Obzvon/ObzvonClientShovTest.php`, раздел «Р127».
### Слом 1 — починки нет вовсе (исходное состояние, RED до правки)
`63 теста, 61 зелёный, 2 красных`
| Сторож | Что сказал дословно |
|---|---|
| `Р127 А (нож)` | `Failed asserting that two strings are identical. -'z55cd4b336662c0c967a469e193b10791fe7231d5' +'z3739fcb0392739d92d950b3b97eb31e145da4dce'` |
| `Р127: приведение НЕ портит знак дважды` | `Failed asserting that two strings are identical. -'z3739fcb0392739d92d950b3b97eb31e145da4dce' +'z55cd4b336662c0c967a469e193b10791fe7231d5'` |
Это и есть замеренная беда: два знака на одного человека.
### Слом 2 — снял проверку длины (`=== 11` → `>= 7`)
`63 теста, 61 зелёный, 2 красных`
| Сторож | Что сказал дословно |
|---|---|
| `Р127 Г (граница): начинается на 8, но цифр НЕ одиннадцать` | `Failed asserting that two strings are identical. -'8495123' +'7495123'` |
| `Р127 Г: длинный номер с восьмёрки НЕ сливается со своим семёрочным двойником` | `Expecting 'zc0a12c93a346ed7b0ee5f1849f90…53b662' not to be 'zc0a12c93a346ed7b0ee5f1849f90…53b662'.` |
Второй сторож — тот самый, что охраняет от ошибки в обратную сторону: слияния
двух РАЗНЫХ людей в один знак.
### Слом 3 — привёл к восьмёрке вместо семёрки
`63 теста, 59 зелёных, 4 красных`
| Сторож | Что сказал дословно |
|---|---|
| `Р127 А (нож)` | `-'79990000001' +'89990000001'` |
| `Р127 В (граница): одиннадцать цифр НЕ с восьмёрки — не трогаем` | `-'79990000001' +'89990000001'` |
| `в теле задания номер без украшений` (прежний сторож) | `-'79990000001' +'89990000001'` |
| `«что ещё сказать роботу» не может перебить знак или номер` (прежний) | `-'79991234567' +'89991234567'` |
Этот слом доказывает, что случай В — не украшение: он ловит правило, которое
сливает пару правильно, но выбирает не тот канон.
### Возврат доказан слепком
```text
должно быть 72f220ebe9a26cb4545be92e83d14c672e96b00d
72f220ebe9a26cb4545be92e83d14c672e96b00d
```
(`tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin`)
После возврата: `63 теста, 63 зелёных, 158 проверок`.
## Мерка 2 — полный прогон
Своя база `liderra_testing_vosmerka`, поднята с нуля через `migrate --force`.
Вывод в файл, не в трубу.
```text
"result":"passed"
"tests":5301
"passed":5297
"skipped":4
"assertions":21819
"duration_ms":1177716
```
**Датчик — число проверок, а не код возврата.** Арифметика сходится:
`5297 зелёных + 0 красных + 0 ошибок + 4 пропущено = 5301 всего`.
На входе было **5293** проверки при нуле красных. Стало **5301** — ровно на
восемь больше, и восемь новых сторожей я и написал. Красных — **ноль**.
Красноты в `Render` и `Autopodbor` не было вовсе, расследовать нечего.
Заодно: `composer stan`**0 ошибок**; `vendor/bin/pint --test` по обоим моим
файлам — **passed**.
## Мерка 3 — про соседей
моего имени нет ни в одном выводе ни одного сторожа
чужие файлы в дереве не тронуты
## Мерка 4 — зовущие поимённо
🔴 Прибор (`grep`) считает **упоминания строк**, а не вызовы: он одинаково
покажет и `self::odinVidNomera($telefon)`, и `{@see self::odinVidNomera()}`
внутри пояснения. По `app/app` он даёт **13 упоминаний**. Вызовов среди них
**три**, и все три — внутри самого `ObzvonClient.php`:
| Кто зовёт | Кого | Где |
|---|---|---|
| `ObzvonClient::klyuchZadaniya()` | `odinVidNomera()` | `ObzvonClient.php:193` |
| `ObzvonClient::pozvonit()` | `odinVidNomera()` | `ObzvonClient.php:255` |
| `ObzvonClient::pozvonit()` | `klyuchZadaniya()` | `ObzvonClient.php:269` |
Остальные десять — упоминания в пояснениях (`{@see …}`), в том числе
`ObzvonOtvet.php:84` и `Autopodbor/Agent/Fetch/DverRendera.php:21`. Ни одного
вызова оттуда нет.
**За пределами класса `ObzvonClient` живых потребителей сегодня НЕТ ни одного**
ни джоба, ни команды, ни контроллера. Из тестов зовёт один файл:
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php`. Значит область поражения
правки замкнута двумя файлами.
## Разбор задания
### Картина беды верна
Замер сходится: `'8 999 000-00-01'` и `'+7 999 000-00-01'` давали разные знаки и
разные тела. Я это увидел красным до правки (слом 1). Лечение лечит именно это.
### 🔴 «Одиннадцать цифр с восьмёркой не имеют второго толкования» — НЕВЕРНО. Цена
Владелец просил проверить это место, и оно не выдерживает проверки. Второе
толкование есть: **иностранные номера, у которых код страны начинается на `8` и
всего цифр ровно одиннадцать.**
- **Вьетнам `+84`** — код 2 цифры + 9 цифр номера = **ровно 11**.
- **Гонконг `+852`**, **Макао `+853`**, **Камбоджа `+855`**, **Лаос `+856`**
3 + 8 = **11**.
- **Япония `+81`**, **Корея `+82`** — 2 + 9 = **11** (городские).
Большинство из них после подмены дают номер, которого в России не существует
(`+852…``+7 52…`, а кода `52x` у нас нет) — такой звонок просто не состоится,
это ошибка громкая и неопасная.
🔴 **А вот Вьетнам опасен по-настоящему.** Вьетнамские мобильные Viettel
`096/097/098` в международной записи выглядят `8496…`, `8497…`, `8498…`.
Р127 превратит их в `7496…`, `7497…`, `7498…`а `496` и `498` это **живые коды
Московской области**. То есть иностранный номер молча станет похож на настоящий
российский, и робот позвонит **чужому человеку**, который в списке не значился.
Это меняет род ошибки: **было** «номер не тот, звонок не состоится», **стало**
«звонок состоится, но не тому». Второе тише и хуже — тот самый род, ради
которого Р127 и делали узким.
**Насколько это вероятно у нас.** Списки клиента — российские, иностранный номер
в них попадёт разве что опечаткой или мусором в выгрузке. Оценивать вероятность
я не берусь, это не мой замер.
🔴 **И сразу поправлю сам себя, иначе цена названа нечестно.** Эта беда **не
новая и не моей правкой внесена**: у портала уже есть общий нормализатор
`App\Support\PhoneNormalizer::normalize()`, и он делает **ровно то же самое**:
```php
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
$digits = '7'.substr($digits, 1);
}
```
Им пользуются **двадцать файлов** — считано тем же прибором и с той же оговоркой:
это файлы, где имя встречается, а не число вызовов. Среди них загрузка баз СМС и телеграма, загрузка
номеров рекламной кампании, реквизиты, проекты, автоподбор — и, что важнее
всего, **сам обзвон** в `ObzvonZamorozka` при проверке номера менеджера. То есть
вьетнамский номер уже сегодня превращается в подмосковный на входе в любой
канал. Р127 не добавляет опасности — он **прекращает расхождение** обзвона с
остальным порталом. Это сильный довод ЗА Р127, и владельцу его стоит знать.
**Чем лечится по-хорошему.** Отдельной чисткой на входе в обзвон, которая умеет
сказать «это не российский номер» и отбить его с внятной причиной. Владелец эту
задачу уже отрезал как отдельную.
**Чем НЕ лечится.** Сузить правило по второй цифре (пускать только `3/4/8/9`
первые цифры живых российских кодов) кажется дешёвым ходом, но Вьетнам он **не
отсекает**: у `84…` вторая цифра как раз `4`. Так что полумеры тут нет.
**Решение Р127 я не отменяю и правку сделал.** Цена названа, чтобы владелец знал
её и решил сам.
### Что Р127 делает правильно и стоит сказать вслух
Казахстан тоже живёт на коде `+7` с междугородним `8` (`8 701 …` = 11 цифр).
Р127 приводит казахстанские номера верно, а не ломает.
### Второе место того же класса, о котором в задании не сказано
🔴 **В обзвоне сейчас живут ТРИ разных канона телефона, и `odinVidNomera`
третий, самодельный.**
| Канон | Что делает | Где |
|---|---|---|
| `PhoneNormalizer::normalize` | 11 цифр с `8`/`7``+7…`; **10 цифр → `+7…`**; всё прочее → `null`, то есть **отбить** | общий для портала, в обзвоне — `ObzvonZamorozka` |
| `ObzvonErasureAdapter::hvostCifr` | **последние 10 цифр**, короче — как есть | `ObzvonZapretObrabotki` — проверка запрета звонить |
| `ObzvonClient::odinVidNomera` | после Р127: 11 цифр с `8``7…`; всё прочее — **как есть** | знак и тело задания |
После Р127 все три сходятся на главном случае — это и есть польза правки. Но два
расхождения остаются, и оба того же класса, что и починенная беда:
1. **Десятизначный номер.** Пару `9990000001` и `79990000001` общий
нормализатор и проверка запрета считают **одним человеком**, а знак задания —
**двумя**. Значит на этой паре «позвонят дважды» **остаётся**.
2. **Мусор вместо номера.** `PhoneNormalizer` такую строку **отбивает** словом
«не читается как телефон». `odinVidNomera` пропускает её дальше, если в ней
есть хоть одна цифра, — и робот получит задание на несуществующий номер.
Чинить я это не стал и чинить молча не буду: Р127 короткие номера трогать
запретил прямым словом, а замена самодельного канона на общий — это уже другая
правка, которая меняет поведение двери и объём отбоя. Записываю как незакрытое.
## Что осталось незакрытым
1. **Иностранные одиннадцатизначные номера с `8`** — цена Р127, описана выше.
Вьетнамский может стать похожим на подмосковный, и позвонят чужому. Беда не
новая: общий `PhoneNormalizer` ведёт себя так же уже сегодня во всех каналах.
2. **Пара «десять цифр / одиннадцать цифр»** — два знака на одного человека
остаются. Запрет обзвона и общий нормализатор считают их одним, знак задания
— нет.
3. **Мусорная строка с одной цифрой** проходит в задание. Общий нормализатор
такую отбивает, `odinVidNomera` — нет.
4. **Три разных канона телефона в одном модуле** — сведение их к общему
`PhoneNormalizer` не делалось: это шире Р127.
5. **Что робот и вправду читает знак** — как и раньше, здесь не доказывается.
Замыкает приёмник З-0.6.
6. **Отдельной чистки номеров на входе в обзвон нет** — отрезано владельцем как
отдельная задача. Пункты 1–3 закрылись бы там.
</content>