Commit Graph

1094 Commits

Author SHA1 Message Date
Дмитрий df70a78e17 merge: ветка Билайна и Мотива сведена с главной — 347 коммитов, один затык
Затык был ровно один — routes/web.php: главная добавила ручку суточного итога
сторожей с машины робота, ветка добавила приёмник статусов Билайна. Ручки
независимые, оставлены обе.

Попутно поправлено примечание в config/services.php: оно говорило «заявка подана,
срок до десяти рабочих дней», а имя `Liderra.ru` было ОДОБРЕНО обеими сетями
10.08.2026 в тот же день — проверено глазами в кабинете. Примечание, которое
врёт, хуже отсутствующего: следующая смена прочла бы его и не стала поднимать
выключатель.

Замер на сведённом коде:
- модуль СМС 575/575, 7151 проверка (опора ветки была 548 — главная принесла 27);
- фронтенд 2205 из 2210. Пять падений — ЧУЖИЕ и приехали из главной:
  KoshelyokZakladkiPoKanalam.spec.ts, экран AdWalletMoneyColumn.vue спотыкается
  об отсутствующее поле frozen_breakdown. Доказано отпечатками: и тест, и экран
  байт в байт совпадают с gitea/main, моя правка их не касалась;
- Pint и PHPStan — чисто.
2026-08-10 21:39:33 +03:00
Дмитрий a159bf73cb feat,смс: портал научился сети Мотива — маршрут через Билайна на выключателе
Номер абонента Мотива не доходил даже до каналов: словарь операторов о Мотиве
не знал, и номер выбывал на первой заставе «кому мы вообще шлём».

Что сделано:
· словарь знает ОБА сырых написания — торговое «Мотив» от ДаДаты и юридическое
  ООО "ЕКАТЕРИНБУРГ-2000" из реестра Россвязи, где слова «Мотив» нет ни разу
  замер по DEF-9xx.csv: 29 диапазонов, среди 89 имён реестра столкновений нет;
· Мотив добавлен к четвёрке владельца — сетей стало пять;
· маршрут через Билайна стоит на выключателе SMS_BEELINE_SERVES_MOTIV,
  умолчание ВЫКЛЮЧЕНО: до одобрения имени отправка вернёт 20230, а этот отказ
  окончательный — повтора нет, сообщение умрёт;
· цена по сети, а не одна на канал: 770 копеек за Мотива. Экран сверки тоже
  научен считать по сети — иначе занижал бы расход на 1,84 ₽ с сообщения;
· проверка ввода настроек и галки в админке пропускают Мотива: без этого
  владелец не смог бы его отметить, а заполненный список отменяет код;
· на клиентском экране «Имя отправителя» появилась пятая строка — скрыть её
  значило бы солгать умолчанием.

Тесты: модуль 548/548, было 530; 1801 проверка. Фронтенд 120/120, Sales 483/483.
Контрольные прогоны: убранное юридическое написание красит ровно четыре
зависящих от него теста, перевёрнутое умолчание выключателя — ровно один.

Не выкачено. Включать после того, как Билайн согласует имя — ориентир 24.08.2026.
2026-08-10 20:27:55 +03:00
Дмитрий 700fa5455a fix(лиды): кириллический домен больше не принимается за СМС — лид по .рф доезжает до клиента
Беда, пойманная на бою 10.08.2026: лид 683 по проекту 'B2_автозаим.рф' не
развёлся ни одному клиенту. Работа падала 35 раз с 10:35 до 16:02, потом сдалась
— лид остался недоставленным.

Причина не в поставщике и не в данных, а в разборе строки проекта. Тип сигнала
угадывался по виду строки: телефон — звонок, домен — сайт, всё остальное — СМС.
Но оба правила «это домен» были написаны только под латиницу: `[a-z0-9-]` и
окончание `[a-z]{2,}`. Строка `автозаим.рф` под них не подходила и проваливалась
в последнее правило «значит, СМС». А в справочнике поставщика тот же ключ
записан как 'site' — типы не сошлись, и разводчик отказался работать.

Замерено на бою: таких проектов пять, все на .рф — авто-займ-124.рф,
автозаим.рф, автоломбард-экспресс.рф, займ-24.рф, сфера-займа24.рф. Из них
включён один (автозаим.рф, план 15 лидов в день), остальные четыре — мины на
случай включения.

🔴 Это ТРЕТИЙ случай одной семьи. 18.05.2026 так же не распознался домен внутри
свободного текста ('B1_заявка carmoney.ru/') — тогда застряло 22 живых лида.
Оба раза причина одна: разбор судил по алфавиту, а не по строению строки.
Теперь правило одно и записано в коде объяснением: домен опознаётся по точке и
буквенному окончанию, буквы считаются любые (\p{L} + флаг u).

Граница проведена намеренно: кириллица сама по себе сайтом не считается. Слово
без точки ('АкцияЛето') остаётся СМС-ключом — иначе лечение было бы хуже
болезни. Охраняет отдельный тест.

Проверено: новые тесты сначала упали, причём три из четырёх — дословно с боевой
ошибкой. После правки 44/44 в тестах разводки и 467/467 по поставщику,
вебхукам, деньгам и имитации. Pint чист, статанализ по моим файлам чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:03:39 +03:00
Дмитрий 272ccbffe0 feat,смс: приёмник статусов Билайна — платформа сама сообщает судьбу
Билайн умеет звонить нам сам: POST на /api/webhook/beeline-delivery/{secret}
с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Раньше судьбу сообщения
добирал только опрос — теперь она приходит сразу, как у МТС и Теле2.

Защита та же, что у двух соседних приёмников: секрет короче 32 знаков или
пустой = приёмник закрыт наглухо; сверка секрета через hash_equals; список
адресов отправителя необязательный, пустой список объявляется в журнале;
ограничитель 600 обращений в минуту; на чужой адрес и на неверный секрет
отвечаем 404, а не 403, чтобы не подсказывать чужому, что дверь тут есть.

Записи в журнал уровня warning, не notice: на бою LOG_LEVEL=warning и всё,
что ниже, не доезжает вовсе.

12 тестов написаны до кода, каждый посмотрен красным. Защита проверена
подсовыванием ошибки — краснеют ровно те тесты, что должны. Модуль целиком
530/530, pint чисто, статанализ 0.

Адрес приёмника в кабинете Билайна ещё не заявлен и секрет на бою не задан.
Пока их нет, приёмник закрыт, а канал работает по-старому через опрос.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:38:11 +03:00
Дмитрий 977c10bdba feat,смс: канал Билайна — отправка, судьба сообщения и вход двух видов 2026-08-10 12:08:51 +03:00
Дмитрий 29daf584ea fix(сторож пульса): свежая команда в расписании не считается умершей, ожившая закрывает свою жалобу
Живой случай 07.08.2026. Выкат в 12:04 добавил три ежедневные команды
(obzvon:chistka 03:45, obzvon:chistka-materialov 03:50, lena:proverka-obhoda
06:10). Их время в этот день уже прошло, записи пульса не было — и часовой
сторож с окном дедупа 60 мин заводил три жалобы КАЖДЫЙ ЧАС, каждая со своим
письмом владельцу. К утру набежало бы под полсотни. Тем же способом 05-06.08
набралось 18 записей про incidents:watch-failures.

Две правки:

1. Нет записи пульса — не жалуемся, а заводим отметку «команда известна с
   этого мгновения» и молчим, пока не выйдет срок первого запуска: два
   ожидаемых интервала, отсчёт от отметки. Не отработала за срок — жалоба будет.

2. Команда снова здорова — её открытые жалобы гасятся. Без этого записи копятся
   навсегда: 06.08 сторож починился в 20:37, а 18 жалоб остались открытыми, и
   лампа «Здоровье портала» горела сутки после устранения причины. Кнопки
   «закрыть инцидент» в админке нет.

Якорь пробел-тире в поиске жалобы обязателен: без него имя команды совпало бы с
любой другой, начинающейся так же. Обратный слэш в именах джобов экранируется —
в LIKE Postgres он служебный.

Тесты: 5 новых, из них 3 сначала красные, каждый по своей причине; 2 сторожевых
держат границы — молчание не вечно, больную команду не закрываем. Прогон 13/13,
соседние 84/84, composer stan 0 ошибок, pint чисто.

Выкачено на боевой 07.08.2026 14:30 МСК: тревог стало 0 вместо 3 в час.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:36:22 +03:00
Дмитрий b65ad2ecf8 merge: рабочая ветка сведена в главную — вкладка «Активность» отвечает «жив ли клиент», плюс обзвон, телеграм и запрет переадресации
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
# Conflicts:
#	docs/observer/STATUS.md
#	docs/superpowers/runbooks/2026-08-06-rezultaty-priyomki-telegram.md
2026-08-07 11:31:42 +03:00
Дмитрий 9ba2a6a49b fix(безопасность): за переадресацией не ходим — тайный ключ больше не уезжает чужому
Дверь проверяет адрес из настройки. Если разрешённая машина отвечает «иди на
другой адрес», перевозчик по умолчанию туда идёт и везёт наш заголовок дальше:
при уходе на чужой адрес Guzzle снимает только Authorization и Cookie, а
X-Render-Key и X-Obzvon-Key не из них.

Замерено до правки на подставных машинах: ключ рендера доехал до неразрешённой
машины из ленты Яндекса, из поиска клиентов одиночным запросом и пачкой, из
плитки прокси — и плитка при этом горела зелёным, показывая ЧУЖОЙ IP как свой.
Ключ голосового робота уезжал вместе с заданием.

Поставлен withoutRedirecting в пяти строениях запроса четырёх файлов. У
SelfRenderClient их два: пачка строит запрос сама, и запрет из одиночного пути
туда не доезжает — проверено вырезанием, снятие запрета только с пачки роняет
отдельного сторожа и даёт по утечке на каждый адрес порции.

