Commit Graph

816 Commits

Author SHA1 Message Date
Дмитрий 7d3ed2f6eb feat(реклама): Часть 6 — списание по факту показов + клиентский отчёт по показам
CampaignImpressionCharger: списание с рекламного кошелька за фактически
показанные показы (показано×120/1000, дельта от уже списанного, идемпотентно
по external_key yandex-imp:{id}:{billable}), режет по оплаченному, статус
completed при достижении оплаченного; bcmath, 6 boundary-тестов. Клиентский
отчёт CampaignReportDialog переведён с недельного бюджета на показы
(оплачено/показано/частота/потрачено); статусы queued/completed в списке и
отчёте. Маржа/yandex_cost клиенту не видны. Бэкенд 105/105, фронт 104/104.

Отложено до Части 4 (нужен Директ): джоб, тянущий фактические показы из отчёта
Директа и зовущий CampaignImpressionCharger; campaigns.suspend при стопе;
переделка админ-маржи с наценки-% на реальный yandex_cost_rub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 16:07:19 +03:00
Дмитрий de9f0d7d8e feat(реклама): Часть 5b — мастер «за показы», отправка-заявка, скрытие маржи
Мастер CampaignWizard переделан с клик-модели на показы: окно дней → частота
+ живая смета (120 ₽/1000 показов, без маржи) → одна картинка + галерея превью
+ утверждение баннеров → проверка и «Отправить заявку». Бэкенд store/update
принимают частоту/бюджет показов (клик-поля убраны), новый submit → статус
queued («готова к запуску», реальный запуск в Директ — Часть 4). Маржа
yandex_cost_rub скрыта от клиента ($hidden, тест). CampaignList: метка queued
и показы вместо недельного бюджета. Тесты: бэкенд 99/99, фронт 102/102.

Директ: заявка на ПОЛНЫЙ доступ к API подана 26.07 (upgrade, статус «новая»);
картинка-креатив через API невозможна (creatives.add только видео) — гибрид,
баннер заливается в кабинет вручную, CreativeId вставляется в портал.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 15:45:53 +03:00
Дмитрий 2c8c876d51 feat(реклама): Часть 3b-2 — endpoint'ы баннеров (загрузка/превью/утверждение)
Часть 3b-2 из 6 (Часть 3 «баннеры» закрыта целиком).

- ad_campaigns.banners_approved_at (nullable) — момент утверждения набора; новая
  загрузка сбрасывает в NULL.
- Endpoint'ы tenant-scoped: banner-source (1 картинка→15 баннеров), banners (список превью),
  banners/{id}/preview (стрим приватного файла), banners/approve (флаг; пусто→422). Чужой→404.

Тесты: 22/22 зелёные (вкл. регресс). CHANGELOG v8.98.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 11:49:17 +03:00
Дмитрий 8e91be53c3 feat(реклама): Часть 3b-1 — набор баннеров кампании (хранение + генерация)
Часть 3b-1 из 6. Из одной картинки клиента генерируется и сохраняется полный
набор баннеров кампании (15 размеров Яндекса).

- Таблица ad_campaign_banners (RLS tenant_isolation, GRANT crm_app_user SELECT/INSERT/DELETE,
  клиентская — srv_bypass не нужен). Модель AdCampaignBanner.
- CampaignBannerService::generate — прогон исходной картинки по BannerSizes через
  BannerGenerator, файлы на приватный диск local, строки в БД; перегенерация заменяет набор.
- CHANGELOG_schema v8.97 + предупреждение для Части 4 (джоб под crm_supplier_worker
  потребует GRANT + srv_bypass re-run, иначе тихий ноль).

Тесты: 9/9 зелёные (Storage::fake). rls-reviewer: PASS.
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>
2026-07-26 11:35:31 +03:00
Дмитрий f99bc54d6a feat(реклама): Часть 2 — смета показов (CampaignEstimateService + endpoint)
Часть 2 из 6. Живая смета медийной кампании «за показы»: из аудитории (окно
дней) и частоты → показы × 120₽/1000 = сумма клиенту (наценка не видна).

- CampaignEstimateService: size×частота=показы, сумма по ad_settings.client_cpm_rub
  через AdImpressionPricing (bcmath, вверх до копейки); порог MIN_AUDIENCE=100.
- AdvertisingCampaignController::audienceSize — при переданной frequency добавляет
  в ответ impressions/cpm_rub/cost_rub; без частоты ответ прежний (обратная совместимость).
- Аудитория (deals за N дней + свой список, дедуп, RLS) и порог 100 уже были в модуле.
- Тест сметы обёрнут в DatabaseTransactions (мутация глобальной ad_settings не утекает).

Тесты: 17/17 зелёные (вкл. проверку отсутствия утечки цены + регресс).
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>
2026-07-26 10:26:09 +03:00
Дмитрий c9443dca0c feat(реклама): Часть 1 — денежная модель кампании «за показы» (CPM)
Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания),
Часть 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>
2026-07-26 00:43:26 +03:00
Дмитрий a487c6602c fix(реклама Яндекс): чиню падение формы объявления на 422 и «тихий ноль» при загрузке номеров
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Ревью нашло: форма объявления (CreativeForm.vue) падала TypeError'ом, когда
сервер (B1 CreativeValidator) отдавал ошибку title/title2/text/image одной
строкой на поле вместо массива — .map() ломался на штатном 422. Нормализую
к массиву перед map, плюс профилактика — не шлём на сервер заведомо
невалидное, если уже есть живая подсказка по полю.

