Commit Graph

113 Commits

Author SHA1 Message Date
Дмитрий 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
Дмитрий 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
Дмитрий ec6bdbfb19 feat(прогрев): разъезд API «Реклама на кандидатов» по площадкам {platform}
GET/PATCH /api/sales/warming/{platform}, GET .../firms, POST .../toggle
и .../assign заменяют общую /api/sales/ad-audience*; настройки и статус
площадки читаются из sales_ad_audience_platforms, членство фирмы —
по ch_yandex/ch_vk/ch_mts. Метод channels() (массовый мультивыбор)
убран — заменён точечным toggle. Неизвестная площадка → 404 через
platformOr404() в контроллере (не через route whereIn — иначе
непойманный сегмент проваливается в Route::fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:11:39 +03:00
Дмитрий a04662898e fix(sales): джоб Яндекса читает/пишет рубильник и сегмент из sales_ad_audience_platforms
Task 3 переезда на per-platform таблицу: SyncAdAudienceJob больше не трогает
sales_ad_audience_state.enabled/yandex_segment_id — источник теперь строка
площадки 'yandex' в sales_ad_audience_platforms (enabled/external_id/
last_synced_at/last_error). Поведение не изменилось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:01:44 +03:00
Дмитрий c6d9d24e4d feat(прогрев): таблица sales_ad_audience_platforms + модель площадки
Основа для разделения экрана "Реклама на кандидатов" на три
самостоятельные площадки (Яндекс/ВК/Телеграм). Новая таблица —
строка настроек на площадку (рубильник, порог запуска, синхронизация,
статус); колонки sales_ad_audience_state не удаляются — страховка
отката, как и channels в v8.80.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:50:48 +03:00
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и 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>
2026-07-21 06:35:43 +03:00
Дмитрий 54ffba360d fix(sales): AdAudienceIntake молча ронял ch_yandex/ch_vk/ch_mts
'channels' убрали из $fillable в Task 2 плана tri-ploshadki-i-mts, но
AdAudienceIntake остался класть его в атрибуты — Laravel тихо
выбрасывал ключ при mass assignment. Итог: каждая новая фирма из
«Поиска клиентов» получала ch_yandex=false/ch_vk=false/ch_mts=false —
не грелась нигде, без единой ошибки в логах.

Контракт принимает оба формата: новые булевы поля ch_yandex/ch_vk/
ch_mts (приоритет) и старую строку channels ('yandex'|'vk'|'both')
для совместимости с ещё не переехавшей Python-службой. Если не
пришло ни то, ни другое — ch_yandex=true (фирма отправлена в прогрев
осознанно, разумное умолчание — основная площадка).

Регресс-тест ловил баг до фикса: 3/3 новых теста падали с
"Failed asserting that false is true" на ch_yandex/ch_vk.
2026-07-20 08:31:50 +03:00
Дмитрий 27d0b70649 test(sales): заливки проверяются по трём площадкам 2026-07-20 08:13:09 +03:00
Дмитрий 991af98c97 feat(sales): три галочки площадок и выгрузка файла для МТС
У МТС Маркетолога нет программного доступа к рекламе в Telegram (только SMS
API) — портал отдаёт готовый текстовый файл с номерами, начальник грузит
руками. channels() переписан под три независимых булевых поля вместо одной
строки; firms() отдаёт три признака вместо старого 'channels'.

Выгрузка берёт только активные (state=active), неудалённые (removed_at IS
NULL) номера фирм с ch_mts=true — иначе в МТС накопятся погашенные клиенты
и придётся платить за показы им.

Task 3 плана docs/superpowers/plans/2026-07-20-tri-ploshadki-i-mts.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 08:07:46 +03:00