Новых настроек не заводил: тумблер «сюда переадресовывать можно» и был бы той
дырой, ради которой запрет ставится.

Сторожа: app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php, 8
проверок, смотрят на запись переговоров, а не на текст исходника. Каждый показан
красным снятием своего запрета по одному. Отдельная проверка «датчик сам жив»
падает, если подставная сеть перестанет ходить за переадресацией, — иначе
остальные сторожа молча стали бы пустыми.

Отчёт смены: docs/superpowers/priyomka/stroyka-7/otchyot-pomoshchnika-zapret-pereadresacii-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:37:22 +03:00
Дмитрий 250ecf03c4 style: pint — импорт User в тесте списка клиентов 2026-08-07 08:45:33 +03:00
Дмитрий 7c646d3c7c fix: список клиентов сортируется по живому журналу входов, а не по мёртвой колонке 2026-08-07 08:43:25 +03:00
Дмитрий c808188c57 feat: карточка клиента отдаёт «жив ли» и ленту дел вместо журнала сделок 2026-08-07 08:34:30 +03:00
Дмитрий 69ba292d17 feat: сервисы «жив ли клиент» и ленты дел для карточки клиента 2026-08-07 08:27:23 +03:00
Дмитрий 56ddaa1ed4 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>
2026-08-07 08:25:28 +03:00
Дмитрий 6b732de6ad fix телеграм: счётчик зависшей модерации сбрасывался каждой проверкой робота
Кампания, застрявшая на модерации дольше 48 часов, обязана уйти в ручной
разбор — иначе висит вечно с замороженными деньгами клиента и молча.
Срок считается по времени изменения строки, а вчерашняя правка «день начала
показов» писала дату при КАЖДОЙ проверке робота массовым обновлением, которое
всегда двигает updated_at. Робот ходит раз в четверть часа — счётчик не
досчитал бы никогда. Защита была цела на вид и мертва на деле.

Улика с боевого: у кампании №15 время изменения строки совпадало с концом
последнего задания робота до секунды, при том что вердикта не было третьи сутки.

Пишем дату, только если она вправду изменилась. Сравнение сырых значений мимо
приведения типов: starts_on это date:Y-m-d, сравнение с Carbon разошлось бы молча.

Сторожа SchyotchikZavisshejModeraciiTest (3 шт) видены красными до правки.
Блок телеграма 381 зелёный на своей базе, статанализ 0, pint чисто.
На боевой НЕ выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:50:01 +03:00
Дмитрий 2a05adfac9 @
fix: письма о недоливе — одна сводка в сутки вместо потока на каждом прогоне

Сверка с поставщиком шла каждые 30 минут и на каждом прогоне слала письмо
на КАЖДУЮ пару (тенант, дата) с недоливом больше 20%. Вчерашняя дата остаётся
в окне сверки, поэтому поток рос день ото дня: 144 письма 27.07 → 510 за 06.08,
около 3000 за 12 дней на ops@liderra.ru.

Теперь пары собираются в один список и уходят одной сводкой раз в сутки
(отсечка по московской дате в redis; гонки нет — handle держит общий lock).
Частоту самой сверки не трогал.

Поштучный TenantBusinessDriftAlertMail с шаблоном удалён — стал мёртвым.

Тесты: сводка собирает все пары в одно письмо, второй прогон в те же сутки
молчит, в новые сутки письмо уходит снова. Красными их видел.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-08-07 07:49:27 +03:00
Дмитрий a4ff69aea3 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
2026-08-07 07:43:34 +03:00
Дмитрий 1464fd5c3f fix телеграм: счётчик зависшей модерации сбрасывался каждой проверкой робота
Кампания, застрявшая на модерации дольше 48 часов, обязана уйти в ручной
разбор — иначе висит вечно с замороженными деньгами клиента и молча.
Срок считается по времени изменения строки, а вчерашняя правка «день начала
показов» писала дату при КАЖДОЙ проверке робота массовым обновлением, которое
всегда двигает updated_at. Робот ходит раз в четверть часа — счётчик не
досчитал бы никогда. Защита была цела на вид и мертва на деле.

Улика с боевого: у кампании №15 время изменения строки совпадало с концом
последнего задания робота до секунды, при том что вердикта не было третьи сутки.

Пишем дату, только если она вправду изменилась. Сравнение сырых значений мимо
приведения типов: starts_on это date:Y-m-d, сравнение с Carbon разошлось бы молча.

Сторожа SchyotchikZavisshejModeraciiTest (3 шт) видены красными до правки.
Блок телеграма 381 зелёный на своей базе, статанализ 0, pint чисто.
На боевой НЕ выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:18:27 +03:00
Дмитрий b522d98afe @
fix: письма о недоливе — одна сводка в сутки вместо потока на каждом прогоне

Сверка с поставщиком шла каждые 30 минут и на каждом прогоне слала письмо
на КАЖДУЮ пару (тенант, дата) с недоливом больше 20%. Вчерашняя дата остаётся
в окне сверки, поэтому поток рос день ото дня: 144 письма 27.07 → 510 за 06.08,
около 3000 за 12 дней на ops@liderra.ru.

Теперь пары собираются в один список и уходят одной сводкой раз в сутки
(отсечка по московской дате в redis; гонки нет — handle держит общий lock).
Частоту самой сверки не трогал.

Поштучный TenantBusinessDriftAlertMail с шаблоном удалён — стал мёртвым.

Тесты: сводка собирает все пары в одно письмо, второй прогон в те же сутки
молчит, в новые сутки письмо уходит снова. Красными их видел.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-08-07 07:02:45 +03:00
Дмитрий dd3b6fb4b5 fix обзвон: к роботу только по шифрованной дороге, кроме своей машины — Р123
Дверь пускала http к разрешённому хосту, и тайный ключ уезжал заголовком
открытым текстом — молча, без единого признака. Одной опечатки в OBZVON_ENDPOINT
хватало. Теперь адрес без шифрования портал не принимает: дверь закрыта, звонка
нет, причина сказана словами и лежит в журнале уровнем предупреждения.

Исключение ровно одно — разработчик на своём компьютере — и устроено оно ДВУМЯ
независимыми ответами, чтобы одной перепутанной настройки на боевом для утечки
не хватило:

1. приложение само говорит, где живёт: спрашиваем Laravel, а не список слов.
   Список окружений положительный, а не «всё кроме боевого»: любое неназванное
   окружение обязано шифрование.
2. робот стоит на ЭТОЙ ЖЕ машине: адрес ведёт на петлю — вся сеть 127.0.0.0/8,
   ::1, ::ffff:127.x.x.x, имена localhost и *.localhost по RFC 6761. Определяется
   двоичным сравнением адреса, а не совпадением слов. Резолвер имён не зовём
   намеренно: он вносит в проверку безопасности неопределённость и щель между
   «проверили» и «пошли».

Именно второй ответ и закрывает то, что владелец назвал незакрытым: одной
ошибки в APP_ENV на боевом теперь мало — боевой робот петлёй не станет. На это
стоит именной сторож.

Тумблера «можно без шифра» нет намеренно: он и был бы той опечаткой, ради
которой Р123 принято.

12 новых сторожей: четыре случая ножа приёмки ж, з, и, к; сторож на само
исключение; сторож против совпадения слов вместо петли; сторож на подложное
шифрование, когда проверку чужого удостоверения снимают. Каждый показан
красным — починка ронялась четырьмя разными способами, и каждый раз краснело
именно то, что этот способ ломает. Возврат доказан слепком со снятием невидимых
знаков конца строки.

Моя ошибка первой: первый сторож про журнал зеленел не по той причине — любая
строка предупреждения его устраивала. Нашёлся только арифметикой прогона, потому
что упал в поле ошибок, а не провалов. Переписан.

Найдено и НЕ тронуто второе место того же класса: services.self_render шлёт
секрет заголовком, и его дверь не проверяет ни схему, ни хост вовсе. Путь живой,
боевой. Вынесено решением владельцу отдельно.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:31:35 +03:00
Дмитрий 5dfe32dc91 feat: окно часов обзвона — общая рамка и своё окно клиента, З-3.1
Правило часов остаётся ОДНО и живёт в боевом SmsQuietHours — решение
владельца Р76. Обзвон получает не копию правила, а тонкую обёртку
ObzvonOknoChasov со своими числами: Р76 отдал в общее пользование
ПРАВИЛО, а не ГРАНИЦЫ. Админ, поправив окно рассылок, окно обзвона
не двигает.

Два окна живут в разных часах, и рамка — их пересечение, Р115. Часы
менеджера приходят вместе со СВОИМ смещением, LocalHoursWindow, и
меряются им, а не смещением получателя. Иначе московские 14:00-18:00
были бы прочитаны как владивостокские, робот перевёл бы звонок на
спящего менеджера, а на экране и в журнале всё бы сошлось.

Окно клиента только сужает рамку, Р64: 09:00-22:00 принимается как
10:00-20:00. Часы целиком вне рамки и непересекающиеся из-за поясов
окна дают пустой ответ «звонков не будет» — это ответ, а не молчание.

Входящие окном не ограничены, Р35: спросить обёртку про приём звонка
физически нечем, и на это стоит сторож.

Живая рассылка СМС не изменилась ни на один час — доказано вычитанием
против старого правила из git HEAD: 144 816 сверок на 13 поясах, всех
1440 минутах суток и 7 наборах границ, расхождений 0. Прибор показан
красным: подложенная поломка в один знак дала 6049 расхождений.

Подпись canSendNow не менялась — новое условие вошло третьим
необязательным доводом с умолчанием. nextWindowOpensAt, earliestOpening
и isValidWindow не тронуты вовсе. Границы читаются один раз на объект:
20 000 номеров — ровно один запрос к базе.

