Commit Graph

790 Commits

Author SHA1 Message Date
Дмитрий 5211048bce feat(телеграм-реклама): Этап 2 закрыт — робот несёт фактическую стоимость (2.2), билинг МТС подтверждён
Задача 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>
2026-07-28 07:52:56 +03:00
Дмитрий 2e5db6737b feat(телеграм-реклама): Этап 2 (часть) — статус «отменена» и возврат брони при отказе/отмене
Закрыты денежно-независимые куски Этапа 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>
2026-07-28 07:28:16 +03:00
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 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>
2026-07-28 06:37:25 +03:00
Дмитрий cc985e07de feat(телеграм-реклама): экран «реклама отклонена» + закалка робота против глюков МТС
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).

Задача 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>
2026-07-27 20:10:10 +03:00
Дмитрий 5b04d751c0 feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 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>
2026-07-27 18:41:33 +03:00
Дмитрий 0c9642f3d0 feat(телеграм-модуль): клиентский экран + ручной запуск в песочнице — API, telegram.ts, экран, сквозной сценарий
Сессия 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>
2026-07-27 17:39:43 +03:00
Дмитрий b23d380d69 feat(телеграм-модуль): мост Laravel → Node-робот — статус-машина, обёртка робота, джоб, песочница
Сессия 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>
2026-07-27 16:51:12 +03:00
Дмитрий 298afb835a feat(телеграм-модуль): ядро клиентского модуля Telegram-рекламы — таблицы, модели, цена, аудитория, кошелёк
Сессия 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>
2026-07-27 16:15:17 +03:00
Дмитрий b5898b8017 @
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>
@
2026-07-25 13:05:57 +03:00
Дмитрий c7bcd7943a fix(прогрев): ночная заливка в Яндекс молчит при нуле номеров, не пишет красную ошибку
При нуле активных номеров (например, после чистки данных) джоб слал в Яндекс
пустую замену состава, Яндекс отвечал 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>
2026-07-23 19:20:15 +03:00
Дмитрий 83ebb91001 feat(витрина B6): read-API состояния прогрева для finder
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
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>
2026-07-23 15:34:23 +03:00
Дмитрий ddabe61558 Merge commit 'a90d60a3' into sms-multi-operator
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
# Conflicts:
#	app/app/Jobs/SendSmsCampaignJob.php
2026-07-23 14:34:52 +03:00
Дмитрий a90d60a351 feat(витрина B3): отправка СМС пишет эпизод прогрева
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
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>
2026-07-23 13:55:16 +03:00
Дмитрий 80c9ca7e67 feat(витрина B1,B2,B4,B5): летопись эпизодов прогрева + значки в воронке; уборка мёртвых скоупов
Кусок 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>
2026-07-23 13:54:09 +03:00
Дмитрий 509c66e7f9 fix(смс): предпросмотр/оценка учитывают наличие имени канала (§3.4) + убран мёртвый конфиг
Раньше имя отправителя проверялось только в джобе на отправке — предпросмотр
и оценка цены считали такие номера оплачиваемыми. Теперь 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.
2026-07-23 13:18:45 +03:00
Дмитрий 794c020a80 feat(смс): имя отправителя под конкретный канал + статус skipped_no_sender 2026-07-23 12:41:09 +03:00
Дмитрий 1acf32a591 test(смс): усилить проверку нормализации оператора (роутер только-МТС ловит отсутствие защиты) 2026-07-23 12:34:02 +03:00
Дмитрий 7e0b7a608d feat(смс): маршрутизация по каноническому оператору (нормализация в отборе/джобе/предпросмотре) 2026-07-23 12:28:58 +03:00
Дмитрий 08ffe371b8 feat(смс): реестр каналов в конфиге + сборка роутера из реестра 2026-07-23 12:15:46 +03:00
Дмитрий 780d7552b5 fix: статус доступа ВК пишем в строку площадки, не в мёртвый синглтон
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Тот же класс, что баг значка воронки. Экран ВК показывает 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>
2026-07-23 12:03:29 +03:00
Дмитрий 8033debfbc fix: воронка — значок «на прогреве» берём из firm_channels, не из мёртвых ch_*
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
После Фазы 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>
2026-07-23 11:37:54 +03:00
Дмитрий 48b88737e7 feat(прогрев ф2 этап D): backend СМС-канала — свой список sms-firms + счётчик «Отправлено N» (+ план этапа D)
Фаза 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>
2026-07-22 18:28:43 +03:00
Дмитрий b5e6051803 feat(прогрев ф2 этап C): экран канала — выбор + «Греть»/«Убрать», убран тоггл «Греть здесь»
Фаза 2 Этап C, Task C4 (Vue + tiny backend). SalesWarmingView переделан под модель
«фирма×канал»: галочки = выбор (без сети), кнопка «Греть» открывает модалку —
«По воронке» или «Простой срок [N] дней» (1–365) → warmFirms; кнопка «Убрать»
(массово с подтверждением + построчная иконка) → unassignFirms. Тоггл «Греть здесь»
и toggleWarmingFirm удалены. Загруженные (ещё не греются) показаны серым чипом
«Ждёт запуска» — для этого firmRow отдаёт channel_status (loaded|warming|null),
api-строка получила поле channel_status. Vuetify (v-dialog/v-text-field), палитра
проекта, переиспользованы существующие чипы sm-*. Vitest 20 (экран) + 1482 фронт,
build ✓, backend 46 GREEN, larastan 0. Bump baseline (actingAs-ложняк).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:16:48 +03:00
Дмитрий 75fd019325 feat(прогрев ф2 этап C): эндпоинт «Убрать» — снять выбранные фирмы с канала
Фаза 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>
2026-07-22 17:57:28 +03:00
Дмитрий effcf013dd feat(прогрев ф2 этап C): эндпоинт «Греть» — массовый запуск прогрева канала (воронка/простой срок)
Фаза 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>
2026-07-22 17:48:39 +03:00
Дмитрий 6775f58003 feat(прогрев ф2 этап C): показ по каналам — firms/бейджи/числа из firm_channels (+ план этапа C)
Фаза 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>
2026-07-22 17:38:15 +03:00
Дмитрий ce48d911d6 feat(прогрев ф2 этап B): заезд = только загрузка во все 4 канала (loaded), без автостарта
Фаза 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>
2026-07-22 16:39:45 +03:00
Дмитрий e15a4d8a69 feat(прогрев ф2): тоггл «Греть здесь» и firmRow — на строки firm_channels
Фаза 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>
2026-07-22 16:08:35 +03:00
Дмитрий 32f9d87f9c feat(прогрев ф2): файл МТС берёт только строки firm_channels warming+active
Фаза 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>
2026-07-22 15:47:08 +03:00
Дмитрий ce62ae249d feat(прогрев ф2): ВК-заливка берёт только строки firm_channels warming+active
Фаза 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>
2026-07-22 15:32:30 +03:00
Дмитрий c7831e3f14 feat(прогрев ф2): Яндекс-заливка берёт только строки firm_channels warming+active
Фаза 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>
2026-07-22 15:19:13 +03:00
Дмитрий 475d814100 feat(прогрев ф2): recalc бежит по строкам firm_channels — гасит выдохшихся в loaded
Фаза 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>
2026-07-22 15:08:07 +03:00
Дмитрий 80b85a7a02 feat(прогрев ф2): модель SalesAdAudienceFirmChannel + связь firmChannels на фирме
Фаза 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>
2026-07-22 14:48:06 +03:00
Дмитрий a4edbdf5f1 feat(прогрев ф2): таблица firm_channels — состояние прогрева на канал (миграция + бэкфилл)
Фаза 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>
2026-07-22 14:24:32 +03:00
Дмитрий 10ea2f6295 feat(прогрев): строка фирмы показывает состояние по срокам площадки
state/stop_reason/resume_at/warming_active на вкладке площадки
(GET /api/sales/warming/{platform}/firms) теперь считаются
PlatformWarmingDecider по срокам ИМЕННО этой площадки, а не сводкой
по phone.state/firm.stopped_at — та же фирма может быть active на
yandex и уже stopped на vk. allFirms() (СМС-подборщик) сохраняет
старую сводную логику без изменений.
2026-07-22 11:27:09 +03:00
Дмитрий 4d90168d75 feat(прогрев): файл МТС по срокам своей площадки
Task 8: mtsFile() отбирает фирмы решением PlatformWarmingDecider по
срокам ИМЕННО МТС (тем же приёмом, что SyncVkAudienceJob), вместо
плоского phone.state='active'. Фирма, погасшая по срокам МТС, в файл
больше не попадает, даже если у неё есть номер с state=active.
2026-07-22 11:18:40 +03:00
Дмитрий 27be277ca1 feat(прогрев): ВК-джоб читает строку площадки и льёт по срокам ВК 2026-07-22 11:11:51 +03:00
Дмитрий 194532042e feat(прогрев): Яндекс-заливка по срокам своей площадки
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>
2026-07-22 11:00:49 +03:00
Дмитрий 39d460f8b6 test(прогрев): AdAudienceScreenTest под новый контракт days на строке площадки 2026-07-22 10:50:13 +03:00
Дмитрий bd2def5fc8 feat(прогрев): recalc сводит per-platform решения в firm-level сводку
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:46:34 +03:00
Дмитрий 2c3698c615 feat(прогрев): сроки площадки пишутся/читаются со строки площадки 2026-07-22 10:32:50 +03:00
Дмитрий 53c55f1973 feat(прогрев): durations() + days на модели площадки 2026-07-22 10:10:04 +03:00
Дмитрий 2a3e65f412 feat(прогрев): 11 сроков + days на строки площадок (миграция + бэкфилл) 2026-07-22 10:05:40 +03:00
Дмитрий fc16500fea feat(прогрев): блок warming (active + каналы) в ответе по проспектам
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) рассылку.
2026-07-21 16:16:41 +03:00
Дмитрий 71c93477b8 feat(прогрев): значок sms в warming_channels по отправленной рассылке 2026-07-21 16:07:20 +03:00
Дмитрий f93ab67e7e feat(прогрев): платформо-независимый маршрут назначения менеджера (для экрана СМС) 2026-07-21 16:00:36 +03:00
Дмитрий fbe4701399 feat(прогрев): firmRow отдаёт нишу, дату запуска, менеджера и состояние прогрева
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() в новом тестовом файле — по конвенции остальных тестов).
2026-07-21 15:53:10 +03:00
Дмитрий 079e96edf1 fix(прогрев): список фирм одинаков на всех вкладках площадок (синхронизация)
firms({platform}) больше НЕ фильтрует по ch_<platform> — отдаёт весь состав
прогрева. Так фирму видно и можно включить на любой площадке; ВК/Телеграм
перестают быть пустыми. Отметка «Греть здесь» в строке (isOnHere) по-прежнему
по своей площадке. Тест переведён на $this-> (larastan понимает тип);
phpstan-baseline регенерирован (п.25) — счётчики тестов выровнены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:19:41 +03:00
Дмитрий b7dc3f9b08 fix(реклама): вернуть GET /api/sales/ad-audience/firms для модуля «Прогрев СМС»
Разъезд «Рекламы на кандидатов» на площадки (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>
2026-07-21 11:39:15 +03:00
Дмитрий 42462119f4 test(прогрев): переносит AdAudienceScreenTest на /api/sales/warming/{platform}
Старый /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>
2026-07-21 11:18:25 +03:00