Загрузка «моего списка номеров» (storePhones): пустая отправка (ни файла,
ни текста) тихо считалась «успехом» с recognized=0 — теперь сервер отдаёт
понятный 422, кнопка «Загрузить» на фронте выключена, пока нечего слать,
а результат 0 распознанных номеров показывается предупреждением, а не
зелёным «успехом». Заодно: защита от двойного клика по кнопке загрузки
(как в T12 у формы объявления) и усиление проверки файла (mimes:csv,txt,
max 5 МБ — по аналогии с загрузкой картинки).

Мелкая чистка: осиротевший префилл form.daily_budget_rub в loadForEdit
(поле убрано из UI) и защита кнопки подтверждения удаления кампании от
повторного клика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 22:36:24 +03:00
Дмитрий cd77d32fc3 feat(реклама): удаление черновика кампании (T15) и загрузка своего списка номеров (T17)
DELETE /api/advertising/campaigns/{id} — удаляет кампанию только в статусе
draft своего тенанта (409 для запущенных/на паузе/др., 404 для чужого
тенанта); дочерние объявления и телефоны уходят каскадом на уровне схемы.

POST /api/advertising/campaigns/{id}/phones — принимает текстовое поле
или csv/txt-файл, номера построчно/через запятую нормализует через
PhoneNormalizer, невалидные отбрасывает, дубли схлопывает, пишет через
insertOrIgnore (unique tenant_id+campaign_id+phone), отвечает
{recognized, skipped}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:36 +03:00
Дмитрий 1baeae7f3a Merge branch 'feat/reklamnyy-koshelek-chast-A'
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-07-25 18:19:07 +03:00
Дмитрий 498f8cfd6d fix(портал продаж): вход только по токену — чужая web-сессия не перебивает
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.

Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 17:46:53 +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
Дмитрий cc43e696f6 feat(реклама): оплата картой ЮKassa зачисляет рекламный кошелёк (credit_target в settle; основной баланс без изменений) 2026-07-25 11:44:30 +03:00
Дмитрий 566028ed9d feat(реклама): админ-API расход/маржа по тенантам (ad_wallet_transactions charge) 2026-07-25 11:02:23 +03:00
Дмитрий bdc1d09475 feat(реклама): пауза/возобновление кампании клиента (Директ suspend/resume) 2026-07-25 10:26:08 +03:00
Дмитрий 60128d48d8 feat(реклама): расписание джобов Директа (аудитория 03:50, списание 04:20, модерация каждые 2ч, МСК) 2026-07-25 00:12:40 +03:00
Дмитрий 7e0b8e53aa fix(реклама): джобы Директа перечисляют кампании через pgsql_supplier (BYPASSRLS) + SET LOCAL для списаний — иначе на проде RLS даёт 0 строк (молчаливый сбой)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 00:07:45 +03:00
Дмитрий 314c8ce5a2 feat(реклама): API кампаний Директа (CRUD + счётчик аудитории + запуск + креатив) 2026-07-24 23:55:54 +03:00
Дмитрий 7f150922be feat(реклама): модерация (passthrough) + пауза кампаний по AdvertisingStopped 2026-07-24 23:44:21 +03:00
Дмитрий 7f4aca9cf1 feat(реклама): SyncCampaignAudienceJob — ночная replace-заливка аудитории кампании 2026-07-24 23:36:26 +03:00
Дмитрий 620cc2eeb1 feat(реклама): ChargeCampaignSpendJob — суточное списание расхода Директа (Cost×1.3) 2026-07-24 23:27:50 +03:00
Дмитрий ca42e3b3aa feat(реклама): CampaignLauncher — запуск кампании (сегмент→Директ→заморозка) 2026-07-24 23:19:23 +03:00
Дмитрий a7c1036c58 feat(реклама): CampaignAudienceBuilder — сбор телефонов кампании + счётчик 2026-07-24 22:53:36 +03:00
Дмитрий 696638e2fc feat(реклама): GRANT crm_admin_user на ad_* + модели кампаний 2026-07-24 22:46:03 +03:00
Дмитрий 57d5c3f2a3 feat(реклама): таблицы ad_campaign_ads и ad_campaign_phones с RLS 2026-07-24 22:38:32 +03:00
Дмитрий b1f58ec14b feat(реклама): таблица ad_campaigns — кампании клиента с RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:33:09 +03:00
Дмитрий cc4148d342 feat(реклама): пополнение рекламного кошелька по счёту (credit_target=advertising) 2026-07-24 21:26:37 +03:00
Дмитрий ea01e50a7a feat(реклама): письмо и флаг баннера при нехватке рекламного кошелька 2026-07-24 21:11:51 +03:00
Дмитрий f39bae16ef feat(реклама): AdWalletGate (платёжеспособность) + AdStopAll (событие стоп-всё) 2026-07-24 21:00:54 +03:00
Дмитрий 6832fba852 feat(реклама): charge — идемпотентное списание за рекламу из кошелька
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:55:03 +03:00
Дмитрий c41206121a fix(портал продаж): вход только по токену — чужая web-сессия не перебивает
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.

Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:53:24 +03:00
Дмитрий eb18491114 feat(реклама): freeze/release — заморозка резерва рекламного кошелька под кампанию
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:48:16 +03:00
Дмитрий 225b7d9207 feat(реклама): AdWalletService::topup — пополнение рекламного кошелька
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:40:41 +03:00
Дмитрий 647ac5b624 feat(реклама): модели AdWallet, AdWalletTransaction, AdWalletHold 2026-07-24 20:15:44 +03:00
Дмитрий 1ea6b951f3 feat(реклама): таблицы ad_wallet_transactions и ad_wallet_holds с RLS 2026-07-24 20:09:55 +03:00
Дмитрий 83117c921b feat(реклама): таблица ad_wallets — отдельный рекламный кошелёк тенанта с RLS 2026-07-24 20:01:05 +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