Сторожа показаны красными пятью врезами; дословные сообщения, разбор
задания и незакрытые хвосты — в отчёте
docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 05:58:59 +03:00
Дмитрий 18f933550e feat обзвон: портал умеет позвать голосового робота — портальная половина шва З-0.3
Модуль «Обзвон» построен на 17 задач из 53 и при этом не мог позвонить ни разу:
между порталом и роботом не было шва. Эта половина его закрывает — робот живёт
на отдельной машине, портал теперь умеет сказать «позвони», спросить «что с
разговором» и сказать «останови».

Что сделано:
- блок services.obzvon: адрес, тайный ключ, предел одновременных обращений,
  ожидание места, ДВА предела времени, список разрешённых хостов. Всё через env,
  ни одного секрета в коде;
- ObzvonClient — три умения плюс опознавательный знак задания;
- ObzvonOtvet и IskhodObrashcheniya — пять исходов вместо «вышло / не вышло»;
- .env.example — семь настроек с пустыми значениями и пояснениями;
- 35 сторожей.

Главное решение. Ответ клиента НЕ булев. «Портал не получил ответа» — это не
«звонка не было»: робот мог принять задание и уже звонить, пока ответ терялся по
дороге. Слейся эти две правды — работник очереди повторил бы вслепую, и живому
человеку позвонили бы дважды, а с кошелька клиента списали бы вторую трубку.
Поэтому «не знаю» отделено от «точно не было», и у задания есть устойчивый
опознавательный знак, одинаковый у всех повторов одной попытки.

Решение владельца Р84: робот делит железо с боевым рендером «Поиска клиентов».
Предел одновременных взят замками общего кэша, а не числом в памяти процесса —
иначе два работника очереди дали бы двойную нагрузку. Что он ограничивает и чего
НЕ ограничивает, сказано прямо в шапке клиента.

Урок соседнего шва self_render взят дословно: успех — только по условленному
слову робота. Ответ 200 с мусором успехом не считается. В отличие от задания,
мусор записан не в «отказ», а в «не знаю» — иначе вернулась бы дыра с двойным
звонком. Клиент, в отличие от SelfRenderClient, не повторяет запрос сам никогда.

Журнал — только от «предупреждения» и выше: на бою LOG_LEVEL=warning.

Незакрытое названо в отчёте: защита от двойного звонка ЗАЯВЛЕНА, но не замкнута,
пока нет приёмника на машине робота З-0.6; настоящие пределы времени и предел
одновременных на живой нагрузке не мерены; ключ едет открытым заголовком.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-0-3-2026-08-06.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:01:50 +03:00
Дмитрий c351b0b6f5 fix обзвон: дверь итога сторожей проверяет связность того, что приняла
Дверь принимала итог, который сам себе противоречил, и записывала «всё
хорошо». Годный по форме случай: тело говорит «сторожей 3, красных 2», а в
списке лежит одна зелёная строка — двое сторожей исчезали молча.

Дыры было две:
- длина списка storozha ни с чем не сверялась;
- поле storozhey_krasnyh не читалось вовсе — машина присылала своё
  показание, а дверь его выбрасывала.

Теперь сверяются три показания о числе сторожей — настройка, объявленное
число и длина списка — плюс объявленное число красных против нашего
подсчёта по строкам. Красных считаем ровно по машинному правилу: код не
ноль или провалов не ноль. Иначе зелёный сторож с нулём проверок давал бы
ложную тревогу, потому что портал ругается на такого, а машина его красным
не считает.

Сверх заказанного закрыты два места того же рода. Поля, которых дверь не
знает, ищутся вычитанием, а не перечислением — и в итоге, и в строке
сторожа: тогда забытое поле оказывается под защитой, а не мимо неё. И
ловится список нужной длины, набитый повторами одного имени: числа при этом
сходятся все до одного, а гоняли одного сторожа трижды.

Каждая новая проверка показана красной вырезанием своего куска по одному за
раз. Без чтения storozhey_krasnyh случай с объявленной краснотой при трёх
зелёных строках не ловит ничто — список бед выходит пустым.

Машину не трогал, схему не трогал, остальную дверь не переписывал.

Полный прогон: всего 5207, зелёных 5203, красных 0, ошибок 0, пропущено 4.
2026-08-06 23:53:05 +03:00
Дмитрий 67195f0bf8 feat обзвон: машина робота проверяет себя сама, портал следит за свежестью
Сторожа уборки записей телефонных разговоров запускал только человек, который
про них помнил. Уборка могла умереть молча, и голоса чужих людей копились бы
месяцами при обещанном сроке хранения.

Теперь машина раз в сутки гоняет своих сторожей сама и стучится в портал с
итогом. Портал хранит итог вместе с ЕГО СОБСТВЕННОЙ датой и шлёт письмо, когда
итог красный ИЛИ протух. Зелёный итог недельной давности хуже красного
сегодняшнего, поэтому возраст проверяется всегда.

Решение владельца Р116: стучится машина, портал на машину не ходит — ключа от
машины с голосами живых людей у боевого портала быть не должно.
Решение владельца Р117: адрес письма из настройки MONITORING_ALERT_EMAIL, а не
вписанный в код.

На машине
- суточный обход и его расписание, 05:20 МСК, под flock, от ubuntu
- машинная копия 12 эталонов: своего ssh-ключа у машины нет и не будет
- у сторожей появилась вторая створка двери LENA_MESTNAYA=1 — работа без ssh

В портале
- приём итога: подпись тела, без слова в адресе. Адреса текут в журналы nginx
- ежедневная проверка свежести и красноты, письмо только когда плохо
- итог лежит в scheduler_heartbeats, схема базы не тронута
- сама проверка записана в EXPECTED_INTERVALS: присмотр без присмотра не жил бы

Сторож нового обхода ходит с рабочего места по ssh и сверяет машинную копию с
хранилищем побайтово — обход на машине сравнил бы копию сам с собой.

Попутно закрыто: копия моста и журнал обхода стали новыми местами, где ложится
похожее на речь. Разобраны поимённо, журнал обнуляется каждым прогоном.

Замеры: сторожа 26/46/27 не изменились, обход 2,2 с под нагрузкой nice,
проверок в портале 21 из 21, полный прогон 5161 при одной чужой красной.
Три ножа показаны красными; один мой сторож при этом оказался ложно зелёным и
переписан.

Не выкачено: портальная половина не на бою, дорога с машины не настроена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:31:13 +03:00
Дмитрий 6e775055d9 feat обзвон: закрыта запись исхода через связь — третий пол и якорь по столбцу
Нож надзирателя пробил обе прежние половины. Случай: запись через связь,
одной строкой, где нет ни имени класса, ни имени таблицы. Пол в модели молчал,
потому что это построитель, а не объект; обход кода не начинался, потому что
искал имена, которых в выражении нет. Проверил своим ножом: сторож дал восемь
из восьми зелёных при живой дыре.

Закрыто двумя вещами, и первая — не то, о чём просили.

Свой построитель Eloquent, подставляемый моделью. Он ловит массовую правку на
самой записи, разом все три формы: через модель, через связь и через новый
запрос от строки. Имён переменных не читает вовсе. Проверяет по факту, а не по
намерению: спрашивает базу, есть ли среди строк под правку хоть одна с
состоявшимся разговором. Массовая правка тем и опасна, что пишущий не знает,
что лежит в каждой строке, — значит знать обязан пол.

Третий якорь в обходе кода — сам столбец. Замер надзирателя перемерил своей
рукой и подтвердил: столбец ровно outcome есть во всём каноне схемы у одной
таблицы, в миграциях его нет вовсе, у соседней таблицы он зовётся иначе.

Но якорь в предложенном виде дал бы ложную красноту, и я его поправил дважды.
Подстрока без кавычек совпала бы с именем столбца соседней таблицы — беру ключ
в кавычках. И главное: ключ уникален в схеме, но НЕ уникален в коде — он есть
ещё в пяти местах, и одно из них пояснение в шапке самой двери, где дословно
написан образец запрещённого. Обход по тексту покраснел бы на самой двери.
Поэтому комментарии убираются разбором PHP, а не вычёркиванием по образцу:
разборщик отличает комментарий от такой же строки внутри кавычек. Ложная
краснота хуже дыры: она приучает не смотреть.

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

Белого списка нет ни в одной из трёх половин. Исключений в обходе ровно два и
оба по устройству: файлы, где полы и стоят.

Чего не ловит и этот якорь, названо и в отчёте, и в подписи к сторожу: правку
прямым SQL мимо портала, ключ и имя таблицы, собранные из кусков, столбец,
переименованный в будущем, и код вне папки приложения. Полную защиту даст
только замок в самой базе — это вопрос владельцу, схему не трогал.

Новая половина показана красной ножом надзирателя: сторож назвал файл, строку
и само выражение. Возврат доказан пустым состоянием дерева и слепками.

Прогоны: полный 5166, зелёных 5162, красных ноль, ошибок ноль, арифметика
сходится. Обзвон и сделки 447. Сторож мимо-двери 13 из 13. Статанализ ноль,
deptrac ноль.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:24:16 +03:00
Дмитрий df8c4720cb feat обзвон: запись исхода мимо двери больше не проходит — пол в модели и обход кода
Голый шов. Правило Т84 «недозвон не затирает состоявшийся разговор» жило в двери
ItogPoNomeru, и восемь сторожей доказывали, что дверь работает. Но все восемь
ходили ЧЕРЕЗ дверь, а дверью можно не воспользоваться: запрета писать МИМО не
было ни одного. Итог по номеру сегодня не пишет никто, значит первым пишущим
будет следующая смена — она увидит у модели поле outcome раньше, чем найдёт
дверь, напишет в него напрямую, и правило рухнет молча. Дверь осталась бы целой
и никем не использованной.

Защита разделена по УСТРОЙСТВУ, а не по тексту, и белого списка «кому можно»
нет ни в одной половине — такой список сам стал бы дырой.

