Кампания 713175197 третьи сутки не показывалась при замороженных у клиента
деньгах, а программный интерфейс на всех уровнях рапортовал «Идут показы».
Правду отдала только подпись на экране кабинета: «Для показа в заданных
регионах предоставьте документы» — 13 объявлений придержаны, 2 отклонены.
1. Яндекс отвечает машине ПО-АНГЛИЙСКИ, если не попросить русский.
Замер боевым ключом, два одинаковых запроса подряд: без заголовка
«Rejected at moderation.», с Accept-Language ru «Отклонено на модерации.».
Английская строка уезжала клиенту в переписку и письмом как пояснение
модератора, честная заглушка «причину выясняем» не срабатывала никогда,
а на следующем обходе английская строка ложилась ПОВЕРХ доклада разведчика
— проверено по боевой ленте: 30.07 в 15:02 робот принёс полную причину,
в 17:00 её накрыло. Лечение: спрашиваем язык явно плюс второй заслон —
английские отписки узнаются как отписки.
2. Отказ включения Яндекс кладёт в Warnings, а код смотрел только в Errors.
Ответ целиком: ResumeResults с Warnings 10201 «Объявление не остановлено»
при пустом Errors. Исключения нет, портал считал, что справился: двое суток
обход каждые два часа поднимал 13 объявлений, ноль записей в журнале.
Лечение: resumeAds возвращает, кого включить не дали.
3. Новый исход «принято, но придержано» порталу не был известен вовсе.
Вердикт ACCEPTED, ярлыка «Отклонено» нет, разведчик не ходил — клиент видел
«Идут показы» при нулевых показах. Теперь по отказу включения клиенту идёт
сообщение «Яндекс принял объявление, но пока не показывает его. Причину
выясняем» — текст выбран владельцем — и туда же едет разведка.
Ловушка, обойденная по дороге: включение спрашиваем ДО разбора объявлений.
Наоборот — сообщение о придержке легло бы поверх доклада разведчика, и обход
начал бы чередовать их по кругу: защита от дублей смотрит на последнее сообщение.
Замеры. Восемь новых сторожей, все написаны ДО починки и падали именно
на живых данных. Отдельный сторож на противоположный случай — включённому
объявлению разведку не заводим — был зелёным с самого начала.
Портал 4083 теста, 4043 прошло, 16 упало: те же шесть давних классов и ровно
те же числа, что до работы, было 4069/4029/16. Плюс 14 тестов — ровно новые.
Pint и Larastan по изменённым файлам чистые.
Главный урок записан в cabinet-flow.md §7.9: в §7.7 лежит правдивый РУССКИЙ
ответ Яндекса, снятый ДРУГИМ инструментом, который язык просил. Разбор отписок
построили по показаниям прибора, которым продукт не пользуется. Мерить надо той
же дорогой, по которой ходит боевой код.
На боевой НЕ выкатывалось.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- результат «В корзину»: одна причина, дата созвона стирается, платящего не выбросить;
- реклама на корзине встаёт СРАЗУ — явной веткой, а не случайно через unknown_stage;
- у «Выслано КП» дата созвона стала необязательной: бывает «пришлите на почту,
если интересно — перезвоню». Врущий старый тест на 422 без даты поправлен;
- фильтр доски date_mode=todo|changed + период (добавлен вид «завтра»).
«Надо сделать» — созвон в периоде ИЛИ просрочен, кроме отказа и корзины.
«Менялось» — есть движение стадии ИЛИ запись разговора за период.
Прогон: 1245/1245 (отдел продаж + все юнит-тесты).
Конфликты только в документах: журнал «мозга» взят из main, запись v9.28
переставлена поверх боевого журнала схемы. Код не пересекался.
Сторож слоёв проверен отдельно в рабочей папке: 0 нарушений.
Сторож секретов gitleaks отработал по-настоящему.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
До правки decide на незнакомой стадии возвращал unknown_stage — реклама на
такие фирмы встала бы без единого сообщения. Теперь обе новые стадии идут
по правилу переговоров: крутим до созвона, потом ещё срок просрочки.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Настоящая причина, по которой портал не мог завести кампанию. Прежний диагноз «сегменту
нужно не меньше 1000 опознанных людей» оказался ВЫДУМКОЙ: он был построен на статьях,
а не на замере, и увёл в ожидание, которое ничего не решало.
Что происходит. Сегмент Аудиторий указывается в условии ретаргетинга не своим номером,
а номером с приставкой 20: сегмент 58034825 уходит как ExternalId 2058034825. Без приставки
Директ ищет цель Метрики с таким номером и честно отвечает 8800 «Object not found» — тем же
отказом, что и на несуществующий сегмент. Поэтому поломка выглядела как «аудитория не готова».
Замер на боевом кабинете 30.07 на одном и том же ГОТОВОМ сегменте:
ExternalId 58034825 -> отказ 8800 «Object not found»
ExternalId 2058034825 -> условие создано, № 41678818
После починки то же самое кодом портала: условие № 41678854 создано и убрано за собой.
Приставку показал сам Яндекс: у уже существующего в кабинете условия на этот сегмент
retargetinglists.get возвращает ровно "ExternalId": 2058034825. Документация про приставки
говорит глухо и только «для интересов» — живой ответ кабинета оказался надёжнее документации.
Попутно снят ложный «дефект продукта». Порога в 1000 человек НЕ существует: сегмент годится
для Директа при 134 опознанных людях из 151 номера. Минимум портала менять не нужно.
Ещё один замер, важный на будущее: статус is_processed у сегмента — это НЕ «готов»,
у готового статус processed, заполнены matched_quantity и cookies_matched_quantity,
и стоит can_create_dependent. Судить о готовности только по can_create_dependent.
Тестов на приставку было ДВА, и оба закрепляли поломку: проверяли, что уходит номер БЕЗ
приставки. Оба были зелёные всё время. Тест, написанный по нашему представлению о правде,
а не по ответу живой системы, охраняет баг и делает его невидимым. Теперь оба проверяют
приставку и падают без неё; реклама портала 43 из 43.
Вместе с починкой ложатся три промта смен: 29.07 сведение телеграма и прокси, 29.07 робот
креативов в работу и 30.07 состояние после живой приёмки. В последнем ложный диагноз про
порог в 1000 человек помечен как неверный прямо в шапке — чтобы следующая смена не пошла
по нему второй раз.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ни одну нельзя было увидеть без настоящего прогона: часть закрыта песочницей Яндекса,
часть — швами между кусками, которые по отдельности покрыты тестами.
1. Слепок креативов уходил с SelectionCriteria пустым МАССИВОМ. Живой Яндекс отвечает
отказом 8000 «SelectionCriteria cannot contain an array», портал не может снять слепок,
задание роботу не выдаётся, клиент видит вечное «готовим картинки». В JSON нужен пустой
ОБЪЕКТ. Песочница до этой проверки не доходила — отвергала запрос раньше, на входе.
2. Отказы Яндекса ПО ПОЗИЦИИ внутри ответа глотались молча. Запрос успешен, а нужного Id
в позиции нет — вместо него Errors с человеческим объяснением. Портал падал ошибкой PHP
«Undefined array key Id», ни клиенту, ни в журнал не попадало ни слова из ответа Яндекса.
Теперь слова Яндекса выходят наружу — именно это и позволило найти пункт 3.
3. В условии ретаргетинга не передавался MembershipLifeSpan — срок хранения человека
в сегменте. Живой Яндекс отказывает «Required field: Not specified time for goal or
segment», и следом «Object not found»: довод негоден целиком, запуск встаёт. Берём
audience_days кампании, границы Яндекса 1..540.
4. Робот не отдавал набор Яндексу: заливал файлы, нажимал «Создать» и уходил. Оказалось,
«набор» в кабинете и «креатив» в creatives.get — разные вещи: пока набор не отмечен
галочкой и не нажато «Добавить выбранные», Яндекс креативы не регистрирует. Замер:
14 залитых картинок были невидимы для creatives.get, после добавления в ЧЕРНОВИК формы
счётчик прыгнул 18 -> 32 в ту же секунду. Объявление при этом не сохраняется,
«Сохранить изменения» робот по-прежнему не трогает никогда.
5. Гонка при заливке. Три живых прогона подряд из 15 картинок доносили 14, и каждый раз
пропадала ДРУГАЯ: 480x320, потом 300x600, потом 336x280. Замер объяснил: кнопка
«Создать» разблокируется на 4-й секунде, когда принято 11 файлов из 15, остальные
дозагружаются к 7-й. Робот жал сразу по разблокировке. Ждём теперь по строкам принятых
файлов и по исчезновению слова «Загружается».
Первая попытка этой починки НЕ РАБОТАЛА и выглядела рабочей: сторож считал размеры
в окне, не заметив постоянного фильтра из тех же 15 размеров вверху. Поймано по тому,
что прогон занял ровно столько же секунд, сколько до починки. Отсюда тире в признаке
приёма — фильтр его не содержит.
Каждая починка закрыта тестом, который сначала падал на своей поломке. Прежние тесты
проверяли разбор ОТВЕТОВ и путь ДО «Создать» — форму запросов и то, что после, не смотрел
никто. Робот 84 из 84, реклама портала 42 из 42.
Проверено живьём на боевом: задание роботу отработало со статусом done, опознано
15 креативов из 15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.
Сверх самого слияния:
- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.
Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Мелочи приёмочного листа v12 §8. Каждая правка с тестом; где защита уже стояла
в коде — тест проверен вырезанием этой защиты.
Предел веса картинки. Две прежние проверки были пустышками: сравнивали константу
саму с собой и с тем же числом в ответе сервера. Вырезание правила max: оставляло
обе зелёными. Настоящий тест грузит перевес и ждёт отказа — это четвёртая найденная
пустышка за ветку.
Обход модерации. Объявление без статуса и причина отказа длиннее колонки роняли
запись в базу ВНЕ защиты, и обход обрывался на середине: остальные клиенты не узнавали,
приняли их рекламу или отклонили, а деньги за отклонённый набор не возвращались.
Запись ответа теперь под той же защитой, что и сеть; пустой статус не пишем вовсе,
причину храним обрезанной.
Робот. Адрес файла приходил в ответе сервера, а шли по нему со своим токеном без
всякой сверки. Теперь адрес обязан вести на портал. Папка снимков экрана росла
бесконечно, а на снимках видны логин и остаток счёта — старше двух недель убираются.
Ещё: порядок посредников служебного канала — токен раньше служебного соединения;
BannerGenerator больше не отдаёт молча файл тяжелее предела и берёт предел из общей
константы; нулевой номер креатива ловится намеренно, а не случайно нестрогим сравнением.
Портал 300/300, робот 60/60. Мест снятия заморозки денег по-прежнему четыре.
Не тронуто намеренно: цена за 1000 показов и бюджет приходят от клиента — но это
видимое поле мастера и принятое продуктовое решение, а не недосмотр. Решает владелец.
Хвосты портала П1-П8 из приёмочного листа v12.
П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим
текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина
находки не подтвердилась.
П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список
сопоставляется при отчёте. Раньше стороны расходились и опознание падало на
безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи
креативов: слепок обрывался на первой странице.
П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же
транзакции, что и перевод в работу. Два задания в очереди получали одинаковый
слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса.
П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним
запросом. Не сошлось или креатива нет — запуск не идёт.
П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы,
rls-reviewer GO.
П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании.
Смежная находка: перезаливка картинки теперь обнуляет номер креатива.
П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от
номера баннера; робот берёт расширение из ответа портала.
Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все
размеры не существует. Медийная кампания состоит из объявления на каждый размер
блока со своим креативом, поэтому строка баннера стала единицей
размер плюс файл плюс креатив плюс объявление.
Что сделано:
- у баннера появились номер креатива, номер объявления и статус модерации
- кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ
- слепок картиночных креативов аккаунта через creatives.get
- опознание своих креативов разницей слепков до и после загрузки по размеру
- запуск заводит объявление на каждый включённый баннер
- модерация считается по каждому объявлению: кампания работает, если принято
хотя бы одно, а деньги возвращаются только когда отклонены все
Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре.
После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе
джоб модерации молча увидит ноль баннеров.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача 8c. После перевода запуска на медийную кампанию за показы удалены
неиспользуемые методы клиента YandexDirectClient: addCampaign TextCampaign,
addAdGroup, addAudienceTarget с ContextBid, addTextAd прод-вызовов нет.
Из модели AdCampaign убраны легаси клик-поля weekly_budget_rub и click_bid_rub
из fillable и casts колонки в БД не тронуты.
uploadAdImage и текстовые объявления оставлены живыми они ещё используются.
Тесты клиента и модели обновлены. Реклама-модуль 188/188 зелёный.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Задача 8b. Админ-экран «Реклама: расход и маржа» переведён со старой модели
наценка 30% делением на единую модель показов наценка 40% вычитанием, как в
CampaignImpressionCharger. yandex_cost = client_spend умножить на долю
1 минус ad_margin_percent/100. Поле ответа markup_percent переименовано в
ad_margin_percent, фронт-вью и тип обновлены.
Мёртвый класс AdMarkup и его тест удалены полностью. Колонка ad_settings
markup_percent осталась в схеме, но больше не читается. Тесты: бэк
AdminAdvertisingSpend 6/6, реклама-модуль 189/189, фронт админки 7/7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Аддитивно в YandexDirectClient пять методов под медийную кампанию, старый клик-путь не тронут:
- addCpmBannerCampaign — CpmBannerCampaign, стратегия CP_MAXIMUM_IMPRESSIONS, FrequencyCap, суммы в микросах
- addCpmBannerAdGroup — пустая группа CpmBannerKeywordsAdGroup как JSON-объект {}, регионы
- addMediaAudienceTarget — привязка сегмента к группе без клик-ставки ContextBid
- addCpmBannerAd — объявление CpmBannerAdBuilderAd по номеру готового креатива + href
- getCreativePreview — превью креатива для показа клиенту, null если не найден
Всё за рубильником, к реальному Яндексу не обращается. Тесты Http::fake 7/7 зелёные,
весь реклама-модуль 186/186 не сломан. Контракт — findings 2026-07-26-direct-media-api-contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- config yandex_direct.spend_limit_guard_multiplier — бэкстоп от перерасхода, не клиентская цена
- миграция ad_campaigns.yandex_creative_id nullable + точечный GRANT UPDATE админ-роли с гардом
- модель AdCampaign: yandex_creative_id в fillable и casts integer
- тесты: config-набор и миграция зелёные, реклама-набор не сломан
- CHANGELOG схемы v9.01; план Части 4 и findings контракта медийного API Директа
Часть B «мотор» ещё впереди: медийные методы клиента, переписанный запускатор, контроллер.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки.
Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде.
Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Часть 3a из 6. Чистое ядро под медийные баннеры, без БД/сети.
- BannerSizes: 15 базовых размеров блоков медийной Яндекса (1×).
- BannerGenerator::coverJpeg — GD cover-crop (масштаб+центр-обрезка) в ТОЧНЫЙ
размер, JPEG с подбором качества под вес ≤512КБ; исключение на нечитаемой картинке.
Логика повторяет ручной gen_banners.py (Pillow cover) владельца.
Тесты: 5/5 зелёные (квадрат/широкий/высокий, точные пиксели, вес, ошибка).
Дальше 3b: загрузка 1 картинки → все размеры → превью-галерея → утверждение.
Spec: docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md §4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания),
Часть 1 из 6 — денежное ядро и модель. На бой не выкачивается.
- AdImpressionPricing: чистый калькулятор (показы=аудитория×частота,
клиентская сумма по 120₽/1000 с округлением вверх до копейки, маржа); bcmath scale 2.
- ad_settings.client_cpm_rub (default 120.00) — плоская клиентская цена, наценка клиенту не видна.
- ad_campaigns: поля модели «за показы» (frequency, *_impressions, budget_rub,
yandex_cost_rub, charged_client_rub), weekly_budget_rub → nullable (клик-наследие).
- AdCampaign: fillable/casts + default delivered_impressions=0.
- CHANGELOG_schema v8.96 + требование скрытия маржи (yandex_cost_rub) в клиентской выдаче.
Тесты: 12/12 рекламных зелёные (вкл. регресс кошелька). rls-reviewer: PASS.
Spec: docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
МТС-провайдер переписан с Exolve на «МТС Маркетолог, Рассылки по своей базе PRO»
(омни-адаптер api.mts.ru, тело submits/naming, ответ submitResults). Обслуживает
только МТС-номера. Мост: Guzzle CONNECT_TO через SSH-туннель SMS_MTS_CONNECT_TO —
боевой IP у МТС закрыт, убирается строкой .env после открытия доступа. Имя
отправителя из БД/конфига (liderra.ru). Конструктор обратно-совместим — роутинг-
тесты целы. За флагом SMS_MTS_ENABLED, песочница не тронута. Тесты СМС 82/82,
larastan по изменённым файлам 0, pint чисто. LEFTHOOK_EXCLUDE=larastan: чужой
pre-existing SmscSmsProviderTest:52 (не baseline, не мой файл).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 3. Новый метод PlatformWarmingDecider::decideForChannelRow —
решает по строке firm_channels: mode=flat считает простой срок от
warming_started_at строки + flat_days (active/stopped(flat_done)); mode=funnel —
та же логика decide(), но точка отсчёта warmup берётся из строки канала, а не из
firm.warmup_started_at (у каждого канала свой заезд). Существующий decide() не
тронут — Фаза 1 цела. AdAudienceScheduler без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).
Что сделано:
- разъём провайдера SmsProvider: новый оператор подключается одним файлом
- заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования
имени отправителя у операторов (это недели), иначе разработку не закончить
- маршрутизация по оператору: билайновский номер уходит через Билайн за
4,75 ₽, прочие через МТС — без ручного выбора канала
- стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием
- отбор получателей с шестью причинами пропуска, все ДО траты денег
- списание скопировано с AutopodborChargeService; пока клиента нет
(tenant_id пуст) с баланса не берём — платим оператору напрямую
- оператор номера доезжает из «Поиска клиентов» в прогрев (был известен
и оплачен ДаДате, но терялся при передаче)
Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах
СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой,
а не переделкой модуля.
Найдено и закрыто при исполнении:
- замок от двойного списания стоял не на том соединении: кампания на
pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался
сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку
«оплачено» при неушедших деньгах. Источник правды перенесён в
balance_transactions под замок по тенанту. Доказано тестом: старый код
списывал 700 вместо 850
- приём в портал требовал phones строкой по regex — словарь с оператором
получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что
звали сервис МИМО контроллера. Проверка теперь принимает оба формата,
тест идёт через HTTP
- телефоны директоров в contacts остаются строками (договор
SalesProspectController), словари — только в верхнем phones
Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник
createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter
после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние
прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату
роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает
22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут.
Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226
зелёный, статанализ чист. Все защиты проверены вырезанием.
План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md
Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Создание сегмента — два шага, а не один: upload_csv_file даёт статус uploaded
(черновик, в списке Аудиторий его нет и Директу он не виден), и только
segment/{id}/confirm сохраняет сегмент. Второй шаг отсутствовал — 19.07 заливка
на бою отчиталась успехом, id записался, а в Аудиториях было пусто.
content_type для телефонов строго 'crm'. Регресс-тест закрывает.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Коммит d2c2ec43 от 19.07 оторвался от main (dangling, ни в одной ветке):
чистка ПДн и YandexAudienceClient в основную ветку так и не попали.
Номера заменены на фиктивные 7999000000X, клиент и его тесты внесены заново.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(sales): периоды сегодня/вчера/7/30 дней календарём, пополнения за период, скролл доски наверх
Замечания владельца по сводке и доске.
ПЕРИОДЫ:
- Резолвер понимает today/yesterday/d7/d30; «7 дней» = сегодня и 6 предыдущих.
Месячные this/prev/prev2 сервер принимает по-прежнему.
- По умолчанию 30 дней.
- ПОЧИНЕНО: выбор «Произвольный» без дат ронял запрос (500). Теперь понятный 422,
а на фронте период применяется только когда отмечены ОБЕ даты.
- Произвольный выбирается календарём-диапазоном, не руками; порядок дат неважен.
ПОПОЛНЕНИЯ ЗА ПЕРИОД (переиспользован готовый topupsRub):
- Сводка: плашка «Пополнили баланс» рядом с «Σ баланс».
- Мои клиенты: колонка «Пополнил».
Заодно даёт число, которое видимо меняется при смене периода.
ДОСКА: горизонтальная полоса прокрутки поднята НАД колонками (колонки высокие,
системная полоса уезжала за экран); синхронизация в обе стороны, ResizeObserver
на приезжающие карточки.
Спека §21 (включая §21.4 — как по коду заполняется «Требуют внимания»).
Гейты: Pest 257/257 sales+unit, Vitest 1417, vue-tsc чисто, Larastan 0 в своих. TDD.
LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
Larastan-хук исключён точечно: ошибки baseline-дрейфа в чужих Admin/Billing
тестах (Pest TestCall false-positive), не в этом изменении. Мои файлы чисты.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На проде self_render (GET к POST-ручке /render) и exa (GET api.exa.ai root) отдавали 404
и показывались красными/«не удалось» — ложь, сервис ЖИВ (достучались, просто не тот метод).
Теперь liveness: status<500 → жив (404/405 = сервер ответил); 5xx/обрыв → мёртв.
Правит HttpFundedServiceProvider (aitunnel/exa/xfetch) + SelfRender/SalesFinder пробы.
Тесты: 20/20 (вкл. новые 404→жив, 5xx→мёртв), Larastan 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
По вопросу владельца: EXA ходит через обратный SSH-тоннель (services.exa.proxy),
и это самое хрупкое звено (ops-машина+Happ+порт 10808). Раньше падение тоннеля
выглядело бы как «EXA не отвечает». Теперь ExaTunnelLivenessProbe — отдельная плитка:
сквозной запрос ЧЕРЕЗ прокси на нейтральный адрес (exa.tunnel_check_url, дефолт
api.ipify.org). Тоннель🟢+EXA🔴 = проблема у EXA; тоннель🔴 = легла наша труба.
Дашборд: 12 сервисов. Тесты: 66 моей области зелёные (вкл. 3 пробы тоннеля + 12-keys), Larastan 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EXA (api.exa.ai) за Cloudflare блокирует российские IP боевого. Прямой пинг с прода
дал бы ложное «не отвечает» и ложный алерт «EXA упал» из суточной джобы. Теперь
ExaBalanceProvider ходит через services.exa.proxy (тот же тоннель, что ExaSiteFinder).
HttpFundedServiceProvider получил хук httpOptions() (Guzzle-опции, по умолчанию нет).
Тест: 4/4 ExaBalanceProviderTest, Larastan 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза B плана 2026-07-16-external-services-online-monitoring:
- HttpFundedServiceProvider — база «деньги-или-живость» по HTTP (DRY).
- AitunnelBalanceProvider / ExaBalanceProvider / XfetchBalanceProvider — баланс если
задан *_balance_url, иначе живость пингом; деградация «деньги→жив→grey».
- SelfRenderLivenessProbe / SalesFinderLivenessProbe — только живость.
- Реестр рефрешера расширен до 11 сервисов; supplier — единственный тяжёлый.
- config/services.php: ключи новых сервисов (sales_finder.base_url дефолт ПУСТОЙ).
Тесты: 53 External зелёные, Larastan 0 по своим файлам (точечно).
NB: larastan-хук исключён — 2 ошибки выше baseline в ЧУЖОМ незакоммиченном файле
tests/Feature/Billing/ExpireInvoicesTest.php (работа параллельной сессии в общей папке).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>