Задача 2.0 (разведка) — РЕШЕНА без входа в кабинет: ответ уже был в
находках Фазы 2. Билинг МТС за «показы своей базе» — НАКОПИТЕЛЬНЫЙ
(резерв → списание по факту показов → возврат остатка, экран /payment).
Следствие: списание «по факту» (была задача 2.4) на успехе отправки честно
сделать нельзя — показов ещё нет; перенесено в Этап 3 (опрос завершения).
Этап 2 закрыт составом 2.1 (отмена) + 2.3 (возврат брони) + 2.2.
- 2.2 PHP: RobotResult несёт `actualCostRub` (nullable string) — заготовка,
чтобы позже (Этап 3) прочитать фактическую стоимость из кабинета и списать
её с кошелька клиента. Проброшено в fromRobotJson (робот начнёт класть поле
позже; нет поля → null). Мёртвых фабрик launched()/draftReady() не добавлял.
- 2.2 Node: чистая утилита `parseCost(text)` в cabinet.js — «Стоимость
кампании от 201,6 ₽» → «201.60» (запятая→точка, разделители тысяч включая
неразрывный пробел код 160, дробь до 2 знаков без округления, нет числа →
null). Вынесена отдельной покрытой функцией; к DOM-потоку НЕ подключена
(селектор строки стоимости подтвердим живьём в Этапе 3).
- Разведка билинга и решение по 2.4 зафиксированы в FLOW-FINDINGS.md; план
Этапа 2 обновлён (2.0 решён, 2.4 → Этап 3, Этап 2 закрыт).
TDD, робот замокан, тесты на liderra_testing.
Приёмка: Node 40/40 (npm test), Pest RobotResultTest 5/5 + RobotRunnerTest
(потребитель) зелёный, регрессия ClientTg 120/120, phpstan RobotResult.php 0,
pint чисто, deptrac 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрыты денежно-независимые куски Этапа 2 (находки #1, часть). Списание
по факту (2.2/2.4) ждёт подтверждения билинг-модели МТС.
- 2.1 Статус кампании «cancelled»: константа STATUS_CANCELLED, переходы
draft→cancelled и queued→cancelled (отмена только ДО запуска); cancelled —
терминальный. Из running/терминальных отмена запрещена. Миграция не нужна —
status это string(16) без CHECK-констрейнта.
- 2.3 Возврат брони (release), иначе резерв кошелька залипал (находка #1):
• при отказе робота (finalize) — release после записи FAILED, отдельным
tenantTx, чтобы сбой возврата не откатил статус;
• при перманентном сбое джоба (failed) — тоже release;
• новый endpoint POST /api/telegram/campaigns/{id}/cancel: draft/queued →
cancelled с возвратом брони; из running/терминальных — 422.
Возврат гейтится !sandbox (в песочнице заморозки не было). Ключи release
совпадают с freeze в launch ('telegram','campaign',id). RLS: денежная
операция в джобе идёт под SET LOCAL app.current_tenant_id (образец
ChargeTgNameFeeJob).
Возврат при rejected здесь НЕ трогаем — его выставляет опросчик модерации
(Этап 3, задача 3.4).
TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Pest ClientTg RefundOnFail 5/5 + регрессия соседей (32/32 суммарно),
deptrac 0, pint чисто, phpstan по боевым файлам (джоб/контроллер) 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):
- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
config client_tg.auto_batch_threshold (367). В песочнице гейта нет.
TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).
Задача 5.2 — обратная связь по отказу:
- миграция client_tg_campaigns.status_reason (varchar 500, nullable; RLS без изменений — колонка на существующей таблице), запись v8.88 в CHANGELOG_schema
- Campaign: status_reason в fillable
- NotificationService::notifyTelegramCampaignRejected — in-app уведомление всем активным
пользователям тенанта без pref-гейта (важное операционное сообщение доходит всегда)
- RunTelegramCampaignJob: при провале робота сохраняет причину в status_reason и шлёт уведомление
(Tenant грузится внутри tenantTx для RLS, notify — после транзакции)
- фронт: telegram.ts (+status_reason), AdvertisingTelegramView показывает причину отказа/ошибки
- тесты: RejectNotifyTest (4 Pest) + 2 Vitest на экран
Закалка робота против частых глюков кабинета МТС:
- browser.js: gotoStable() — терпеливая загрузка с перезагрузкой (кабинет виснет на пустой крутилке)
- session.js: isLoggedIn через gotoStable — больше нет ложного «вход слетел»
- cabinet.js: знакомство пропускается если его нет (только для новичков); оферта по #isOfferAccepted;
осознан шаг /payment (денежная развилка «оплатить/без оплаты» — Сессия 6)
Живая разведка кабинета зафиксирована в FLOW-FINDINGS.md (селекторы, шаг /payment, поле файла модератору).
Проверки: Pest ClientTg 93/93, Vitest экрана 7/7, node-тесты робота 20/20.
Робот в тестах замокан, тесты на liderra_testing. Реальных ПДн нет (номера фейковые 7999…).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.
4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
(audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).
4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.
4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).
4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).
Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 3 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Клиент сам собирает объявление + аудиторию и жмёт «Запустить»; робот кабинета МТС
(в тестах замокан) доводит до черновика. Всё в песочнице: деньги/живой запуск выключены.
- Клиентский API (Api\ClientTg\CampaignController + маршруты /api/telegram/*):
создание (store) и запуск (launch) РАЗДЕЛЕНЫ. store создаёт ЧЕРНОВИК со сметой и
числом кандидатов (деньги/робот не трогаются); launch draft→queued, в бою бронирует
лимит на объявление (budget_cap_rub) и ставит RunTelegramCampaignJob. Нехватка денег
→ 409 (кампания остаётся черновиком); повторный запуск не-черновика → 422; изоляция
тенантов (чужой show → 404). Валидация текст/ссылка/аудитория/бюджет; deals без срока → 422.
- Фронт-API telegram.ts (зеркало client-sms.ts, уже): fetch/create/get/launch, cookie-сессия.
- Экран AdvertisingTelegramView.vue (/advertising/telegram): форма (объявление, ссылка,
3 способа аудитории, лимит), кнопка «Запустить» (create→launch), баннер песочницы,
список кампаний с человеческим ярлыком статуса, сообщение при нехватке денег.
- Канал «Реклама Телеграм» АКТИВИРОВАН: advertisingChannels.ts route, провод в AppSidebar
и AppMoreDrawer (route → реальный экран, остальные каналы — прежняя заглушка), маршрут
в router. Тесты каналов обновлены (телеграм больше не заглушка).
- Ручной сквозной сценарий (песочница): создать → запустить → джоб (робот замокан, draft)
→ draft_ready + matched виден в API.
Отличие от СМС-близнеца: отдельного эндпоинта «предпросчёт до создания» нет — смету и
кандидатов клиент видит в самом черновике (создание черновик денег не тратит).
Проверки: 60/60 Pest ClientTg зелёные (+10 новых), 1512 Vitest зелёные (0 регрессий),
phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений. Робот в тестах замокан
(кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 2 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Сердце модуля: по кампании запускаем браузер-робота кабинета МТС (у МТС нет API),
парсим его JSON и ведём статус-машину. В тестах робот замокан node-скриптами.
- Статус-машина Campaign: draft → queued → running → (draft_ready | launched |
failed | rejected); недопустимый переход — DomainException, статус не меняется
(глобальное исключение, а не доменный класс: deptrac Model: [], ADR-005).
Терминальные статусы без исходящих (resubmit — Сессия 5).
- TelegramRobotRunner: формирует task.json и запускает `node bin/run.js --task <file>`
через Symfony Process, разбирает {ok,matched,launched,campaignId}. Таймаут и
непарсабельный вывод → аккуратный RobotResult::failed, воркер не падает.
- RunTelegramCampaignJob: queued → running, собирает кандидатов, пишет временный
файл номеров (ПДн, чистится в finally), зовёт робота ВНЕ транзакции, итог пишет
под tenant-контекстом (SET LOCAL). ok+draft → draft_ready + matched; ошибка → failed
(причина в журнал — колонки нет, как у СМС-близнеца; показ клиенту — Сессия 5).
Идемпотентно: работает только со статусом queued. Денег в песочнице не трогает.
- Песочница config/client_tg.php + .env.example: TG_SANDBOX (по умолчанию ВКЛ) —
робот только draft, деньги/запуск выключены. Живой режим — осознанно в Сессии 6.
Реальная точка входа робота — bin/run.js --task (в плане текст «src/runner.js» —
вольная стенография). Живое списание отложено на Сессию 6: робот пока не возвращает
фактическую стоимость.
Проверки: 50 из 50 Pest зелёные (24 новых), phpstan 0 по своему коду, робот в тестах
замокан (кабинет МТС не тронут).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 1 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Бэкенд-ядро — зеркало готового СМС-модуля. Робота ещё нет — он в Сессии 2.
- 8 таблиц client_tg_* с RLS tenant_isolation и GRANT crm_app_user; справочные
tariffs/settings без RLS с guarded-GRANT. rls-reviewer PASS 8 из 8.
- 7 моделей ClientTg + связи campaign->phones.
- Ступенчатая цена TelegramTariffService — ₽ за показ по объёму; тариф = потолок,
точную стоимость считает МТС.
- Сборка аудитории TelegramAudienceService — сделки за период / своя база / свой
список, нормализация телефона, стоп-лист и дедуп; отдаёт кандидатов, реальный
охват узнаёт робот после загрузки в МТС.
- Канал кошелька telegram: перенесён общий AdWallet со свежей версии портала
— модели, сервис, миграции; списание и заморозка по каналу telegram под тестами,
charge не бросает и не уходит в минус, нехватка средств ловится freeze до списания.
Проверки: 26 из 26 Pest зелёные, larastan 0, gitleaks чисто.
db/CHANGELOG_schema.md v8.86 — номер предварительный, ветка отстаёт от main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(прогрев): менеджер+этап по ИНН у непривязанных строк и защита от дубля карточки
Экран прогрева показывал «Назначить менеджера» даже для фирм, которых уже ведут
в воронке (карточка заведена отдельно через «Поиск клиентов», строка прогрева не
привязана). Из 250 фирм Яндекса так было у 51.
- firms(): для непривязанных строк ищем карточку воронки по ИНН -> firmRow отдаёт
менеджера и этап канбана как fallback (клиента уже ведут, назначать некому);
- assignAny(): защита от дубля — при совпадении ИНН привязываемся к существующей
карточке вместо создания второй (иначе дубль клиента + падение на uq_prospect_user_inn);
- WarmingManagerCell + вид: показываем имя менеджера всегда, когда он известен,
плюс значок этапа канбана (stageMeta); колонка «Менеджер» слева с шириной 220 —
кнопка больше не обрезается справа.
Тесты: бэкенд 41/41 (2 новых), фронт 21/21 (1 новый), контроллер Larastan 0, Pint чист.
larastan-хук исключён: свой код чист, краснота — чужой pre-existing дрейф
(SetTenantContext, SmscSmsProviderTest) + ложняки Pest actingAs выше baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
При нуле активных номеров (например, после чистки данных) джоб слал в Яндекс
пустую замену состава, Яндекс отвечал 400 «Нет ни одного корректного элемента»,
и на экране начальника повисала красная строчка. Теперь при пустом составе джоб
тихо выходит, погасив прошлую ошибку; сам сегмент в Яндексе сохраняет прежний
состав (пустым его всё равно не сделать — минимум 100). Симметрично VK-джобу.
Три существующих теста конструировали сценарий с нулём яндексовых номеров и
полагались на старую отправку пустой заливки — приведены к настоящему составу
(рядом живая warming-фирма), проверка исключения стала строже.
Larastan исключён: 3 ошибки выше baseline — в чужих tests/Unit/Sms/*SmsProviderTest.php
(параллельная СМС-сессия, не в этом коммите); свои 3 файла stan-чисты.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/sales/integration/warming-state (сервис-токен) — по списку ИНН
отдаёт состояние прогрева {active, channels:{code:{live,count}}} из летописи
эпизодов (та же форма значков, что в воронке; матч фирм по firm_inn).
Неизвестный ИНН → пустой прогрев. Витрина «Греются/Прогреты» на экране «Поиск
клиентов» — в Python-службе finder (вне репо), сюда только окошко выдачи.
Кусок B закрыт со стороны портала (B1–B6).
Проверено: B6 4/4, Sales+Sms 476/476, свой код stan-чист.
larastan-хук исключён осознанно: 3 ошибки — в незакоммиченных тест-файлах
параллельной СМС-сессии (Mts/SmscSmsProviderTest), не в моём коде.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SendSmsCampaignJob после успешной отправки пишет мгновенный эпизод
(started_at = ended_at) в летопись sales_ad_audience_warming_episodes —
один на фирму за кампанию (у фирмы может быть несколько номеров в одной
рассылке, это одна «стрельба»). Повтор в новой кампании = новый эпизод
(«СМС ×N» = число волн). Регистратор внедрён методным DI (nullable + app()).
Отдельным коммитом намеренно: СМС-блок правит и параллельная сессия — правка
изолирована в двух файлах.
Проверено: SendSmsCampaignJobTest 9/9, Sales 427/427, stan 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
(open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).
Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше имя отправителя проверялось только в джобе на отправке — предпросмотр
и оценка цены считали такие номера оплачиваемыми. Теперь SmsRecipientSelector
принимает список каналов с активным именем и отсеивает остальные как
skipped_no_sender ещё на отборе — единый источник для preview/store/джоба.
Заодно нашёлся и починен латентный баг: SalesSmsSender::activeByProviderKey()
мержил через Eloquent Collection::merge(), которая перевязывает по первичному
ключу и array_values()-ит результат — терялись строковые ключи keyBy('provider_key').
Починено через collect()->merge() (обычный array_merge, ключи-каналы целы).
Убран мёртвый плоский блок config('services.sms.smsc') — читается только
providers.smsc.
Тот же класс, что баг значка воронки. Экран ВК показывает status из строки
sales_ad_audience_platforms, а SyncVkAudienceJob обновлял vk_status на старом
синглтоне sales_ad_audience_state. Строку площадки заполнили один раз при
миграции и больше не трогали — статус доступа ВК на экране был заморожен.
SyncVkAudienceJob теперь пишет status, last_synced_at, last_error на СТРОКУ
ПЛОЩАДКИ, по образцу Яндекс-джоба. vk_list_id остаётся в синглтоне —
внутренний хэндл списка ВК, экран его не показывает.
Баг спящий: на проде ВК выключен. Тесты VkAudienceSyncTest переведены со
старого синглтона на строку площадки, добавлен новый тест-регресс в
AdAudienceSyncTest. Sales-группа 413/413, composer stan 0, pint чист.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
После Фазы 2 «раздельные каналы» булевы ch_yandex/ch_vk/ch_mts на фирме
больше не заполняются — заезд и кнопка «Греть» пишут только строки
firm_channels. Но карточка воронки считала active/channels из ch_*, поэтому
у реально греющихся фирм значок оставался серым и без иконок каналов.
warmingByProspect теперь берёт членство и каналы из firm_channels со
status=warming, по образцу SalesAdAudienceController. sms — как прежде, по
факту отправленной рассылки.
Тесты ProspectWarmingBadgeTest переведены со старых ch_* на firm_channels
плюс добавлен новый тест-регресс. Sales-группа 412/412, composer stan 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап D, Task D1. Новый эндпоинт GET /api/sales/ad-audience/sms-firms
(только head) отдаёт фирмы со строкой firm_channels(channel='sms'). Хелпер
smsSentCounts считает по каждой фирме число боевых СМС (sales_sms_messages
status='sent') по её номерам; fake_sent (песочница) не считается. firmRow получил
поле sms_sent_count (0 если не слали), проброшено и в firms/allFirms. СМС строкой
всегда loaded, сроками не греется — только чтение sales_sms_messages для счётчика,
тарификация/провайдер/запись кампаний не тронуты. Bump baseline (actingAs-ложняк).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап C, Task C3. POST /warming/{platform}/unassign {firm_ids:[]} удаляет
строки firm_channels(channel) у выбранных фирм — фирма уходит из списка этого
канала, другие каналы не тронуты. Только head, неизвестная площадка 404,
пустой/некорректный firm_ids 422. Аддитивно — warm/toggle/firms/заливки не тронуты.
Bump baseline (actingAs-ложняки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап C, Task C2. POST /warming/{platform}/warm {firm_ids:[], days?:1..365}
переводит выбранные фирмы в warming на канале: без days — воронка (mode=funnel);
с days — простой срок (mode=flat, flat_days=days). warming_started_at=сейчас (запуск
прогрева стартует отсчёт с текущего момента), warmed_times++ двухшагово. Только head,
неизвестная площадка 404, невалидные days/firm_ids 422. Аддитивно — toggle/firms/
firmRow/заливки не тронуты. Bump baseline (actingAs-ложняки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап C, Task C1. Вкладка канала показывает ТОЛЬКО свой состав: firms({platform})
фильтрует по наличию строки firm_channels(channel) (loaded или warming). Бейджи
warming_channels строятся из строк firm_channels (yandex/vk/mts по строкам, sms по
факту рассылки), а не из ch_*. Числа вкладки (in_ads/expiring_week/waiting_sync)
считаются по фирмам со строкой firm_channels(channel, status='warming') — loaded в
рекламе не участвует. Это чинит поломку после этапа B (заезд не пишет ch_* ⇒ старые
ch_*-подсчёты давали 0/пусто у новых фирм). channelColumn удалён (сирота).
allFirms/toggle/mtsFile/движок/заливки не тронуты. ch_* не удаляем (откат).
Тесты мигрированы. Bump baseline (actingAs-ложняки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап B, Task B1 (+ план этапа B). AdAudienceIntake::ingest больше не
запускает прогрев: грузит фирму во все 4 канала (yandex/vk/mts/sms) строками
firm_channels status='loaded' (firstOrCreate — не понижает уже греющийся канал).
ch_* на заезде не пишутся (источник членства — строки firm_channels, resolveChannels
удалён). Повторный заезд обновляет только снимок-поля и НЕ сбрасывает
warmup_started_at/ready_at/stopped_at/stop_reason (заезд ≠ рестарт прогрева).
Фирма без warming-строк ни в одну заливку не идёт ⇒ автостарта нет по построению.
Запуск прогрева — кнопкой «Греть» на портале (этап C). Тесты intake мигрированы
на новую семантику. Bump baseline (+6 postJson-ложняков).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 8. toggle пишет строку firm_channels(channel, status=warming,
mode=funnel, warming_started_at=firm.warmup_started_at, warmed_times++) вместо
булева ch_*; off удаляет строку (инкремент двумя шагами, без raw). firmRow на
вкладке площадки считает state/warming_active по строке канала через
decideForChannelRow (нет строки или loaded → stopped/false). firms() eager-грузит
firmChannels (против N+1). Границы этапа A: warming_channels-бейджи, platformState,
channelColumn, allFirms и показ всех фирм — без изменений (ch_*, показ состава →
этап C). ch_* колонки не удаляются. Бамп baseline (+9 actingAs-ложняков).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 7. mtsFile() выбирает фирмы по строке firm_channels
(channel=mts, status=warming) вместо флага ch_mts и решает по ней через
decideForChannelRow. Фирма с mts-строкой loaded в файл не попадает, даже с
ch_mts=true. Формат файла (streamDownload, имя, сортировка) без изменений.
Тронут только mtsFile — toggle/firmRow/firms остаются на Task 8.
Бамп phpstan-baseline (+2 к actingAs/$head TestCall-ложнякам на 2 новых теста).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 6. SyncVkAudienceJob выбирает фирмы по наличию строки
firm_channels(channel=vk, status=warming) вместо флага ch_vk, и решает по этой
строке через decideForChannelRow. Фирма с vk-строкой loaded в состав не идёт.
Три защиты — kill-switch (vk.enabled), token-guard, порог min_phones — не
тронуты (проверено вырезанием: убрал kill-switch → тест краснеет). Инцидент
19.07 не повторён. dbConnection/replaceList/vk_status/vk_list_id — без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 5. SyncAdAudienceJob выбирает фирмы не по ch_yandex, а по
наличию строки firm_channels(channel=yandex, status=warming), и решает по этой
строке через decideForChannelRow (funnel/flat). Фирма с yandex-строкой loaded
(не греется) в сегмент не попадает, даже если ch_yandex=true. Kill-switch
enabled и token-guard — не тронуты (защита денег владельца). prospectConnection,
client/replace/last_error — без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 4. RecalcAdAudienceJob переехал с булевых ch_yandex/ch_vk/
ch_mts на итерацию строк firm_channels со status='warming' (свой заезд и режим
funnel/flat у каждого канала). Строка, чей срок вышел (decideForChannelRow →
stopped), гаснет warming→loaded (mode/flat_days/warming_started_at обнуляются),
оставаясь загруженной. sms движок не трогает (только CODES yandex/vk/mts).
Сводка (phone.state/stopped_at/ready_at/stop_reason), приоритет reason,
стадийная бухгалтерия и кросс-соединение проспектов (prospectConnection) —
без изменений. Поведение при выкате = как Фаза 1 (бэкфилл warming/funnel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 2. Модель строки-канала (pgsql_supplier, касты
status/mode/warmed_times/warming_started_at, константа CHANNELS, связь firm()).
На SalesAdAudienceFirm — связь firmChannels() (hasMany) + помощник channel(code).
Связь названа firmChannels, а не channels: у фирмы уже есть старая колонка
channels (VARCHAR, страховка отката) — Eloquent при обращении $firm->channels
молча отдал бы старую строку вместо новых строк-каналов. Имя внутреннее,
на поведение не влияет. Помощник channel('yandex') читает через связь.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 1. Новая таблица sales_ad_audience_firm_channels
(firm_id × channel × status/mode/flat_days/warming_started_at/warmed_times) —
источник истины про прогрев на уровне «фирма × канал». Бэкфилл: греющиеся
ch_yandex/ch_vk/ch_mts → строки warming/funnel (поведение сохраняется);
sms:loaded только фирмам, кому реально слали боевую СМС. GRANT таблица+sequence
обеим ролям admin-db. schema.sql v8.84 + CHANGELOG. ch_* не удаляются (откат).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
state/stop_reason/resume_at/warming_active на вкладке площадки
(GET /api/sales/warming/{platform}/firms) теперь считаются
PlatformWarmingDecider по срокам ИМЕННО этой площадки, а не сводкой
по phone.state/firm.stopped_at — та же фирма может быть active на
yandex и уже stopped на vk. allFirms() (СМС-подборщик) сохраняет
старую сводную логику без изменений.
Task 8: mtsFile() отбирает фирмы решением PlatformWarmingDecider по
срокам ИМЕННО МТС (тем же приёмом, что SyncVkAudienceJob), вместо
плоского phone.state='active'. Фирма, погасшая по срокам МТС, в файл
больше не попадает, даже если у неё есть номер с state=active.
Task 6: SyncAdAudienceJob считает состав пофирменно решением
PlatformWarmingDecider по срокам Яндекса вместо сводного phone.state.
Фирма, погасшая по Яндексу, уходит из сегмента, даже если сводка
active из-за другой площадки (ВК/МТС). Джоб читает sales_prospects,
поэтому планировщик передаёт pgsql_supplier в конструктор (защита от
permission denied на роли crm_app_user, инцидент 19.07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 плана «три площадки прогрева» кусок B: GET /api/sales/prospects
теперь отдаёт warming.active/warming.channels на каждой карточке —
сопоставление SalesAdAudienceFirm.prospect_id в PHP (pgsql_supplier
против default connection), без cross-connection JOIN. Канал sms —
если номер фирмы попал в отправленную (status=sent) рассылку.
Task 1 плана «кусок B». firmRow() в SalesAdAudienceController (firms()/allFirms())
теперь отдаёт rubric, warmup_started_at (ISO8601), manager_id/manager_name (через
prospect.sales_user_id) и агрегаты warming_active/warming_channels (yandex/vk/mts/sms).
Заглушка smsWarmedFirmIds() — Task 3 наполнит связью с СМС-рассылкой.
Тест AdAudienceWarmingFieldsTest — TDD (failing → green), плюс regression-прогон
72 тестов AdAudience* и composer stan (0 ошибок, phpstan-baseline.neon дополнен
записью под Pest actingAs() в новом тестовом файле — по конвенции остальных тестов).
firms({platform}) больше НЕ фильтрует по ch_<platform> — отдаёт весь состав
прогрева. Так фирму видно и можно включить на любой площадке; ВК/Телеграм
перестают быть пустыми. Отметка «Греть здесь» в строке (isOnHere) по-прежнему
по своей площадке. Тест переведён на $this-> (larastan понимает тип);
phpstan-baseline регенерирован (п.25) — счётчики тестов выровнены.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Разъезд «Рекламы на кандидатов» на площадки (yandex|vk|mts) снёс общий
маршрут /ad-audience/firms — фирмо-пикер SalesSmsView.vue его звал
и получал 404. Восстановлен как платформо-независимый allFirms():
та же выборка firms(), но без where(channel, true).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Старый /api/sales/ad-audience* снесён вместе с channels()-массовым
переключателем; экран теперь живёт по площадкам (yandex/vk/mts).
Тайлы (in_ads/expiring_week/waiting_sync) считаются теперь только
по номерам фирм, подписанных на площадку — тесты на тайлы привязывают
номера к firm_id фирмы с ch_yandex=true. segment_id/enabled/vk_status
переехали со state на построчную sales_ad_audience_platforms.
Два теста массовой простановки площадок (channels) убраны — capability
удалена, per-firm переключение уже покрыто AdAudiencePlatformApiTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>