Пол в самой модели ловит любую запись через объект: присваивание полю, fill,
update у строки, save. Имя переменной он не читает вовсе — он стоит на самой
записи, поэтому главная слабость текстового поиска закрыта не текстом, а местом.
Проверяется смысл перехода, а не автор: дверь поблажек не имеет и проходит лишь
потому, что запрещённого перехода не делает.

Обход кода ловит то, чего пол не видит физически: массовую правку и запросы
построителем — Eloquent на них событий не шлёт. Запрет действует на всех без
исключений, включая саму дверь.

Раскладка исходов НЕ задвоена: обе половины читают одну настройку. Повторено
только применение правила — модели запрещено зависеть от служб, у слоя Model
в настройке слоёв разрешённых зависимостей ноль. Два места связаны сторожем,
который обходит все 64 пары исходов и требует одинакового ответа.

Чего не ловит НИ ОДНА из половин, названо в бумагах: правку прямым SQL мимо
портала, имя таблицы или столбца, собранное из кусков, и код вне app. Первое
лечится только замком в самой базе — это правка канона схемы и вопрос владельцу,
поэтому названо, а не сделано молча.

Сторожа показаны красными. Настоящая запись мимо двери положена в САМ обходимый
код, а не в песочницу: сторож назвал файл, строку 14 и само выражение. Снятие
пола дало три красных, включая связку — пара transferred и no_answer. Возврат
доказан слепком со снятием невидимых знаков конца строки.

Заодно подписи двух значений в настройке: владелец закрыл обе развилки решениями
Р120 и Р121 от 06.08.2026, оба совпали с поставленным. Значения не менялись,
переписаны только пояснения, и рядом с каждым названо то, чего решение НЕ
закрывает.

Прогоны: обзвон и сделки 442, зелёных 441, красных ноль. Мои сторожа 32 из 32.
Статанализ ноль, deptrac ноль нарушений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:10:53 +03:00
Дмитрий bed1389b01 merge: рабочая ветка сведена в главную — починка возобновления рекламы и сторожа очередей
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Столкновений нет. Внутри: Т-Б3 (кампания оживает после пополнения),
incidents:watch-failures переживает нулевой байт, статус обзвона в карточке сделки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:29:16 +03:00
Дмитрий 07a17b2767 fix: пополнил кошелёк — реклама оживает; охрана очередей больше не слепнет от одной строки
Т-Б3 приёмки телеграма и падающая задача incidents:watch-failures.

1. Реклама, вставшая из-за нехватки денег, была в тупике: ручка
   возобновления принимала только `paused`, ручка запуска — только
   `draft`, а на экране у такой кампании не было ни одной кнопки.
   Клиент пополнял кошелёк и не мог вернуть рекламу ничем.
   Найдено чтением кода, БЕЗ траты живых денег и без остановки
   рекламы Яндекса — ровно то, ради чего карточка откладывалась.

2. incidents:watch-failures падал на бою каждые 10 минут с 05.08
   09:30. Причина: не отправилось письмо тревоги, упавшее письмо
   легло в failed_jobs, а в его слепке — нулевой байт от закрытых
   полей объекта PHP. Postgres такую строку хранит, но текст из неё
   не достаёт и роняет весь запрос. Одна строка глушила всю охрану
   очередей и вебхуков поставщика, а в журнале был только «код 1».

Сторожа обеих починок видены красными до правки.

NB: настоящий нулевой байт из своих же комментариев убран — с ним
git считал оба файла двоичными и переставал показывать различия.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:26:46 +03:00
Дмитрий bde377a111 feat обзвон: статус обзвона виден в карточке сделки, пометки касаний в списке
Повторная запись той же работы. Первую соседняя смена сняла с ветки откатом
на один шаг назад, сделанным дважды подряд: первый откат убрал их собственный
коммит про кошелёк, второй убрал мой. Содержимое сверено побайтно со снятым
коммитом 015a10070 — все семнадцать файлов совпали до знака.

Менеджер открывал карточку клиента и не знал, звонили ему уже или нет, чем
кончился разговор, есть ли запись. Он набирал человека, которому робот звонил
час назад и которому уже пообещали перезвонить завтра, — человек слышал от
одной компании два несогласованных звонка. Теперь в карточке есть блок
«Обзвон», а в строке списка сделок — пометки о том, что мы этого человека уже
трогали.

Главное в задаче — не показ, а замок. Восемь исходов обзвона живут В ОДНОМ
столбце obzvon_number_results.outcome, и «не дозвонились» там такое же
разрешённое значение, как «переведено». Замок в базе сторожит СПИСОК значений,
а не их смысл, поэтому требование «двенадцать недозвонов после удачного
разговора не затирают удачный разговор» базой не охранялось вовсе: правильное
на вид действие с разрешённым значением давало запрещённый смысл, и ошибка
выглядела бы безупречно. Заведена единственная дверь записи исхода, и правило
спора вынесено в одно место — настройку.

Что появилось:

- config/obzvon.php — одно место раскладки восьми исходов на «про номер» и
  «про попытку», плюс два временных ответа на вопросы, которые владелец ещё не
  закрыл: где кончается «хоть секунда разговора» и сколько прошлых списков
  показывать в карточке. Переиграть каждый — поправить одно число;
- RaskladkaIskhodov — единственный, кто знает разницу между недозвоном и
  семью остальными исходами. Три правила: недозвон не ложится поверх
  разговора никогда; разговор ложится поверх недозвона всегда; два разговора
  спорят временем, и опоздавший отчёт не перепишет свежий;
- ItogPoNomeru — единственная дверь записи, с замком строки против гонки двух
  отчётов робота по одному номеру. В боевом коде её сегодня не зовёт никто:
  робот ещё не звонит. Дверь поставлена ДО первого пишущего — в этом её смысл;
- SvodkaObzvonaVSdelke — читающая сторона. В карточку: одно верхнее значение,
  лента состоявшихся разговоров по каждому номеру, недозвоны свёрнуты в
  счётчик. В список сделок: пометки касаний одним запросом на модуль на всю
  страницу, а не запросом на строку.

Слова для пометок подобраны нарочно другие. На экране сделок «Звонки» и «СМС»
уже означают, ОТКУДА пришёл лид. Назвать пометку теми же словами значило бы
дать человеку прочесть её задом наперёд, поэтому в колонке «Касания» стоят
«Обзвон» и «Рассылка».

Модулей два, а не четыре. Столбца deal_id нет ни у одной таблицы телеграма и
нет у рекламы — связи со сделкой у них не существует, и придумывать её было бы
враньём. Устройство под третий модуль открыто, и сторож числом запросов
обязан его заметить.

Схема базы не тронута: всё нужное было построено раньше.

Сторожа показаны красными шестью вырезаниями: вырезанное правило Т84 роняет
два сторожа сразу, «недозвон считается разговором» даёт девять строк ленты
вместо двух, запрос на строку даёт 61 запрос вместо 16, слияние номеров роняет
четыре сторожа, подмена слов пометок ловится сразу. Возврат каждого доказан
слепком со снятием невидимых знаков конца строки.

Прогоны: полный 5130 проверок, 5126 зелёных, 16099 утверждений, красных ноль.
Обзвон 234 из 234. Статанализ ноль, deptrac ноль.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:23:37 +03:00
Дмитрий 6af73653b6 merge: рабочая ветка сведена в главную — телеграм, обзвон, СМС и лендинг
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Сведено 265 коммитов ветки feat/prospects-manual-testing-kp (c84fed271)
в главную. Столкновение было одно — машинный файл docs/observer/STATUS.md,
взята свежая версия ветки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:21:59 +03:00
Дмитрий 337f6b9897 feat телеграм: дата старта показов, признак живости системы и человеческий язык ошибок
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной
даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался
ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня.
Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом —
ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on
и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете:
три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821.
Мастер перестал молчать о том, что день начала ставит кабинет, а не мы.

Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад».
Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой
не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине
владелец 36 часов не знал, что робот вообще не может войти в кабинет.

Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше
1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить»
называет причину и шаг, куда вернуться, а не гаснет молча.

Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе
защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого
теста. Сторожа доказаны вырезанием.

Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:33:35 +03:00
Дмитрий f83b845658 fix телеграм: экран разбора показывал смету вместо живых денег
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.

Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.

Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.

Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:18 +03:00
Дмитрий 13ce301ab3 feat телеграм: разбор застрявших кампаний и человеческий язык отказов
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.

Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.

Заодно: клиенту больше не показывают внутренние адреса и команды запуска —
причина отказа переводится на человеческий язык. Портал перестал принимать
пустой отчёт робота как успешный.

Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:56:50 +03:00
Дмитрий 9a45793571 обзвон З-2.1: датчик на ложь больше не слеп по месту и по расширению файла
Надзиратель зарезал датчик своим ножом: положил годную по форме ложь в папку
миграций, где обзвон живёт тремя файлами. Датчик остался ЗЕЛЁНЫМ. Допущение
первой редакции — «ложь живёт в трёх папках» — неверно: обзвон живёт ещё в
database и в routes. Ложь под зелёным датчиком хуже, чем ложь без датчика:
следующий человек видит зелёное и решает, что класс закрыт.

Списка папок не завёл — это тот же список разрешений, вывернутый наизнанку, и он
ослеп бы снова на седьмой папке. Обход идёт по ВСЕМУ, исключается поимённо и с
доводом то, что портал про себя НЕ ПИШЕТ:
  • vendor, node_modules — чужой код, мы его не пишем и править не вправе;
  • storage и public/storage — мусор рантайма: журналы, кэши, подставные диски
    проверок. Это следы, а не бумага;
  • bootstrap/cache — СГЕНЕРИРОВАННАЯ копия настроек: соврала бы эхом нашей же
    строки и покраснела бы вторым разом за то же самое;
  • public/build, public/hot — собранный фронт, машинная копия исходников;
  • .env — тайны: строка оттуда не должна попасть в текст падения проверки;
  • символические ссылки — public/storage увёл бы обход в storage мимо
    исключения, да ещё и по кругу.

Расширение файла тоже перестало быть условием: прежний датчик смотрел только
php — то же допущение, только по другой оси. Отсеивается не «не тот язык», а
«не текст»: двоичное, не-UTF-8 и всё крупнее двух мегабайт.

Про db расширил, и вот граница. В обход вошёл канон схемы db с расширением sql:
он описывает базу такой, какая она сейчас, обзвон в нём расписан, и на прошлой
смене ложь нашлась именно там. НЕ вошли docs и CHANGELOG_schema.md — это
летопись: запись от 05.08 была правдой того дня, и править её значит подделывать
записанное.

Заведён датчик на сам датчик. Слепота пришла тихо: зелёный цвет означал не
«чисто», а «не смотрел». Теперь обход обязан доказать досягаемость четырёх
дальних точек в разных углах — миграция обзвона, ежедневные команды, настройки
и канон схемы в другом корне. Сузит кто-нибудь обход — покраснеет сразу.

Показан красным трижды: ножом надзирателя в миграции, тем же ножом в канон схемы
как в не-php файл в другом корне, и сужением обхода. Оба разрезанных файла
возвращены, слепки сошлись знак в знак: 8fb6d531b и 29dbbe675.

Цена сбита с 30 до 8 секунд внутри прогона: регистр складывается один раз на
файл, а не на каждой строке.

Слепо и дальше, говорю вслух: перечень слов конечен, и ложь, сказанная иначе,
пройдёт мимо. Закрыть это перечнем слов нельзя в принципе.

Прогон обзвона 214 из 214, статанализ 0 ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:51:43 +03:00
Дмитрий 3ca4ce16c2 обзвон З-2.1: убраны последние утверждения, что хранилища записей нет, и заведён датчик на класс
Мест оказалось не три, а пять. Четвёртое назвал надзиратель, пятое нашлось при
доделке.

  • ObzvonStiraniePoTrebovaniyuTest — заголовок был написан в будущем времени, а
    комментарий утверждал, что диска в настройках пока не завели. Оба стали
    ложью в день постройки хранилища, а проверка осталась зелёной и сама себя не
    показала бы никогда: она подменяла настройки портала своими;
  • ObzvonChistkaNaObyomeTest, заготовка z22vDisk — словами не врёт, и потому
    опаснее: та же подмена настроек руками. Сторожа объёма стерегли СВОЮ ЖЕ
    подпорку и остались бы зелёными, убери хранилище из настроек портала.

В обоих местах подмена настроек убрана, Storage::fake оставлен: он не трогает
настройки, а уводит корень диска в storage/framework/testing/disks и сам за
собой прибирает. Без него проверки клали бы и удаляли файлы в настоящей папке
записей разговоров на машине, где их запустили.

Заголовок стирания переписан по существу: проверка стережёт ВЕСЬ путь живого
человека — обращение, PdErasureService, переходник обзвона, диск. Соседний
сторож зовёт переходник напрямую и разрыв в середине дороги не увидел бы.

Датчик на весь класс: перечень живых строк, утверждающих в настоящем или
будущем времени, что хранилища записей нет, обязан быть ПУСТ.

  🔴 Датчик написан на PHP, а не на grep, и это замер, а не вкус:
     printf 'ДИСКА ещё НЕТ' | grep -i "диска"  →  НЕ находит.
     Локаль здесь C.UTF-8, и складывать регистр кириллицы grep не умеет вовсе —
     никаким ключом. Собранный им перечень соврёт коротким списком, на чём
     надзиратель и обжёгся. mb_strtolower кириллица родная.
  🪤 Вторая ловушка, моя: шаблон с [^.] между словом и отрицанием не находит
     фразу «Диска … в config/filesystems.php ещё НЕТ» — точка в имени файла
     рвёт совпадение. Датчик проверяет вхождение подстроки, никаких «между».

Датчик показан красным трижды: на возвращённой прежней строке, на моём же
комментарии с дословной цитатой прежней лжи, и рядом с grep, который на той же
строке нашёл ноль. Комментарий переписан пересказом, а не ослаблен датчик: ложь
в кавычках для читателя вскользь неотличима от лжи.

Убран мой собственный мусор из app: три файла-обрывка, порождённых стрелкой
внутри однострочника для tinker — оболочка прочла её как перенаправление.

Прогон обзвона 214 из 214, статанализ 0 ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:25:58 +03:00
Дмитрий 42df0aab2f обзвон З-2.1: сбой хранилища больше не выдаётся за испорченную запись
Своя ошибка, найденная перечитыванием уже зелёного кода.

Приём записи сваливал в один отказ две разные беды: «в файле нет разговора»
и «хранилище не приняло». Задание понимает MaterialNePrinyat как «повторять
бессмысленно» — и правильно понимает, от пятой попытки пустышка целой не станет.

Цена ошибки: временный сбой хранилища — нет прав, отвалилось объектное
хранилище, кончилось место — портал объявил бы «запись испорчена», в повтор бы
не пошёл, и голос человека потерялся бы навсегда при живом исходнике на машине
робота.

Разведено:
  • беда в самой записи — MaterialNePrinyat, повторов нет;
  • беда в хранилище или в дороге — RuntimeException, задание уходит в повтор.

23-й сторож: «хранилище подвело — это ПОВТОР, а не запись испорчена». Показан
красным: вернул прежний MaterialNePrinyat — сторож сказал
Exception RuntimeException not thrown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:32:39 +03:00
Дмитрий a709efdee4 feat(кошелёк): две колонки, период, разрез по кампаниям, расшифровка заморозки
Владелец 06.08: "кошелёк убогий и неинформативный". Отметил пять слабых мест
из шести. Не отметил только прогноз "надолго ли хватит" - он пополняет по
факту, а не по остатку.

Что теперь на экране:
- слева липкая колонка денег: свободно крупно, и под ней РАСШИФРОВКА
  заморозки - какая кампания, сколько заперто, когда вернётся. Раньше стояло
  голое "Заморожено 2 507 руб" без единого слова, под что;
- период: сегодня / вчера / 7 дней / 30 дней / свои даты, живёт в адресе
  страницы;
- столбики расхода по дням, цвет по каналу, своим CSS без библиотеки;
- таблица "Куда ушли" - строка на кампанию с суммой и долей, клик отбирает
  ленту;
- закладки по каналам сохранены, но теперь считаются за выбранный период;
- лента разбита по дням с итогом за день и постраничной догрузкой.

Сервер: две новые сборки RaskryitieZamorozki и OtchyotKoshelka, новая ручка
/api/advertising/wallet/report, лента получила отбор по датам, каналу и
кампании и листание по ключу вместо смещения. Расчёт списаний, заморозки и
возврата по сроку НЕ тронут - правка только про показ.

Найдено по дороге и закрыто:
- чтение отчёта обёрнуто контекстом построчной защиты. Без этого на боевом
  запрос вернул бы НОЛЬ строк молча: местная база под суперпользователем
  защиту обходит, и мы бы увидели это только у клиента;
- сторож на число запросов мог зеленеть БЕЗ самой ручки - на любой неизвестный
  путь портал отвечает страницей с кодом 200. Усилен и проверен вырезанием
  маршрута;
- общий форматтер срезал хвостовой ноль: "833,50" превращалось в "833,5". На
  это независимо наступили три правки подряд, каждая завела свою копию.
  Сведено в formatExact.

Границы суток и группировка по дням считаются ПО МОСКВЕ, а не по Гринвичу.
Сторож взят с живого боевого: запись 69, 2026-08-05 21:00:24 UTC - для
человека это 6 августа. Наивные версии всех трёх мест были написаны нарочно
и увидены красными, прежде чем чинились.

Старые сторожа не выброшены, а перенесены: закладки по каналам переехали в
отчёт вместе с проверкой на 120 строк, отбор ленты по каналу - в проверку
ленты. Каждый переезд назван поимённо.

Проверено: 461 сторож сервера, 2135 сторожей экрана, контроль типов ноль,
статанализ ноль, форматтер чисто, сборка фронта проходит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:18:26 +03:00
Дмитрий 6c01d3e08c обзвон З-2.1: заведено хранилище записей разговоров и переезд звука в него
Портал давал человеку два обещания про запись его разговора — «живёт месяц и
стирается сама» и «потребуешь удалить свои данные — удалим и голос». Обе чистки
были построены и обе ходили в хранилище с именем obzvon_zapisi, которого в
настройках НЕ СУЩЕСТВОВАЛО. Врать они не врали — честно отвечали «не смог
удалить файл», — но удалять им было нечего. Оба обещания были бумажными.

Построено:
  • config/filesystems.php — диск obzvon_zapisi. Драйвер переключается одной
    переменной окружения OBZVON_ZAPISI_DRIVER: в день покупки объектного
    хранилища меняется настройка, а не код;
  • ObzvonZapisHranilishche — единственная дверь к звуку. Приём с проверкой,
    открытие ПОТОКОМ со следом в журнале ПДн ДО чтения, ссылка со сроком в час
    там, где хранилище её умеет, и NULL там, где не умеет;
  • PereveztiZapisJob — переезд записи с сервера робота. Не воскрешает стёртый
    голос, не создаёт вторую копию, не оставляет сироты при проигранной гонке;
  • ZvukovoyFayl — одно место, где живёт знание «что такое запись разговора»:
    расширения, подписи форматов, граница пустой записи в 44 байта.

Три замка изоляции, и все три нужны: RLS на строке, явная сверка клиента и
сверка САМОГО ПУТИ — recording_path заполняет чужая машина, и путь вида
7/../8/golos.wav увёл бы в папку другого клиента при законной сверке клиента.

Исправлена ложь в бумаге. В комментарии диска obzvon_materialy было написано,
что вызов url на закрытом диске бросит исключение и публичной ссылки не
появится. Замерено живым запуском: у местного драйвера url не бросает НИКОГДА,
а молча отдаёт /storage/путь. Ссылка ведёт в папку публичного диска, где записи
нет, — вреда нет, но и обещанной защиты нет. Настоящая защита в том, что url не
зовёт никто.

Ловушка круга: проверка З-2.2 утверждала, что хранилища нет, и покраснела бы от
самой постройки. Вычеркнута не была — переписана. Она стерегла живое: пометить
строку стёртой при живом файле значит выкинуть её из указателя под чистку
навсегда. Теперь «файл жив, а убрать не вышло» строится честно — подставлено
хранилище, у которого delete отвечает отказом.

22 новых сторожа, каждый показан красным в пять заходов. Убрана подмена
настроек в заготовке z22DiskZapisey — пять сторожей З-2.2 теперь охраняют и
наличие хранилища тоже.

Прогон обзвона 212 из 212, статанализ 0 ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:02:17 +03:00
Дмитрий 4a993f8575 feat(кошелёк): видно, за что списали и сколько уходит на каждый канал
Три правки по замечаниям владельца от 06.08.2026.

1. Кошелёк переехал из «Рекламных возможностей» в «Финансы», рядом с
   «Биллингом» — это деньги, а не рекламный канал. Перенесён в обоих меню:
   боковая панель и мобильное «Ещё».

2. Каждое списание теперь называет канал, номер и название кампании:
   «Яндекс Аудитория · кампания №21 «Я4 приёмка — впритык…»». Раньше все до
   одного писались немой фразой «Списание за рекламу (факт)». Подпись
   собирается при показе, поэтому заговорили и уже записанные строки —
   летопись задним числом не переписывается, это финансовый документ.

   Попутно закрыта ловушка: номера кампаний у Яндекса и у СМС идут по разным
   счётчикам и совпадают. В боевой летописи есть и «кампания №4» от СМС, и
   рекламные — различить их было нечем. Теперь разводит канал в подписи.
   Поправлен падеж: было «Заморозка снята — кампанию №13».

3. Закладки по каналам с суммой трат прямо на закладке: Всё 157,74,
   Яндекс Аудитория 130,74, Рассылка СМС 27,00. Тратой считается только
   списание — заморозку ещё можно вернуть, что и случилось с 724 руб.

   Суммы считаются по ВСЕЙ летописи, а не по сотне отдаваемых строк: иначе у
   давнего канала цифра тихо усохла бы при переполнении ленты. Отдельный
   сторож создаёт 120 свежих движений и проверяет, что давние не пропали.

Сторожа: 6 на сервере, 5 на экране, каждый сначала увиден красным.
Рекламный блок 441 зелёный, фронт 2108 зелёных, статанализ ноль, типы чистые.
2026-08-06 06:35:55 +03:00
Дмитрий 9b66c7cd71 test обзвон: шесть сторожей по дырам приёмки — настройка, повтор, объём
Приёмка нашла три дыры, все в сторожах, а не в коде. Код не тронут ни одной
строкой: сверено пустым git diff по всем моим файлам.

Дыра 1 — срок можно было зашить в код, и никто бы не заметил. Прежний сторож
проверял, что строки настроек ЗАВЕДЕНЫ, а не что их ЧИТАЮТ. Заведены два
сторожа поведения: настройка, отличная от умолчания, обязана менять судьбу
записи. У материалов клиента этой дыры нет — чистка там настроек не читает,
а читатель настроек накрыт чужим сторожем круга З-2.3, проверено тем же ножом.

Дыра 2 — повторный запуск. Два сторожа: второй заход не падает, строку не
переписывает и не дописывает в журнал ПДн лишнюю запись об уже стёртом.
Половина сторожа, сравнивавшая столбцы, обманывалась одной секундой —
добавлен ctid, прибор «трогали ли строку вообще».

Дыра 3 — объём. Мерка построена другая, чем просила приёмка, и в отчёте
объяснено почему: запросы обязаны расти с числом строк, иначе придётся
отказаться от правила «не стёрся файл — строку не трогаем». Ловим лишний
запрос в переборе: цена строки прибита к четырём и обязана совпадать на
двух объёмах. Плюс сторож на потолок за заход — его не было вовсе, а именно
он защищает ночь от полумиллиона просроченных строк.

Полный прогон: 4992 на входе, 4998 на выходе, красных ноль.
2026-08-06 01:47:27 +03:00
Дмитрий cec731b17c feat обзвон: чистка записей по сроку хранения — три ступени и чужие голоса
Записи разговоров и записи, которые клиент приносит для обучения робота,
умели приниматься и получать дату истечения — а стирать по этой дате не умел
никто. Любая попавшая к нам запись лежала бы вечно при обещанном сроке.

Построено:
- obzvon:chistka — три ступени по одной строке звонка: месяц звук, три месяца
  расшифровка, дальше ничего. Строку не удаляет никогда: вместе с ней ушли бы
  деньги, и клиент не смог бы спросить «за что вы с меня взяли».
- obzvon:chistka-materialov — чужие голоса: звук 30 дней, расшифровка полгода,
  два срока врозь. Кроме этой команды их не убирает ничто.
- SrokiZvonka — правило срока в одном месте: им пользуется чистка и им же
  обязана пользоваться звонилка волны 3, когда будет проставлять срок.
- Два ключа сроков звонков в system_settings. Новых таблиц нет.
- Обе команды в расписании и в списке сторожа пульса: команду, которой в
  списке нет, scheduler:check-heartbeats не проверяет вовсе.

Отдельно закрыты два ложных отчёта, которых в задании не было:
- чистка не ставит след удаления там, где предмета чистки не было вовсе —
  иначе карточка сказала бы человеку про текст, которого не существовало;
- строка с непроставленным сроком не пропускается молча: срок досчитывается
  от начала разговора, в строку не пишется, число таких строк называется вслух.

Файл не стёрся — строку не помечаем стёртой вовсе: иначе запись выпадает из
указателя под чистку навсегда и её не найдёт уже ни один прогон.

19 сторожей, каждый показан красным. Полный прогон свой рукой.
2026-08-06 00:47:47 +03:00
Дмитрий 1f260c0b97 fix/обзвон: честный отчёт о стирании доходит до оператора - З-2.5, второй заход
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.

1. Невод по тексту не мог найти НИЧЕГО по построению.
   `obzvon_materialy_klienta.transcript` не пишет никто: расшифровщик живёт в
   З-4.2, волна 4. Значит сводка честно говорила "материалы клиента=0", пока
   голос человека лежал на диске 30 дней. Теперь портал считает отдельным числом
   записи, которые проверить НЕЧЕМ - звук жив, расшифровки нет и не было, - и
   говорит это тревожной строкой.
   🔴 Условие уточнено против предложенного: добавлено transcript_deleted_at IS
   NULL. Без него строка, у которой расшифровку стёрли мы сами, а файл убрать не
   смогли, попадала бы в оба числа сразу. Доказано надрезом.

2. Изоляция клиентов не сторожилась ничем. Соединение обходит защиту строк, значит
   отбор по клиенту в коде - ЕДИНСТВЕННЫЙ замок. Сняв его, приёмщик оставил все
   13 сторожей зелёными. Заведены сторожа на все три таблицы.

3. Своя находка того же класса: читатель флага сверял телефон точными написаниями
   с колонкой, которую заполняет ЧЕЛОВЕК руками. На записи "8 (900) 123-45-67"
   он молча отвечал "звонить можно" тому, кто потребовал прекратить обработку.
   Сверка переведена на хвост из десяти цифр - и там, и в переходнике.

4. Экран админки показывал зелёную галочку "выполнено" и пустое поле
   "Webhook-логов", а про обзвон и про нестёртые файлы молчал. Это видимая
   половина той же неправды: тот, кто жмёт кнопку, и есть тот, кто обязан пойти
   проверить руками. Экран называет обзвон поимённо, при нестёртых файлах и
   непроверяемых записях галочки нет вовсе - вместо неё тревога. Мёртвое поле
   убрано вместе с полем в типе ответа.

Сторожа: 13 -> 19 на сервере, плюс 5 экранных. Каждый показан красным семью
надрезами по коду.

Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:26:49 +03:00
Дмитрий cfbe9d51c2 feat/обзвон: требование "удалите мои данные" доходит до обзвона - З-2.5
Портал ставил обращению отметку "выполнено", а голос человека оставался лежать:
наша запись месяц, наша расшифровка три месяца, чужая расшифровка полгода.
Это не недоделка, а машинный ложный отчёт - портал утверждал обратное сделанному.
Бьёт по самым беззащитным: люди на чужих учебных записях согласия нам не давали.

Построено:
- App\Services\Obzvon\ObzvonErasureAdapter - стирает звук, расшифровку и телефон
  в obzvon_calls, телефон в obzvon_number_results, расшифровку и звук в
  obzvon_materialy_klienta. Строку звонка НЕ удаляет: вместе с ней ушли бы деньги.
- App\Services\Obzvon\ObzvonZapretObrabotki - первый в портале читатель флага
  processing_restricted. Отвечает "звонить можно/нельзя" и называет причину словами.
- tests/Feature/Obzvon/ObzvonStiraniePoTrebovaniyuTest.php - 13 сторожей,
  каждый показан красным девятью надрезами по коду.

Правлено точечно: PdErasureService зовёт переходник внутри той же транзакции и
включает его числа в сводку обращения.

Сверх задания, названо в отчёте:
- стирается сам телефон в obzvon_calls, а не только звук с расшифровкой;
- стирается номер в obzvon_number_results - это список, ИЗ КОТОРОГО РОБОТ
  НАБИРАЕТ. Оставить там номер значило бы позвонить человеку снова после того,
  как портал отчитался "выполнено";
- поиск в чужих материалах идёт по нескольким написаниям телефона, а не по
  одному точному: в портале номера лежат и с плюсом, и без.

Границы соблюдены: стоп-листы не тронуты, новых таблиц и миграций нет, боевого
сервера не касался.

Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:25:49 +03:00
Дмитрий 2b401298d8 feat: приём записей клиента для обучения робота обзвона — З-2.3
Клиент отдаёт нам записи СВОИХ холодных разговоров. Это голоса живых людей,
которые об этом не знают и согласия нам не давали. До этой правки принять их
было негде и нечем: положить так, чтобы клиент А не открыл запись клиента Б,
было некуда; срока жизни у записей не было; обращение к чужому голосу не
оставляло следа.

Что заведено:

- таблица obzvon_materialy_klienta — канон в db/schema_modules.sql раздел 24,
  журнал схемы v9.70. Изоляция клиентов защитой по строкам: ENABLE + FORCE,
  политика с USING и WITH CHECK. DELETE не выдан никому — строка остаётся
  носителем надписи «удалены по сроку хранения»;
- ДВА срока, а не один — решение владельца Р82: звук 30 дней, расшифровка
  шесть месяцев. Обе даты истечения NOT NULL: строка без даты означает голос,
  которого не найдёт ни одна чистка. Сами сроки — в настройках портала,
  не в коде;
- два следа удаления врозь: команда чистки из З-2.2 обязана уметь стереть
  звук, не тронув текст. Два замка не дают строке врать «стёрто», пока файл жив;
- закрытое хранилище obzvon_materialy: без ключа url и с serve=false, корень
  под storage/app/private. Публичной ссылки на чужой голос не появится даже
  по ошибке;
- служба MaterialyKlientaService: приём с отклонением чужого формата и битого
  файла, открытие с явной сверкой клиента поверх защиты по строкам, счёт
  записей без нижней границы — три берём, двадцать берём, ноль пускаем
  вторым путём;
- отказ MaterialNePrinyat отделяет наш сочинённый текст от дословного
  показания системы видимой границей. Настоящая причина не выбрасывается;
- след в журнале ПДн pd_processing_log готовым сервисом PdAuditLogger —
  на приём и на каждое открытие.

Стирает НЕ эта правка, а З-2.2, и она не построена. До её постройки материалы
не исчезнут — это известно и так задумано.

При накатке на боевой ОБЯЗАТЕЛЕН перезапуск db/03_service_bypass_policies.sql:
новой RLS-таблице мало политики и прав, иначе служебная роль молча правит
НОЛЬ строк.

Тронут один чужой сторож: SchemaDeltaTest, счётчик таблиц канона модулей
42 в 43. Так же его правили соседи на З-1.1 и З-1.3 — счётчик обязан расти
вместе с каноном, иначе прогон красный у всех.

Осталось открытым: от какой даты считать полгода — считаем от загрузки,
столбец recorded_at заведён под будущий ответ владельца.

24 сторожа, все 24 показаны красными двенадцатью ножами.
Полный прогон 4954 проверки, 4950 зелёных, 4 пропущено, красных 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:35:21 +03:00
Дмитрий 1c2195ebee fix(обзвон): два нуля в тарифе разом запрещены, один ноль можно
Решение владельца Р90 (docs/grilling/2026-08-03-metodika-lena-pod-klienta.md):
цена звонка — два поля, за соединение и за минуту. Оба нуля разом означали
молча бесплатный обзвон: клиент не платит, а оператору за звонки платим мы.
Одна цифра в нуле — осмысленный случай, например не брать отдельно за
соединение.

AdminObzvonTariffController::update() теперь сравнивает обе цены ПОСЛЕ
перевода в копейки и отказывает с объяснением, если обе — ноль. Ловит «0»,
«0.00» и «0.0» одинаково, а не по виду строки. Прежние отказы (пустая,
отрицательная, нечисловая цена) не тронуты.

ObzvonTariffTest.php: +6 проверок «З-1.8». Сторож доказан красным дважды —
до фикса (естественный RED) и после снятия запрета (искусственный RED),
восстановление сверено побайтово через git hash-object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:29:09 +03:00
Дмитрий 778f9b769c feat,обзвон: две заморозки, обе цифры до запуска и разрыв круга с рекламой
Задача З-1.5. Обзвон берёт тот же рекламный кошелёк, что СМС, Телеграм и
Директ, — канал ai_call. Своих денежных таблиц не заводит: весь счёт идёт
через публичные методы AdWalletService, второго места движения денег нет.

Две заморозки, как велит план:
  - обзвон базы — пачкой, минимальный чек умножить на число номеров;
  - авто-обзвон — по поступлению, шаг на каждый пришедший лид.

До запуска клиенту показаны обе цифры — сколько уйдёт под заморозку и
сколько свободных останется. Свободных не остаётся — рядом встаёт названное
словами последствие про рекламу, и запуск требует отдельного подтверждения.
Запуск при этом не запрещается.

Три беды из пяти закрыты здесь.

Беда 1 — дорезерв на ходу не срабатывал ни разу: freeze не складывается, при
уже стоящей броне он выходит первой же проверкой. Бронь теперь растёт
перестановкой release плюс freeze внутри одной внешней транзакции. Общий файл
денег четырёх каналов не тронут ни одной строкой. Цена решения названа в
докблоке: каждый шаг роста кладёт в летопись две строки вместо одной.

Беда 3 — замкнутый круг: обзвон морозит, списание за рекламу тушит Директ,
тушение отпускает бронь рекламы, её доедает обзвон. Круг разорван карантином:
обзвон считает свободным не остаток минус замороженное, а остаток минус
замороженное минус суммы броней, которые сняло именно тушение. Читается по
двум приметам разом — бронь рекламы в состоянии released и кампания-источник
в состоянии остановлено-нет-денег. Ни одной новой таблицы. Деньги выходят из
карантина сами, когда кампания уходит из этого состояния.

Беда 4 — номер менеджера. Формат проверяется замком, принадлежность
телефонам портала — надписью: менеджер клиента вполне может не быть
пользователем, и запирать по этой примете значит запереть почти всех.
Граница замок-или-надпись объявлена владельцем и записана в докблоке.

Беда 5 — вилка стоимости разговора. План называл от 12 до 602 рублей. Это
неверно: предел шестидесяти минут отсчитывается от соединения с менеджером, а
время робота до перевода в него не входит. Настоящий потолок 1252 рубля.
Ни одно из этих чисел в коде не зашито — обе цифры считает тарификатор.

Найдено сверх задания: списание за звонок обязано звать charge с источником
campaign и номером кампании обзвона. С чужим источником бронь не растает,
деньги уйдут с баланса при живой заморозке, свободный остаток просядет вдвое
и выключатель рекламы потушит Директ раньше срока. Контракт для задачи З-1.2
назван в докблоке и охраняется двумя сторожами.

Перевода звонка на живого менеджера в портале нет вовсе, поэтому длинный
разговор и дорезерв на ходу прогнаны на придуманных данных. Доказанными на
живой работе они не называются.

Проверок 25, все зелёные. Сторож показан красным дважды — вырезанием release
из перестановки брони и подменой карантина на ноль.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:09:06 +03:00
Дмитрий 49ef80cd55 fix(реклама): возобновление выдавало непроверенную кампанию за крутящуюся
Прогон Я6 приёмочного листа на боевом, с разрешения владельца трогать кошелёк
тенанта 2. Кошелёк только пополняли — из летописи ничего не вынимали.

Подгонка под «впритык»: свободных было 7 422,76 руб, долили 77,24 руб до
круглых 7 500,00 и завели кампанию на 15 000 показов по 500 руб за 1000 =
смета ровно 7 500,00 руб. Деньги отработали безупречно: запуск при точном
равенстве прошёл, заперли ровно смету, свободных осталось ровно ноль и в
минус не ушли; пауза вернула всё целиком, возобновление заперло столько же;
в летописи три честные строки, строка заморозки одна и та же.

А вот статус врал. На паузу можно поставить и кампанию, которую Яндекс ещё
проверяет — это задумано. Но «Возобновить» ставило running БЕЗУСЛОВНО, и
портал показывал зелёное «Крутится» рекламе, которой Яндекс показываться не
разрешал: показов нет и быть не может. Та же болезнь, что уже ловили на
кампании 6. Потери вердикта нет — часовой обход берёт и крутящиеся тоже.

Починка: «Крутится» ставим только когда вердикт вынесен, иначе возвращаем
«на модерации». Мерка «решено или нет» взята ТА ЖЕ, что у часового обхода:
баннер не решён, пока он не ACCEPTED и не REJECTED. Две разные мерки одного
и того же однажды разойдутся.

Сторож VozobnovlenieNeVryotProStatusTest — три проверки: не врёт на
непроверенной, не ломает одобренную, деньги ведут себя одинаково при любом
вердикте. Принят красным: до починки отдавал running.

Ловушка сторожа записана в журнал: без заглушки на отчёт о показах пауза
отвечает 200, но деньги намеренно не отпускает.

Счёт по комбинациям: закрыты 4 из 9 — Я6, Я7, Я8, Я9.

Прогоны: 430 тестов рекламы зелёные, pint чисто, статанализ 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:04:07 +03:00
Дмитрий 2f0ef8e868 feat,обзвон: тариф звонка переехал в базу — админ правит цену без программиста
Беда: правило цены звонка уже написано и работает, а самих двух цифр — за снятую
трубку и за начатую минуту — держать было негде и менять некому. Чтобы поправить
цену, нужен был программист и новая сборка.

Заведена таблица obzvon_tariffs: строка ровно одна на весь портал, замок
CHECK id = 1, деньги целыми копейками — те же единицы, что у строки звонка.
Ручка админки GET и PUT /api/admin/obzvon/tariff правит обе цифры разом:
половина новой пары с половиной старой давала бы тариф, которого никто не назначал.

🔴 Обе цифры назначены владельцем ВСЛЕПУЮ: сколько нам самим стоит звонок, ни разу
не измерено. Оговорка написана в четырёх местах вплотную к самим числам и уходит
в ответ ручки, чтобы её видел человек на экране, а не только программист в коде.

🔴 Смена тарифа НЕ пересчитывает вчерашние звонки: цена и снимок тарифа лежат в
самой строке звонка. Здесь только «сколько будет стоить следующий».

Мусор в цене не принимается: отрицательная, пустая, нечисловая и с третьим знаком
после точки — третий знак молча пропал бы копейкой. Правка оставляет след в
saas_admin_audit_log — кто, когда, с чего на что.

Канон схемы — db/schema_modules.sql раздел 23, журнал — запись v9.69.
Сторожа — app/tests/Feature/Obzvon/ObzvonTariffTest.php, все показаны красными.

План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:42:34 +03:00
Дмитрий 3333e48450 fix обзвон: сторож бетонировал неверный потолок 602 руб, стык счётчика с таблицей не был исполнен
Беда 1. Сторож закрепил цифру, которой не существует.

В бумагах записано, что крайняя цена одного разговора — 602 руб, и эту цифру по
требованию показывают КЛИЕНТУ перед запуском. Замерил своим прибором на тарифе
2 руб / 10 руб:

  робот 40 с   + менеджер 60 мин = 612 руб
  робот 59 мин + менеджер 60 мин = 1192 руб
  крайний случай 3900 + 3600 с   = 1252 руб

Причина не в расчёте: предел 60 минут отсчитывается от мига соединения с
менеджером, значит режет только ВТОРОЕ плечо. Первое плечо не ограничено ничем,
кроме тревоги, оба складываются, и настоящий потолок вдвое выше обещанного.
А на секунду дальше крайнего случая расчёт не дорожает, а падает с тревогой —
длинный звонок не выставит счёт, он уронит счёт.

Проверка "за 60 минут, а не за 61" была снята при роботе = 0 — на здоровом
случае, где второе плечо идёт в одиночку. Она верна для своего случая, но
потолка звонка не сторожит вовсе, и своим зелёным закрепляла неверные 602 руб.

Что сделано: проверка не удалена, переименована и снабжена оговоркой, что 602 —
цена этого случая, а не потолок. Добавлены проверки на больном случае — длинные
оба плеча разом: 612, 1192, 1252. Добавлена граница падения с обеих сторон:
ровно на краю считается, на секунду дальше по любому плечу — тревога. Крайний
случай выведен из констант класса, а не прибит числом: расширят допуск —
проверка покраснеет и потолок придётся назвать заново.

Сторож показан красным: заменил в счётчике сложение плеч на
min от суммы плеч и предела — 5 проверок из 25 покраснели, а старая проверка
про 602 руб осталась ЗЕЛЁНОЙ. Возврат сверен по git hash-object.

Правило цены НЕ менялось. Счётчик считает верно.

ВОПРОС ВЛАДЕЛЬЦУ: потолка 602 руб не существует, крайняя цена 1252 руб.
Решать одно из двух — либо исправить цифру в бумагах, либо завести предел на
весь звонок, а не только на второе плечо. Сегодня клиенту обещают одно число, а
списать могут вдвое большее.

Беда 2. Стык двух кругов: имена сошлись, смысл разъехался.

Счётчик писал, что tarificiruetsya: false значит "строки в счёте нет вовсе", и
велел класть это в столбец billable. А billable в базе — признак существующей
бесплатной строки: DEFAULT TRUE плюс CHECK billable OR price_kopecks = 0.
Прочитавший подсказку буквально строку не завёл бы и счёт закрытых номеров
потерял.

Смысл сведён по бумагам, не по догадке. З-3.7 проверка 4: в журнале клиента эти
попытки видны ОТДЕЛЬНОЙ СТРОКОЙ. Проверка 5: строк В СЧЁТЕ нет. Т86 требует
счётчик попаданий в пустоту по каждому человеку — считать нечего, если строк
нет. Значит строка звонка заводится всегда, а на закрытом номере она бесплатная:
billable = FALSE при price_kopecks = 0.

Что сделано: подсказки в счётчике исправлены в трёх местах, смысл billable
назван словами в модели строки звонка тремя случаями. Заведён тест стыка — он
берёт ответ schet, кладёт его в obzvon_calls и читает обратно; проверяет, что
закрытый номер даёт строку, что три дозвона в пустоту дают три строки и ноль
денег, что недозвон и закрытый номер в базе различимы, и что база не примет
бесплатную строку с деньгами.

Единицы: перевод копеек в рубли и правда завёлся вторым местом — свой bcdiv в
модели строки звонка. Схлопнуть его НЕ ВЫШЛО, и это отдельная находка: попытка
позвать ObzvonTarifikator::rubli из модели упёрлась в правило слоёв —
App\Models не вправе зависеть от App\Services, deptrac и ADR-005.
Предкоммитный сторож остановил меня, и правильно сделал.

Раз схлопнуть нельзя — два места привязаны друг к другу проверкой: они обязаны
отвечать одинаково на наборе сумм, включая крайнюю цену звонка. Плюс датчик на
класс беды: ТРЕТЬЕ место деления на 100 по коду обзвона запрещено, оба
известных названы поимённо, и отдельная проверка следит, что список
разрешённых не устарел вслепую.

ВОПРОС АРХИТЕКТУРЕ: правильно было бы вынести перевод копеек в рубли в общего
помощника, доступного обоим слоям. Это правка deptrac.yaml либо новый слой —
решение не моё, оставляю названным, а не сделанным втихую.

Сторожа показаны красными трижды. Первый заход: закрытый номер стал
тарифицируемым и в модель вернулся лишний bcdiv — 4 из 8 покраснели, датчик
назвал точную строку. Второй заход после переделки: у модели сбита точность
перевода — 3 из 9 покраснели. Третий: из списка разрешённых убран один файл —
датчик третьего места назвал ObzvonCall.php и номер строки. Каждый возврат
сверен по git hash-object.

Схема БД и миграции не тронуты.
2026-08-05 13:37:07 +03:00
Дмитрий a0761a5c38 fix(реклама): отказ по деньгам говорил клиенту по-английски
Прогон Я7 приёмочного листа на боевом: черновик со сметой 15 000 руб при
свободных 9 122,76 руб. Запуск не состоялся правильно — в Директе не создано
ничего, деньги не тронуты, кампания осталась черновиком. А вот текст отказа
доезжал до клиента как есть:

    Insufficient balance: price_kopecks=1500000, balance_rub=9122.76

Причина: InsufficientBalanceException наследует RuntimeException, и ручка
запуска ловила его общим уловителем, отдавая наружу getMessage. Кнопка
«Возобновить» этой болезнью не болела — там свой уловитель с человеческим
текстом, и он же показал, что сломан именно запуск.

Починка: отдельный уловитель ВЫШЕ общего. Наружу — только клиентские рубли:
сколько нужно и сколько свободно. Ни копеек, ни английского, ни нашей доли.

Сторож PonyatnyyOtkazPoDengamTest — три проверки: нет английского и копеек,
названы оба клиентских числа, нет цены рекламной площадки. Принят красным:
до починки отдавал ровно техническую строку.

Заодно: срок мобильного прокси продлён до 03.09.2026 — поправлена устаревшая
подсказка в config/services.php. На боевом дата уже заменена, плитка стала
зелёной: «жив, IP 92.36.112.83, до 03.09.2026».

В журнал приёмки записаны прогоны Я7, Я8, Я9 — все три закрыты живьём и
бесплатно — и пересчёт цены комбинаций: деньги уходят только за настоящие
показы, поэтому семь прогонов из девяти стоят ноль. Настоящая стена не
денежная, а временная: сегмент 1653 человека даёт около показа в час, и
«вся смета» это полтора месяца.

Прогоны: 427 тестов рекламы зелёные, pint чисто, статанализ 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:09:35 +03:00
Дмитрий 42ebbe769c feat: обзвон — кошелёк уходит в минус только по разрешению канала
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт
уходит в минус, и долг остаётся за клиентом — решение владельца Р38, требование
Т61а. Числа из спеки: было 12 рублей, списали 102 — на счету минус 90, а в
летописи одна строка на 102.

Разрешение выдано поимённо, белым списком: только ai_call. У яндекса, СМС и
телеграма поведение не изменилось ни на копейку — списание по-прежнему упирается
в ноль. Доказано A/B на одном дереве: 424, 425 и 330 тестов трёх каналов дали
ровно те же числа и с правкой, и без неё.

Проверка платёжеспособности научилась называть причину остановки: долг по
обзвону и непокрытые заморозки — разные вещи. Сам вердикт isSolvent не изменён
ни байтом: по решению Р56 минус гасит клиенту всю рекламу, и письмо задачи З-1.7
обязано назвать обе остановки сразу. Различать нужно, чтобы назвать, а не чтобы
смягчить приговор.

Попутно, и это касается уже работающих денег рекламы: возврат refund был
единственным из пяти методов, кто не ставил контекст клиента. Замерено запуском,
а не чтением — под боевой ролью без контекста метод падал ModelNotFoundException,
а не молчал. Живым деньгам не грозило: единственный зовущий, PollClientSmsDelivery
Command, ставит контекст сам во внешней транзакции. Теперь ставит и сам метод.

Обрезка нулём для трёх старых каналов сохранена намеренно, но перестала быть
молчаливой: на ней пишется предупреждение ad_wallet.charge_clamped_to_zero. До
сих пор съеденная разница между летописью и остатком пропадала без следа.

Сторожа: tests/Feature/Obzvon/AdWalletMinusTest.php и AdWalletMinusRaceTest.php.
Все показаны красными пятью поломками. Гонка проверена параллельным прогоном
шести и четырёх настоящих процессов, а не рассуждением: без замка по строке
кошелька теряется пять списаний из шести.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:10:37 +03:00