122 Commits

Author SHA1 Message Date
Дмитрий 7bd4de6800 docs(pilot): выкат итоговой проверки заказа + сторожа дедлайна (10.07) + урок про базу выката
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 06:00:14 +03:00
Дмитрий 8ccf5d955d fix(supplier): миграция supplier_order_checks — pgsql_supplier + инлайн-GRANT crm_supplier_worker
По rls-ревью: соседние SaaS-supplier-таблицы (supplier_sync_runs/deferred_sync)
создаются через pgsql_supplier + GRANT SELECT,INSERT + USAGE на sequence для
crm_supplier_worker в DO-блоке с IF EXISTS. Дефолтный Schema::create оставлял
таблицу во владении crm_migrator без грантов → VerifySupplierOrderJob упал бы
permission denied на боевом Managed PG. Приведено к паттерну + SaaS-коммент + CHECK status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 05:16:08 +03:00
Дмитрий 1db9cca989 chore(security): allowlist проверенных ложняков gitleaks/lychee (379→0)
gitleaks full-history давал 379 находок, ВСЕ ложняки (проверено):
- 360 RU-телефонов в тест-фикстурах 2ГИС, демо bylo-stanet, findings-доках, autopodbor-превью;
- 18 «api-key» = публичные фронт-токены 2ГИС в захваченных HTML-фикстурах;
- 1 «webhook-secret» = плейсхолдер в .env.example (шаблон);
- 2 = демо-номер Красноярска из docblock удалённого HtmlPhoneScanner (в текущем коде нет).
Дописаны path/regex allowlist (те же категории, что уже были: tests/seeders/factories/docs).

lychee: «support@liderra.ru» в кавычках-ёлочках ошибочно резолвился как file://-путь — исключён.

Настоящих утечек не было; история не переписывалась. Мои коммиты фичи gitleaks-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:58:47 +03:00
Дмитрий fd4aee9a8f docs(supplier): спека + план итоговой проверки заказа и сторожа дедлайна
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:43:43 +03:00
Дмитрий 3a5d721478 fix(supplier): правки по код-ревью — матч наших строк по external_id, shouldBeOff без активных, sync_run_id
- normalizeLive матчит наши строки по supplier_external_id (tag=регион, не _lidpotok) — иначе live всегда пуст
- shouldBeOff исключает ключи, активные по формуле (нет ложного should_be_off)
- recordRunSummary → insertGetId, проверка получает sync_run_id
- обновлён phpstan-baseline.neon: count mock() 2→3 (новый тест на баг 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:55:47 +03:00
Дмитрий d94594def8 feat(supplier): сторож дедлайна 20:00/20:40 — письмо если робот не успел
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:36:16 +03:00
Дмитрий 46e8a907b5 feat(supplier): робот запускает итоговую проверку заказа после прогона
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:27:27 +03:00
Дмитрий f29847281b feat(supplier): VerifySupplierOrderJob — итоговая проверка заказа с перепроверкой
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:17:46 +03:00
Дмитрий 0004961bce feat(supplier): письма о расхождении заказа и о срыве дедлайна
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:07:43 +03:00
Дмитрий dffd640aa9 feat(supplier): SupplierOrderPlan::build — задуманный заказ по формуле
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:04:02 +03:00
Дмитрий 8f0f3ccf61 feat(supplier): SupplierOrderVerifier::diff — чистая сверка заказа с живым кабинетом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:58:42 +03:00
Дмитрий d4e055d4f5 feat(supplier): таблица supplier_order_checks — история итоговой проверки заказа
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:55:09 +03:00
Дмитрий fafb0f9b52 chore(larastan): подавить ложный andThrow method.notFound в CsvReconcileJobTest
Хелпер fakeDeliveredThrows (добавлен в 121c061e) вызывал Larastan-ложняк
'undefined method ...andThrow()' на union-типе shouldReceive(), не забаселайненный
и не подавленный — блокировал pre-commit larastan для ЛЮБОГО коммита. Точечный
@phpstan-ignore-next-line как в других supplier-тестах. Поведение теста не меняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:54:03 +03:00
Дмитрий 54053ce38b docs(runbook): yookassa-enable — пометка что sudo -u postgres на VM = мёртвая копия (Managed PG)
Боевые шаги ранбука (флаг/данные через sudo -u postgres psql) после переезда 26.06
бьют в старую копию. Добавлен красный баннер вверху + правка Troubleshooting: данные/
миграции — через app/Eloquent или crm_migrator на живом кластере (§6b пайплайна).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:11:28 +03:00
Дмитрий a402e146b6 docs(deploy): README — миграции на живой Managed-кластер (не мёртвая VM-копия), портал lk, выкат из main
Вторая живая деплой-инструкция с той же миной (после 06-18 runbook): §миграций
велела sudo -u postgres на VM = мёртвая копия. Исправлено на crm_migrator/Managed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:17:15 +03:00
Дмитрий ff436f3f8d docs(runbook): выкат — миграции на ЖИВОЙ Managed-кластер (не мёртвая VM-копия), катим из main, smoke по lk
Исправлены опасные устаревшие шаги: §0/§6b/§8 велели миграции через sudo -u postgres
на VM = мёртвая rollback-копия (после переезда 26.06 боевая = Managed PG). Теперь —
crm_migrator на rw-endpoint кластера. Плюс: катим из main (=прод с 09.07), хирургический
overlay, smoke по lk.liderra.ru, квирк 107 (optimize только www-data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:03:34 +03:00
Дмитрий 0c72b31d30 docs(pilot): порядок в репо 09.07 — off-site git-бэкап, main=прод, чистка веток
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:39:09 +03:00
Дмитрий a2a22b34a2 docs(pilot): выкат пакетного сбора (шаг 2) + монитор свежести YC-бэкапа 09.07
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:05:15 +03:00
Дмитрий 8e2d749c97 docs(handoff): CSV-reconcile vid-fix — прод-выкат, уборка kdv1, анти-затирание 2026-07-09 09:08:17 +03:00
Дмитрий 121c061e82 fix(supplier): CSV-reconcile сверяет журнал ОТДАННОГО (по vid), не пул
Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос
номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил
из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15,
у клиента 15 фантомов, а 15 реально отданных выбило лимитом.

Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки»
(index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid,
добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook).
Пул не трогается -> фантомы невозможны, лимит не забивается мусором.

Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и
проверено на проде (reconcile читает отданное, 0 фантомов за прогон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:56:44 +03:00
Дмитрий 68958ebf33 docs(autopodbor): HANDOFF-STATE пакетного сбора источников — готово в ветке, ждёт выката
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:54:45 +03:00
Дмитрий 0035dd4fae docs(autopodbor): план — вписаны находки Задачи 0 (pgsql_supplier, web.php, FieldWorkspaceScreen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:10:36 +03:00
Дмитрий b2da19c8d0 docs(autopodbor): план реализации пакетного сбора источников + честной очереди
TDD-задачи 0-9: миграция batch_id, AutopodborBudgetGate, AutopodborStudyScheduler,
startStudyBatch, tick в джобе, крон-страховка, endpoint, фронт-подвал, верификация.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:02:25 +03:00
Дмитрий 7bc686da23 docs(autopodbor): спек пакетного сбора источников (шаг 2) + честная очередь между клиентами
Дизайн из брейншторма с владельцем: кнопка в подвале выбора, пакет из
группы конкурентов, одна дорожка + round-robin между тенантами,
денежный гейт всё-или-ничего с резервом под активные лид-проекты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:51:24 +03:00
Дмитрий b35d3888bb fix(admin): клиентский диапазон ступеней в тарифной сетке + живой предпросмотр в редакторе
Админка показывала сырую ширину ступени (leads_in_tier: 250/500/1000...),
а кабинет клиента — накопительный диапазон (1-250, 251-750...). Одни и те же
данные, но выглядят как разные тарифы; в форме редактирования подпись
'Лидов в ступени' читается как потолок, а система понимает её как ширину -
ловушка при смене цен.

Логика денег не менялась. Изменён только показ в админке:
- колонка 'Диапазон у клиента' в активной сетке;
- живой предпросмотр диапазона в форме редактирования;
- расчёт диапазона вынесен в общий utils/pricingTiers.ts, чтобы админка
  и кабинет больше не расходились.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:10:08 +03:00
Дмитрий 78115ead64 feat(ad-scan): волна 3 «лист обзвона» + классификатор телефонов Россвязи
- rossvyaz.py: тип/оператор/регион/часовой пояс по реестру Россвязи (бесплатно, офлайн);
  детект «виртуальный (переадресация)» — городской на мобильном операторе/вирт.АТС.
- wave3.py: зацепка, приоритет, личные почты и мессенджеры с сайта, лист «Памятка менеджеру».
- phones_rossvyaz.py: разбор телефонов рекламодателей, «есть прямой канал».
Файлы реестра Россвязи и локальный тест — вне git. Рунбук обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:11:19 +03:00
Дмитрий d4bbdbdb50 docs(legal): Яндекс.Метрика/Вебвизор в политике конфиденциальности (ПДн маскируются)
Раздел «5. Хранение и защита»: упоминание записи сессий Вебвизором с маскировкой
персональных данных контактов, обработка на серверах в РФ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:46:57 +03:00
Дмитрий 493dd68c78 feat(metrika): ym-disable-keys — поля ввода телефона/имени в кабинете (диалог+фильтр)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:45:39 +03:00
Дмитрий 73467a33a0 feat(metrika): маскировка ym-hide-content — имя+телефон в карточке сделки и канбане
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:45:21 +03:00
Дмитрий d131e84c8f feat(metrika): маскировка ym-hide-content — телефон+комментарий в таблице сделок; телефон убран из aria-label (id вместо ПДн)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:45:00 +03:00
Дмитрий 2d3e16efd1 feat(metrika): hit только для маршрутов кабинета (layout=app)
router.afterEach: при входе на layout=app лениво грузит Метрику и шлёт hit(fullPath).
Вход/публичные/админка/портал продаж — без Метрики. + убраны лишние @ts-expect-error
в тесте (type-check чист).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:38:45 +03:00
Дмитрий a072325149 feat(metrika): ленивый загрузчик + hit с идемпотентностью (TDD, 3 теста)
metrika.ts: читает id из <meta name=metrika-id>, один раз инжектит tag.js + init
webvisor:true (defer), hit(url) шлёт заход. Тесты tests/Frontend/metrika.spec.ts:
no-op без meta, идемпотентная загрузка, hit вызывает ym с id+url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:35:44 +03:00
Дмитрий 6cfe06fb4c feat(metrika): конфиг счётчика портала + meta в HTML-shell (условно)
По образцу JivoSite: config services.metrika.counter_id из env METRIKA_COUNTER_ID;
welcome.blade вставляет <meta name=metrika-id> только когда id задан. Скрипт грузится
лениво из роутера (кабинет), не тут — Вебвизор не пишет вход/админку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:32:57 +03:00
Дмитрий dcfc987dc7 docs(plan): пошаговый план Метрика+Вебвизор на портале (8 задач, маскировка ПДн, gate)
Реализация дизайна 2026-07-08: конфиг счётчика (образец JivoSite), ленивый
загрузчик metrika.ts (TDD), hit только для layout=app, маскировка ym-hide-content/
ym-disable-keys в компонентах кабинета, строка в политику, CSP-правка блока
приложения + выкат с обязательной проверкой маскировки ПДн на стенде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:06:44 +03:00
Дмитрий 6f4efaaa9d docs(spec): дизайн Метрика+Вебвизор на портале (кабинет клиента, маскировка ПДн)
Утверждённый с владельцем дизайн: отдельный счётчик для lk.liderra.ru, запись
только в кабинете клиента, маскировка личных данных (Вариант 1), CSP-правка блока
приложения, строка в политике конфиденциальности. Выкат через тестовый стенд +
проверка маскировки как gate. Следующий шаг — план работ (writing-plans).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:53:35 +03:00
Дмитрий 757b55e571 fix(landing): CSP child-src/frame-src blob: — Вебвизор не писал сессии
Симптом: отчёт «Посетители» полон, а Вебвизор пуст даже за дни с заходами.
Причина: строгий CSP резал скрытый blob-frame Вебвизора (базовый счётчик через
img-пиксель проходил → «Посетители» есть). Fix на боевом (nginx liderra.ru,
блок лендинга): +child-src/frame-src 'self' blob: https://mc.yandex.ru +домены
Метрики в frame-ancestors. Проверено: заголовок отдаётся, / и /login = 200.
Бэкап /etc/nginx/liderra.bak-webvisor-20260708.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:28:46 +03:00
Дмитрий 9af30b2da8 docs(пилот): вечерний выкат 07.07 — сторож поставщиковых тревог понимает режим
Потоп ~116 писем на ops@ убран: FailoverProjectChannel без per-project писем +
Блок 6 spike-детектор в incidents:watch-failures. На проде lk.liderra.ru
(gitea 316bc2d8+9799c0eb). Подчищены 57 stale строк очереди + 1 autopodbor-джоб.
cspell: +непроливший, +флайт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:50:55 +03:00
Дмитрий 65b4e1c3df feat(ad-scan): робот run_spark.py — Спарк-выгрузка → готовый файл рекламодателей
Единая команда: скан сайтов + Директ (Москва+город объединением) + вилка бюджета
+ все реквизиты Спарка (ОГРН, директор, ИНН директора, адрес, email, совладельцы).
Чекпоинты, чистка агрегаторов/дублей, .txt-сводка для приёмки. Рунбук приложен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:17:07 +03:00
Дмитрий 74d4d9f21d feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Блок 6: если за окно (10 мин) в supplier_manual_sync_queue упала пачка
(>= threshold-manual-queue, дефолт 10) — вероятное падение кабинета поставщика.
Инцидент дедупится 60 мин, письмо шлётся каждый прогон на kdv1@bk.ru + ops@liderra.ru
(громко при реальной аварии, но одним сводным письмом). Флаг sendMail в createIncident.
phpstan-baseline: +4 TestCall::artisan() (5→9) от новых тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:07:41 +03:00
Дмитрий 72818f5a80 fix(supplier): FailoverProjectChannel не шлёт per-project CRITICAL письма
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:01:19 +03:00
Дмитрий f113c991dc docs: план реализации сторожа поставщиковых тревог, понимающего режим
3 задачи TDD: (1) FailoverProjectChannel без per-project писем,
(2) spike-детектор ручной очереди в incidents:watch-failures,
(3) финальная проверка + pint/stan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:40:42 +03:00
Дмитрий 87962d0e0b docs: спека сторожа поставщиковых тревог, понимающего режим
Устраняет потоп ~116 писем на ops@ 07.07: убираем per-project письма
FailoverProjectChannel, добавляем аварийный spike-детектор ручной очереди
в incidents:watch-failures. batch остаётся рабочим режимом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:36:06 +03:00
Дмитрий 5755ad5046 feat(ad-scan): поиск рекламы по городам + вилка бюджета + чекпоинты
- per-city: карта город→база Keys.so (regions.py), обработка HTTP 202 (прогрев региона)
- бюджет вилкой: budget_min (average) .. budget_max (bdg100)
- параллельные запросы к API + сохранение чекпоинтами + видимый прогресс
- finalize.py: объединение баз Москва+город, чистка агрегаторов/дублей, медиана
Тесты 20/20. Токен и данные — вне git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:03:52 +03:00
Дмитрий 6560777b10 docs(портал-мобилка): план Этапа 1 — каркас + карточки Сделок
TDD-план на 9 задач: worktree от worktree-avtopodbor (там есть «Поле»),
useLayoutTier, AppBottomNav, AppMoreDrawer, rail-сайдбар, ярус-отступ AppLayout,
ResponsiveTable и пруф на «Сделках». Экраны Этапов 2-3 — отдельными планами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:03:31 +03:00
Дмитрий 29660698b1 docs(портал-мобилка): спека адаптива основного портала под телефон и планшет + аудит реальных экранов
Брейншторм с владельцем 07.07. Рамки: основной портал 9 экранов, телефон+планшет,
премиум-уровень, мобилка урезанная / десктоп полный. Подход А — сначала каркас, потом экраны.
Меню на телефоне: Поле, Проекты, Сделки, Биллинг, Канбан. База — ветка с «Полем» вне main.
Аудит: 4 реальных архетипа поломки сняты локально на 390px [findings/2026-07-07-portal-mobile-audit].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:49:54 +03:00
Дмитрий 590849391d fix(landing): мобилка — звезда не засвечивает заголовок, лого убрано, вход и CTA разведены
- на телефоне текст героя опущен ниже padding-top 22vh, свечение звезды мягче x0.68
- убран логотип ЛИДЕРРА из шапки сверху слева
- Вход ушёл в левый край, Попробовать бесплатно — в правый

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:37:36 +03:00
Дмитрий 6656d78244 docs(ПИЛОТ): Яндекс.Метрика на лендинге (счётчик 110476275, цель GO_PORTAL)
Снимок боевого: лендинг liderra.ru теперь со счётчиком Метрики, строгий фильтр
роботов ВКЛ, цель GO_PORTAL на CTA. CSP блока лендинга дополнен mc.yandex.ru.
+ Вебвизор в cspell-words.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:34:45 +03:00
Дмитрий a8afd88413 feat(landing): счётчик Яндекс.Метрики 110476275 + цель GO_PORTAL на CTA-кнопках
- счётчик Метрики вставлен в <head> лендинга landing-v5-claude.html
- на 3 кнопки перехода на портал повешен reachGoal GO_PORTAL (JS-событие)
- ранбук выката: CSP блока лендинга дополнен https://mc.yandex.ru
  в script-src/img-src/connect-src — иначе строгий CSP блокирует счётчик

Выкачено на боевой liderra.ru 07.07: счётчик проверен живьём (заход уходит
в Яндекс), вход в кабинет /login не сломан. Цель GO_PORTAL создана в Метрике
(ID 579585431).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:31:28 +03:00
Дмитрий ccb539fed6 docs(pilot): снимок боевого 07.07 — лендинг liderra.ru, портал lk, апгрейд VM
- liderra.ru=лендинг / lk.liderra.ru=портал (nginx, CSP, LE-серт)
- APP_URL/ЮKassa/поставщик → lk; VM 12c/36G/80G; статик-IP render
- фикс полос на фоне (sin-hash→fract); откаты и остаток зафиксированы
- cspell: +fract/дизеринг/квинтическая

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:13:54 +03:00
Дмитрий c458dad757 feat(ad-scan): инструмент поиска платной рекламы по выгрузке Спарка
Пилот по стоматологиям: скан кода сайтов (коллтрекинг Calltouch/Comagic/
Callibri/Roistat/Mango + пиксели VK-myTarget/Google Ads/Meta) плюс Яндекс.Директ
через Keys.so API (ключи, объявления, бюджет), склейка в Excel. Две фазы:
сайты параллельно, затем чистый проход по API с повторами. Тесты 16/16.
Спека и план — docs/superpowers. Данные Спарка, результаты и токен Keys.so —
вне git (личная папка + .gitignore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:11:14 +03:00
Дмитрий c8b683dad9 fix(landing): убраны «прямые полосы»/гранёность на фоне (артефакт GPU)
Корень — хэш шума на sin(dot(...)): на аппаратных видеокартах терял точность
и давал угловатые фасетки/полосы (в софт-рендере headless не воспроизводилось).
- hash: sin(dot) → устойчивый fract-хэш (Dave Hoskins), без sin
- fbm: поворот домена на каждом слое (ломает осевую сетку) + 6-й слой
- noise: интерполяция Hermite → квинтическая (гладкие стыки ячеек)
- дизеринг: sin-hash → IGN
- grain SVG: stitchTiles='stitch' (бесшовный тайл)
Выкачено на боевой liderra.ru; владелец подтвердил — фон чистый.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:53:54 +03:00
Дмитрий 9a431baf43 docs(landing): ранбук выката — фазы 1–2 сделаны, фаза 3 (APP_URL→lk) сделана
- зафиксирован итог боевого выката 07.07: liderra.ru = лендинг,
  lk.liderra.ru = портал (свой SSL/nginx), APP_URL=lk, sanctum += lk
- бэкапы (nginx-конфиг, .env, снимок диска) и апгрейд сервера 12c/36G/80G
- остаток фазы 3 — внешнее (ЮKassa webhook, вебхук поставщика),
  служебные пути liderra.ru не убирать до этого

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:34:13 +03:00
Дмитрий 21b95f1752 feat(landing): мобильные пропуски between-block + ранбук боевого выката
- flat/мобилка: высота corridor-секций и hero/final = auto (сбрасываем 3D-высоту
  460/360/420vh), убраны пустые контейнеры свечения → страница 12154→~4900px,
  разделы идут плотно (проверено на 390px)
- добавлен ранбук выката на боевой liderra.ru (2 фазы: lk.liderra.ru = портал,
  liderra.ru = лендинг; факты сервера, CSP для inline-скрипта, откат)
- ВЫКАЧЕНО на боевой 07.07: liderra.ru = лендинг, lk.liderra.ru = портал

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:26:41 +03:00
Дмитрий 38c94181f8 feat(landing): кнопка «Вход в личный кабинет» в шапке + провязка на портал
- «Вход в личный кабинет» (nav a.navlogin) ПЕРЕД «Попробовать бесплатно»,
  ghost-стиль; на мобиле сокращается до «Вход» (.lg-full/.lg-short)
- секции получили класс navsec (скрыты на мобиле), логин остаётся
- обе кнопки шапки + финальная CTA ведут на https://lk.liderra.ru/
  (адрес-заглушка, подтвердить у владельца); у #cta убран preventDefault
- флоу: liderra.ru = лендинг, портал → lk.liderra.ru

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:47:07 +03:00
Дмитрий f35e0af218 feat(landing): мобильная версия — вертикальная раскладка + живой фон
- телефон (≤760px) → flat-режим: секции в столбик, chap-cap сверху (order:-1),
  карточки/чипы полной ширины; размеры/отступы под мобилку, «листайте» скрыт
- фон анимируется через лёгкий flatLoop (bg-only, цвет по общему скроллу,
  портал у верха/низа)
- шапка: текст-меню скрыто, яркая кнопка «Попробовать бесплатно» остаётся
- fix: старый баг flat-режима — блок стартовал до объявления canvas-переменных
  (init2D падал на undefined.style); перенёс перед boot
- проверено на 390×844: hero/поле/поток/CRM/финал, 0 ошибок консоли

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:18:20 +03:00
Дмитрий 83f72aaa3e feat(landing): раздел «Поток/Без эха» (дубли 30 дней) + CTA в шапке
- новый экран между «Конкурентным полем» и CRM: «Один номер — один раз»,
  летящие плашки-номера (новые светятся, дубли гаснут/зачёркнуты),
  подпись конкурента-источника, живой счётчик, зелёно-мадженовая глава
- перенумерация глав 01–04, пункт меню «Поток»
- «Проба» → яркая кнопка-пилюля «Попробовать бесплатно»
- красный пик палитры остаётся на «Успехе» (crmP≈0.713, проверено)
- передача 2026-07-07-HANDOFF-landing-v5.md обновлена

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:20:42 +03:00
Дмитрий c931103947 docs(webmaster): исследование ИИ-наблюдателя портала — источники и протокол
Пласт исследования про ИИ-вебмастера портала: наблюдаемость,
self-healing, ИИ-техподдержка, выбор LLM-моделей, идемпотентность в
multitenant. Семь протокол-документов плюс исходники Perplexity.

NB: LEFTHOOK_EXCLUDE=cspell,markdownlint — сырой внешний исследовательский
материал с чужой терминологией 249 незнакомых слов и 971 стилевой придиркой
markdownlint; орфо-словарь и стиль-линт проекта им не засоряем. Ложная
находка gitleaks Idempotency-Key в allowlist. Прочие проверки прошли.
User-approved bypass для этого коммита.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 20:47:35 +03:00
Дмитрий 206ad9b0a4 docs(ux): косяки портала глазами пользователя — 7 находок
Структурированный разбор недочётов портала поставщика при взгляде
глазами обычного пользователя: баланс и тариф-блокировка, формат
телефона источника, регион вся РФ, потеря черновика реквизитов, вход
до подтверждения почты, обращение на ты в блокировке, пустые состояния
онбординга. Каждый раздел — ОПИСАНИЕ.md плюс скрины до и после.

Словарь cspell пополнен доменными словами.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 20:42:58 +03:00
Дмитрий 412700f97e chore(gitignore): спрятать рабочий мусор сессий
Скриншоты проверок в корне, личные файлы, IDE-заглушки и локальные
демо-данные больше не висят в git. Файлы остаются на диске.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 20:38:58 +03:00
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный
оклад daily_salary и процент от пополнений topup_step. Убраны
percent_oborot и fix_per_client.

Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает
FK sales_users.current_tariff_id и sales_client_assignments.tariff_id,
удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов
возвращает 0 по default-ветке. Снимки привязок не трогаются.

Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics,
фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта,
CHANGELOG схемы v8.61 и спека портала.

Не на проде: ветка feat/sales-portal-demo.

Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 19:25:19 +03:00
Дмитрий 5a590259fd feat(sales): демо-сид портала продаж (dev/test only)
Task 8.2 — SalesDemoSeeder: начальник Орлов + 3 менеджера (Лебедев в
отпуске) + 4 тарифа (3 вида) + привязки (к существующим тенантам,
round-robin, снимок тарифа) + выплаты как в прототипе.
- Защита от прода: throw если app env = production.
- Идемпотентно (firstOrCreate/updateOrCreate; выплаты только если их нет —
  append-only). DatabaseSeeder не трогается — запуск вручную
  `php artisan db:seed --class=SalesDemoSeeder`.

NB: запускать в dev-БД, НЕ в liderra_testing (портит baseline dept-count
тестов). Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:56:07 +03:00
Дмитрий ba7edaab7b refactor(sales): единый трейт DerivesTenantStatus (DRY)
Производный статус тенанта (trial>suspended>overdue>active) был скопирован
в 3 контроллерах портала продаж. Вынесен в
App\Http\Controllers\Concerns\DerivesTenantStatus; контроллеры
(Clients/Dashboard/Managers) теперь используют трейт. Поведение не
изменено — sales-набор 28/28 затронутых, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:46:39 +03:00
Дмитрий 56ffdef079 feat(sales): экран «Менеджеры отдела» (фронт)
Task 7.1b — SalesManagersView (начальник, #page-managers) поверх
GET/POST /api/sales/managers.

- Форма «Новый менеджер» (имя, e-mail, пароль, роль) → POST → snackbar +
  очистка + перезагрузка; ошибки (дубль email) через extractSalesErrorMessage.
- Список менеджеров: роль-чип, клиентов, выплачено всего, статус
  (Активен/Отпуск).
- api/sales.ts: listSalesManagers/createSalesManager. Роут /sales/managers
  со заглушки на экран.

Vitest 6/6, фронт-набор 1089 без регрессий, ESLint чист.

Фаза 7 завершена: начальник создаёт менеджеров (логин+пароль) и видит
список отдела.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:40:59 +03:00
Дмитрий fb9e5b581d feat(sales): создание менеджеров начальником + список (бэк)
Task 7.1a — SalesAccountService + store/index в SalesManagersController,
только head.
- store: создаёт sales_user (пароль Hash::make, created_by=head,
  base_salary=0); email unique → 422; role in manager|head; password min:6;
  в ответе нет password ($hidden).
- index: список всех sales_users (head-первыми) с clients_count и
  выплачено всего.
Менеджер не может создавать/смотреть список (403).

Тесты 7/7 (вкл. проверку хеша пароля), sales-набор 174/174, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:34:47 +03:00
Дмитрий 64d10db20e test(sales): сквозной период — метрики зависят от периода, баланс нет
Task 6.3 — период уже проброшен во всех контроллерах (SalesPeriodResolver)
и экранах (salesPeriod store + watch). Добавлен проверочный тест на
/api/sales/overview: period=this даёт оборот 3000/лид, period=prev — 5000/лид,
а balance_sum_rub одинаков (1000) — баланс не зависит от периода.

Фаза 6 завершена: начальник видит весь отдел; период переключается везде.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:26:51 +03:00
Дмитрий 709478230f feat(sales): результативность менеджеров (фронт)
Task 6.2b — SalesPerformanceView (начальник, #page-performance) поверх
GET /api/sales/managers/performance.

- Поиск по имени (debounce) + таблица: менеджер (имя/email), клиентов,
  активных, лидов пришло, оборот, выплачено, заработал, статус
  (Активен/Отпуск). HelpHint на Оборот/Заработал. Период из salesPeriod store.
- Роут /sales/performance со заглушки на экран.

Vitest 5/5, фронт-набор 1083 без регрессий, ESLint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:23:46 +03:00
Дмитрий 2b6ab45eda feat(sales): результативность менеджеров (бэк)
Task 6.2a — SalesManagersController@performance, только head.
По каждому менеджеру за период: клиентов, активных клиентов (derived
status), лидов пришло, оборот клиентов, выплачено всего, заработал
(forManager), статус (active/vacation по is_active). Фильтр search по имени.

Тесты 4/4, sales-набор 166/166, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:16:56 +03:00
Дмитрий 7cdbbdae46 feat(sales): сводка отдела (фронт)
Task 6.1b — SalesBossOverviewView (начальник, #page-boss-overview) поверх
GET /api/sales/dashboard/overview.

- 5 KPI (менеджеров с active/vacation, клиентов, Σ баланс, оборот отдела,
  выплачено за период).
- 4 кликабельные плитки-алерта → переходы: счета→/sales/invoices,
  заявки→/sales/requests, проблемы баланса→/sales/performance,
  провести выплату→/sales/payouts.
- Мини-таблица результативности менеджеров (клиенты/лиды/оборот/выплачено/
  заработал) с HelpHint. Период из salesPeriod store.
- Роут /sales/boss со заглушки на экран.

Vitest 12/12, фронт-набор 1078 без регрессий, ESLint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:07:10 +03:00
Дмитрий 8778f41a12 feat(sales): сводка отдела (бэк)
Task 6.1a — SalesDashboardController@overview, только head.
- kpi: менеджеров (активных/в отпуске по is_active), клиентов закреплено,
  Σ баланс, оборот отдела за период, выплачено за период.
- alerts: счетов ждут оплаты (issued/overdue), заявок pending, клиентов
  с проблемой баланса (overdue/suspended/runway=0).
- performance: по каждому менеджеру — клиентов, лидов, оборот, выплачено
  всего, заработал (forManager за период).
Департамент-wide агрегаты; kopeck-точный оборот; выплаты по закрытому
интервалу paid_on.

Тесты 4/4, sales-набор 162/162, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:58:55 +03:00
Дмитрий 04d2346754 feat(sales): экран «Счета» — список + отметка оплаты (фронт)
Task 5.1b — SalesInvoicesView (начальник, #page-invoices) поверх готовых
эндпоинтов /api/sales/invoices.

- Таблица счетов (дата/номер/клиент/плательщик/сумма/статус/оплатить до),
  поиск с debounce + фильтр статуса, чипы статусов.
- Кнопка «Отметить оплаченным» (только issued/overdue) → диалог
  подтверждения → POST mark-paid → snackbar + перезагрузка. Зачисление
  баланса и Акт формирует бэк (InvoicePaymentService).
- api/sales.ts: listSalesInvoices/markSalesInvoicePaid. Роут /sales/invoices
  со заглушки на экран.

Vitest 5/5, фронт-набор 1066 без регрессий, ESLint чист.

Фаза 5 завершена: начальник отмечает оплату счёта прямо в портале продаж.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:45:17 +03:00
Дмитрий 72f6a6ae89 feat(sales): счета — список + отметка оплаты (бэк, переиспользование)
Task 5.1a — SalesInvoiceController (index, markPaid), только head.
- index: список saas_invoices (структура 1:1 с AdminInvoiceController —
  фильтры status/search, пагинация).
- markPaid: делегирует InvoicePaymentService::markPaid (идемпотентный
  claim issued→paid + зачисление баланса + акт + письмо) — логика НЕ
  дублируется.
Head-гейт перед обоими методами (менеджер → 403 до обращения к сервису).

Тесты 7/7 (вкл. пополнение баланса и идемпотентность повторной отметки),
sales-набор 158/158, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:38:33 +03:00
Дмитрий d5112bd375 feat(sales): экраны выплат и «Мой доход» (фронт)
Task 4.2b — два экрана поверх готовых эндпоинтов.

- SalesPayoutsView (начальник, #page-payouts): форма «Провести выплату»
  (менеджер из remaining, сумма, дата, комментарий) → POST /payouts →
  snackbar + перезагрузка; таблица «Остаток к выплате по менеджерам»
  (начислено/выплачено/остаток за период); «Журнал всех выплат».
- SalesIncomeView (менеджер, #page-income): 4 KPI (оборот/начислено/
  выплачено всего/к выплате), таблица «Начислено по клиентам» с ИТОГО,
  «Журнал выплат мне» с ИТОГО.
- api/sales.ts: listSalesPayouts/getSalesPayoutsRemaining/createSalesPayout/
  getSalesIncome. Роуты /sales/payouts и /sales/income со заглушек на экраны.

Vitest 9/9, фронт-набор 1061 без регрессий, ESLint чист.

Фаза 4 завершена: начальник проводит выплаты, менеджер видит начисления
и выплаты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:29:32 +03:00
Дмитрий c77495af21 feat(sales): сводка «Мой доход» менеджера (бэк)
Task 4.2a — SalesIncomeController@show для авторизованного менеджера:
- totals: оборот клиентов (Σ), начислено за период (forManager),
  выплачено всего, к выплате за период (остаток начислено−выплачено).
- per_client: строки «Начислено по клиентам» (организация, тариф,
  пополнения, заработано) по снимкам тарифов.
- payouts: журнал СВОИХ выплат.
Head без клиентов → нули. Reuse earnings/metrics/payout сервисов.

Тесты 6/6, sales-набор 151/151, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:19:46 +03:00
Дмитрий 0426735ea6 feat(sales): выплаты — проведение, журнал, остаток
Task 4.1 — SalesPayoutService + SalesPayoutController.

- record: append-only запись в sales_payouts + письмо менеджеру
  (SalesPayoutRecordedMail). Журнал неизменяем (DB-триггер
  sales_payouts_no_mutate — покрыто тестами на UPDATE/DELETE → QueryException).
- remaining (head): по каждому менеджеру за период — начислено
  (forManager), выплачено за период (по дате paid_on), выплачено всего,
  остаток = начислено − выплачено(период); переплата не зажимается в 0.
- index: менеджер видит только свои выплаты, начальник — все.
- POST /payouts и /payouts/remaining — только head; валидация суммы/даты/
  менеджера.

Тесты 15/15 (вкл. append-only через savepoint), sales-набор 145/145,
Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:10:45 +03:00
Дмитрий dfb41751c8 feat(sales): показ дохода «Заработал» в списке/карточке/сводке (фронт)
Task 3.3b — три экрана Фазы 1 показывают живой earned_rub вместо
заглушки «—»:
- «Мои клиенты»: колонка «Заработал» = комиссия по клиенту (или «—»
  без привязки).
- Карточка клиента: KPI «Вы заработали (период)».
- Сводка: KPI «Я заработал» = суммарная комиссия за период.
Тип earned_rub расширен до number|null. Vitest-моки/ассерты обновлены
под реальные значения. Фронт-набор 1052 зелёных, ESLint чист.

Фаза 3 завершена: доход менеджера считается по тарифу каждого клиента
и виден во всех экранах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:50:24 +03:00
Дмитрий 3b8694cd4c feat(sales): живой доход по клиентам в списке/карточке/сводке (бэк)
Task 3.3a — SalesClientsController наполняет earned_rub через
SalesEarningsService (был null-заглушкой):
- index: доход по каждому клиенту за период по снимку тарифа привязки;
  клиент без привязки (начальник видит незакреплённых) → null.
- show (kpi): доход по одному клиенту.
- overview (totals): сумма комиссии по всем привязкам в scope (без оклада).

Тесты Фазы 1 обновлены под реальные значения (percent_oborot 10/20% от
засеянного оборота → 500/5/10000 ₽; привязка без тарифа → 0.0; без
привязки → null). Попутно исправлен ключ params тест-тарифа
(percent→rate, движок читает rate). Sales-набор 132/132, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:32:00 +03:00
Дмитрий bf5639486e feat(sales): экран «Тарифы менеджеров» (фронт)
Task 3.2b — SalesTariffsView поверх готового каталога тарифов.

- 3 семейства-конструктора: «процент от пополнений» (ступени from–to–%,
  +добавить период), «оклад+процент» (оклад→params.base_salary, ставка→
  params.rate), «фикс за клиента» (порог/вознаграждение). Создание — POST,
  правка — PUT.
- Секция «Текущий тариф менеджера»: select тарифа + оклад (для оклад+процент),
  метрики периода и оценка «Начислено» (estimated_earned_rub); сохранение —
  assign. Оклад уходит в base_salary_rub только для percent_oborot.
- Период из salesPeriod store, перезагрузка по смене. Роут /sales/tariffs
  переключён со заглушки.

Vitest 9/9, полный фронт-набор без регрессий, ESLint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:10:20 +03:00
Дмитрий c7d1e1751a feat(sales): каталог тарифов + назначение текущего (бэк)
Task 3.2a — SalesTariffController (index/store/update/assign), только head.

- index: активные тарифы (3 вида) + менеджеры с метриками периода
  (пополнения/оборот/клиенты) и оценкой «начислено» текущим тарифом.
- store/update: валидация params по виду тарифа (topup_step.periods[],
  percent_oborot.rate, fix_per_client.threshold/reward); смена kind в
  update запрещена (422).
- assign: ставит менеджеру current_tariff_id + base_salary_rub («оклад+
  процент» = оклад на менеджере + percent_oborot тариф).
- В12: ни update, ни assign НЕ трогают снимки уже привязанных клиентов
  (sales_client_assignments) — покрыто тестами.

Роуты в группе admin-db/auth:sales/sales-portal. Тесты 19/19,
весь sales-набор 130/130, Larastan 0 (3 Pest-false-positive в baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:56:58 +03:00
Дмитрий 8e64f65cac feat(sales): движок расчёта дохода по тарифу (3 вида)
Task 3.1 — SalesEarningsService, денежное ядро портала продаж.
Доход считается по СНИМКУ тарифа клиента (assignment.tariff_kind/params),
не по live-тарифу менеджера.

- percent_oborot: оборот периода × ставка.
- fix_per_client: reward если накопленные пополнения (all-time) ≥ порога, иначе 0.
- topup_step: Σ по месяцам периода пополнений месяца × ставка ступени по сроку
  обслуживания (tenure = diffInMonths(assigned_at→месяц)+1); вне таблицы → 0.
- forManager: оклад × число месяцев + Σ по клиентам (round 2).
- perClient: детализация для экрана «Мой доход».

Переиспользует SalesMetricsService (целые копейки, полуоткрытые интервалы)
и SalesPeriodResolver. Тесты 12/12 (заморозка времени на середину месяца
против флейков границ + партиций balance_transactions). Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:41:59 +03:00
Дмитрий fcc938790e docs(bot): план реализации ядра ИИ-бота Jivo (14 задач TDD)
По спеке 2026-07-02-jivo-ai-support-bot-design: webhook + FTS-поиск
по инструкции + YandexGPT Lite + эскалация + журнал + тест скорости.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:39:56 +03:00
Дмитрий 66204a045c feat(sales): экраны привязки клиентов и заявок (фронт)
Task 2.3b портала отдела продаж — два Vue-экрана поверх готовых
эндпоинтов /api/sales/attachments:

- SalesAttachView (менеджер, #page-attach): форма заявки по логину
  (e-mail/ИНН), живой баннер результата (отправлено/не найден/ошибка),
  таблица «Мои заявки».
- SalesRequestsView (начальник, #page-requests): очередь на решение
  с проверкой «свободен / уже за: …», кнопки Подтвердить/Переназначить
  /Отклонить (переназначение = тот же approve на бэке), история, счётчик.
- api/sales.ts: submitAttachment / listMyAttachments / listAttachmentQueue
  / decideAttachment + типы.
- Роуты /sales/attach и /sales/requests переключены со stub на реальные экраны.

Vitest: SalesAttach 5 + SalesRequests 7 = 12 зелёных; полный фронт-набор
не сломан. ESLint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:22:49 +03:00
Дмитрий 23239faa7c docs(support-bot): спека своего ИИ-бота в чате Jivo + протокол обсуждения
Дизайн согласован владельцем 02.07.2026: свой бот через Jivo Bot API,
YandexGPT Lite, база знаний = docs/help/ в репо, кнопка «Показать»
(экскурсии), скорость 2-5 сек, v1 только общие вопросы.
Словарь: +jivo/дживо/gigachat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:15:18 +03:00
Дмитрий d264d5078e style(sales): pint-канон импортов routes/web.php
Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:49:42 +03:00
Дмитрий ba79f7d28c feat(sales): эндпоинты привязки клиентов (бэк)
Task 2.3a: SalesAttachmentController — store (менеджер подаёт заявку, 422 «уже ваш»), index (менеджер видит свои; начальник — очередь pending + история с именами менеджеров и подсказкой «свободен/уже за: …»), decide (начальник approve/reject, 403 менеджеру). Маршруты POST/GET /api/sales/attachments, POST .../{id}/decide. Тест 12/12, весь sales 100/100, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:47:27 +03:00
Дмитрий f7c9721058 feat(sales): решение начальника по заявке + снимок тарифа
Task 2.2: SalesAttachmentService.decide(head, requestId, approve|reject) — только начальник (иначе AuthorizationException). approve: создаёт sales_client_assignment со СНИМКОМ текущего тарифа менеджера (tariff_id/kind/params копируются значением на момент решения — В12: смена тарифа менеджера потом влияет только на новых клиентов, подтверждено тестом). Переназначение занятого клиента = delete+create в транзакции. reject: статус+comment. Письмо AttachmentDecidedMail менеджеру. Тест 6/6, весь sales 88/88, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:36:00 +03:00
Дмитрий 81bc62153b feat(sales): сервис привязки — lookup + заявка + письма
Task 2.1: SalesAttachmentService.lookup(login→tenant по contact_email ИЛИ inn) + submit(menedzher, login): не найден→заявка not_found + письмо; свободен→pending + письма менеджеру и всем начальникам; занят другим→pending с пометкой конфликта в comment; уже свой→исключение. Mailable AttachmentSubmittedMail/AttachmentNotFoundMail + blade. Тест 7/7 (Mail::fake), весь sales 82/82, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:26:53 +03:00
Дмитрий 5c59188002 feat(sales): экран «Сводка менеджера» (фронт) — Фаза 1 закрыта
Task 1.5b: SalesOverviewView по демо #page-overview — 5 KPI (клиентов+разбивка статусов, Σ баланс, лидов пришло, оборот, я заработал=«—» до Фазы 3), зелёная плашка-пояснение, таблицы «Требуют внимания» и «Топ клиентов по лидам» (клик → карточка), период из стора. getSalesOverview в api/sales.ts. Vitest 12/12, полный фронт 1031 без регрессий. Фаза 1 (чтение менеджера) завершена. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:15:45 +03:00
Дмитрий bfbdc93712 test(sales): устойчивость SalesMetricsServiceTest к сбросу тест-БД
balance_transactions партиции создаются динамически (partitions:create-months = текущий+будущие), НЕ в schema.sql → при migrate:fresh общей тест-БД прошлые месяцы не восстанавливаются, июньские топап-тесты падали 'нет секции'. Привязал 4 balance_transactions-теста к ТЕКУЩЕМУ месяцу (btDate/btThisMonthRange/btLastDay/btNextMonthStart) — у него партиция есть всегда. deals/lead_charges тесты не трогал (их партиции в схеме, переживают fresh). 15/15. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:02:03 +03:00
Дмитрий 5b83afd98e feat(sales): сводка менеджера (бэкенд)
Task 1.5a: GET /api/sales/overview — агрегаты по своим клиентам (начальник — по всем) за период: счётчики (клиентов/активных/триал/просрочка), Σ баланс, лиды/оборот, earned=null до Фазы 3; «требуют внимания» (overdue/suspended/запас=0, worst-first, до 10); «топ клиентов по лидам» (top-4). deriveTenantStatus вынесен в приватный helper (общий с index). Тест 10/10, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:57:43 +03:00
Дмитрий af214b2282 feat(sales): экран карточки клиента (фронт)
Task 1.4b: SalesClientCardView по демо #page-client-card — 5 KPI (баланс+запас, проектов, пришло/цель, средняя цена лида, заработано=«—» до Фазы 3), 2 колонки: проекты+лиды по дням+последние лиды (телефон маскирован бэком), профиль+активность. HelpHint на Запас/Средняя цена/Заработано. 403 «клиент не закреплён за вами». getSalesClientCard в api/sales.ts. Vitest 8/8, lint/type-check 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:43:43 +03:00
Дмитрий e7e5199c2b feat(sales): карточка клиента (бэкенд)
Task 1.4a: GET /api/sales/clients/{tenantId} — профиль, KPI (баланс/запас/проекты/лиды-цель/средняя цена лида, earned=null до Фазы 3), проекты, лиды по дням, последние лиды с МАСКИРОВАННЫМ телефоном, активность. 403 для чужого клиента (ScopesSalesOwnership), начальник видит всех. Тест 8/8, весь sales 65/65, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:36:36 +03:00
Дмитрий 760956e4a7 docs(plan): мониторинг внешних сервисов — план реализации (10 задач, TDD)
Разбивка на 10 задач по TDD: LivenessProbe/DTO, 4 пробы живости
(SMTP/ЮKassa/Jivo/капча), джоба пишет строки живости, email-алерт по
edge-trigger, фронт со статус-текстом. Схема БД не меняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:35:49 +03:00
Дмитрий 9ef8eccf08 docs(spec): мониторинг внешних сервисов — баланс + живость + email-алерт
Дизайн (брейншторм 02.07): расширяем плитку балансов до наблюдения за всеми
внешними сервисами — деньги И живость. Добавляем Почту (Yandex 360 SMTP,
только живость), ЮKassa, JivoSite, SmartCaptcha. Красная плитка + письмо
на ops@liderra.ru по edge-trigger. Конкурентное поле — вне объёма.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:26:28 +03:00
Дмитрий a85148555c feat(sales): экран «Мои клиенты» (фронт)
Task 1.3b: SalesClientsView — таблица 11 колонок по демо #page-clients (Клиент/Тип/Активность/Баланс/Запас/Проектов/Пришло/Оборот/Тариф/Заработал/Статус), бейдж типа лица, чипы статуса (Триал/Активен/Просрочка/Приостановлен), HelpHint на терминах, деньги ru-RU + JetBrains Mono, перезагрузка при смене периода, клик по строке → карточка. Заработал=«—» до Фазы 3. listSalesClients в api/sales.ts. Vitest 6/6, lint/type-check 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 06:40:56 +03:00
Дмитрий ad975c4d44 feat(sales): эндпоинт «Мои клиенты» (бэкенд)
Task 1.3a: GET /api/sales/clients — менеджер видит своих (ScopesSalesOwnership), начальник всех. Строки: организация, ИНН/тип лица (tenant_requisites), баланс, запас, проекты, лиды/оборот за период (SalesMetricsService), тариф-снимок из assignment, статус 1:1 с AdminTenantsController (trial>suspended>overdue>active). earned_rub=null до Фазы 3. Тест 7/7, stan 0 (baseline: Pest false-pos). Пагинация — TODO. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 06:34:22 +03:00
Дмитрий 05bf7ef1b8 docs(claude-md): горящий баннер БОЕВОЙ ПРОД в §ГЛАВНОЕ (v2.48)
Через плагин claude-md-management (targeted-update):
- §ГЛАВНОЕ: баннер про боевой прод (доступ только с разрешения владельца, БД по умолчанию
  чтение, ЛК поставщика прод=crm.lead.store, снос базы только PROD-DESTROY-OK+бэкап)
- шапка 2.47 → 2.48 (01.07.2026); прод очищен и взведён для боевой работы
- cspell-words.txt: +golive

NB: LEFTHOOK_EXCLUDE=larastan — предсуществующий чужой Sales-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:11:58 +03:00
Дмитрий 4a0e26af09 docs(prod): маркировка БОЕВОЙ ПРОД + сводка 01.07 (ЛК crm.lead.store, чистка базы)
- ПИЛОТ.md: горящий баннер + датированная сводка 01.07 (свап ЛК, приём лидов, снос базы, фикс)
- ЭТАЛОН.md: пометка «локальная dev, боевой = ПИЛОТ, не путать»
- .claude/hooks/prod-db-pointer.mjs: горящий блок БОЕВОЙ ПРОД в SessionStart
- план: прогресс Фаз 4/0/5/6
- cspell-words.txt: +14 валидных слов (gzk, ВТБ, ОКПО, автобэкапы и др.)

NB: LEFTHOOK_EXCLUDE=larastan — предсуществующий чужой tests/Feature/Sales/SalesClientsIndexTest.php.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:01:55 +03:00
Дмитрий 7e8a2dc86a fix(supplier): не отправлять площадку с долей лимита 0 (кабинет crm.lead.store отклоняет)
Новый кабинет отклоняет rt-project-save с limit=0 (Введите limit!), старый принимал.
Проект с daily_limit_target=1 (site->3 площадки) давал доли 1/0/0 и падал на B2/B3.

- distributeForPlatform опускает площадки с долей 0 (сумма ненулевых == order)
- SyncSupplierProjectsJob и SyncSupplierProjectJob: create/missing/dead/update-циклы
  идут только по площадкам с долей >=1 (?? 0 снят — ключ гарантирован, phpstan 0 на этих файлах)
- тесты: allocator (limit-1/2, zero), новый job-тест limit-1 site -> ровно 1 save

Прогон supplier+jobs: 261/261.
NB: LEFTHOOK_EXCLUDE=larastan — гейт падал на предсуществующих ошибках чужого
tests/Feature/Sales/SalesClientsIndexTest.php (не связано с этим фиксом).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:51:06 +03:00
Дмитрий 4e6ac1057f docs(prod): план чистки прода + замены ЛК поставщика на crm.lead.store
Обоснование (spec) + детальный пошаговый план с полным откатом:
- факты живой базы (7 тест-клиентов, деньги синтетические), keep/delete
- разведка crm.lead.store: визуал новый, начинка (эндпоинты/API/формат лида) та же
- порядок: свап ЛК -> проверка проектов по алгоритму -> чистка кабинета -> чистка базы
- маркировка БОЕВОЙ ПРОД во всех доках + стартовый хук

Секреты/ПДн не включены (почты замаскированы, секрет вебхука как <секрет>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:36:09 +03:00
Дмитрий 55c14fc7c2 feat(sales): сервис метрик клиента за период
Task 1.2: SalesMetricsService — leadsDelivered/oborotRub/topupsRub/cumulativeTopupsRub/runwayDays. Деньги: оборот из целых копеек (/100 в конце), полуоткрытые интервалы [start, след.день). runwayDays реиспользует PricingTierRepository→BalanceToLeadsConverter→RunwayCalculator (совпадает с кабинетом клиента, F3). leadsDelivered 1:1 с DashboardController (deleted_at NULL, is_test=false). Тест 15/15 (с граничными), stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:46:31 +03:00
Дмитрий 07b5758291 feat(sales): резолвер периода (этот/прошлый/позапрошлый/произвольный)
Task 1.1: SalesPeriodResolver.resolve({kind,from,to})→SalesPeriodRange (МСК, Europe/Moscow) + monthsIn() для ступенчатого тарифа. Поддержка this/prev/prev2/custom; неизвестный kind→this; custom from>to→исключение. Тест 10/10, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:35:48 +03:00
Дмитрий ef0f7c803f feat(sales): каркас фронта портала — layout, роутер, вход, период-пикер
Task 0.7: SalesLayout (сайдбар ОТДЕЛ ПРОДАЖ, 2 секции nav, boss-only для head), SalesLoginView (реальные email+пароль, не демо-кнопки), сторы salesAuth/salesPeriod, api/sales.ts, PeriodPicker (этот/прошлый/позапрошлый/произвольный), HelpHint (?). Роуты /sales/* с гардом (токен→login, boss-only→/sales). Заглушка SalesStubView для экранов будущих фаз. Vitest SalesLogin 5/5, весь фронт 1005 без регрессий, type-check/lint чисто. Вёрстка по демо v8_sales.html. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:27:04 +03:00
Дмитрий 86bbeb1f06 style(sales): pint-канон контроллера/маршрутов/теста входа
Lefthook-pint не перестейджит — приводим к канону версии из c366614f. Без изменений логики. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:09:05 +03:00
Дмитрий c366614fcd feat(sales): вход (login/me/logout) + маршруты /api/sales
Task 0.5+0.6: SalesAuthController (login 200/422/403, me, logout) + маршруты /api/sales/auth и зона данных. Порядок middleware admin-db ДО auth:sales. Тест SalesAuthTest 7/7, весь sales-набор 25/25. Logout инвалидирует токен (в тесте Auth::forgetGuards() — артефакт мульти-запросов; в бою каждый запрос свежий). Larastan baseline: Pest false-pos SalesAuthTest. Заодно pint-канон моделей/трейта/SalesModelsTest. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:07:47 +03:00
Дмитрий 372668ad41 feat(sales): EnsureSalesUser + ScopesSalesOwnership
Task 0.4: middleware EnsureSalesUser (гейт зоны /api/sales — user('sales') активен, иначе 401, alias 'sales-portal'). Трейт ScopesSalesOwnership: менеджер видит только свои tenant_id из sales_client_assignments, начальник (head) — всех (null=без ограничения). Тест SalesOwnershipScopeTest 6/6, всего sales 18/18. Larastan чистый. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:43:31 +03:00
Дмитрий bdcb82f8f7 feat(sales): guard sales (sanctum) + provider + таблица токенов
Task 0.3: guard 'sales' (driver sanctum, provider sales_users). Тест SalesGuardTest 3/3 (valid Bearer→200, без токена→401, мусор→401). Миграция personal_access_tokens (Sanctum Bearer; раньше не было — основной кабинет SPA cookie), DDL через pgsql_supplier, гранты crm_admin_user, CHANGELOG v8.60. Larastan baseline: +SalesGuardTest (Pest TestCall false-pos), SetTenantContext int→mixed (второй provider расширил тип $request->user()). План: admin-db ДО auth:sales. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:36:13 +03:00
Дмитрий 9b91016f46 feat(sales): Eloquent-модели портала продаж
Task 0.2: SalesUser (Authenticatable + HasApiTokens, isHead), SalesTariff, SalesClientAssignment (snapshot тарифа), SalesAttachmentRequest, SalesPayout (append-only через триггер БД). Тест tests/Feature/Sales/SalesModelsTest.php — 9 passed. Разрешение хозяина: один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:08:52 +03:00
Дмитрий b0794fbef6 feat(sales): схема портала отдела продаж — 5 таблиц + append-only выплаты
Task 0.1: миграция sales_tariffs/sales_users/sales_client_assignments/sales_attachment_requests/sales_payouts (SaaS-level, без RLS, фильтр по владению в коде). Append-only триггер. Гранты crm_admin_user. DDL через pgsql_supplier. CHANGELOG_schema v8.59. Разрешение хозяина: один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:57:22 +03:00
Дмитрий 5a33074dbf docs(sales): план реализации портала отдела продаж — 8 фаз, TDD, по спеке и демо
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:27:19 +03:00
Дмитрий 7067c583ec docs(sales): кликабельное демо портала отдела продаж + спецификация
Демо-прототип портала менеджеров по продажам в дизайне Лидерры (Forest),
приведён к реальной модели данных + расширен. Без бэкенда.

- Роли менеджер/начальник, простой демо-вход, переключение роли
- Менеджер: Сводка, Мои клиенты, карточка клиента, Привязать, Мой доход
- Начальник: Сводка отдела, Результативность, Тарифы менеджеров,
  Счета (оплата по счёту), Заявки на привязку, Выплаты, Менеджеры
- Тарифы = формула дохода (В1): 3 семейства, конструктор, переход «тариф
  прилипает к клиенту» (В12 решён)
- Выбор периода везде (этот/прошлый/позапрошлый/произвольный)
- UX-проход Playwright: ?-подсказки, починена вёрстка подписей
- Спека: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:12:41 +03:00
Дмитрий 2d5e52799e chore(gitleaks): allowlist синтетических демо-телефонов фикстур автоподбора
Разбор 13 false-positive ru-phone-unmasked: фейк-номера в заглушке агента
(Fake*CompetitorAgent), кликабельных прототипах фичи и демо-экране DetailScreen —
не реальные ПДн, та же категория, что factories/doubles/specs. Добавлены пути
и плейсхолдер-паттерн в .gitleaks.toml. full-history скан: no leaks found.

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:00:23 +03:00
Дмитрий 45d67f3322 docs(конкурентное поле): отчёт теста «тупого клиента» + план/спека + 7 скринов
Прогон фичи глазами «самого тупого клиента» на изолированной тест-БД (прод не тронут):
все формы, групповые действия, биллинг с реальным списанием, эмуляция поставки Агента, визуал.
Деньги в порядке (300/50 списываются ровно при успехе, без двойных/ложных списаний).
Найдено и ПОЧИНЕНО 6 находок (код — коммит worktree-avtopodbor 1b3683c6).
Независимая перепроверка по коду; одна самокоррекция (общий тост, не «полная тишина»).

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 06:43:26 +03:00
Дмитрий ad519c89c8 docs(конкурентное поле): сверка прототип↔реализация + статус доводки находок
Находки эталон↔реализация и их закрытие (F1 «Предложения», F2 админ-тарифы,
F3 биллинг, M2 чистка; M1 — не баг; N1/M3 — хвосты). Код фичи — отдельным
коммитом в ветке worktree-avtopodbor (793b20a3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:58:53 +03:00
Дмитрий 9737ea7b1b feat(ui): тип лица полными словами, зелёные дни недели, скрытие банк-реквизитов у физлица
- Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты)
- У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП)
- Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:22:09 +03:00
Дмитрий adfdf9583c docs(ПИЛОТ): сквозная сверка байт-в-байт прод↔git↔бэкап (1:1) после выката «оплаты по счёту»
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:05:12 +03:00
Дмитрий 0f1bced2a5 docs(ПИЛОТ): снимок 29.06 — фича «оплата по счёту» (Этап 1) выкачена на прод + реквизиты ИП в legal_entities 2026-06-29 11:46:38 +03:00
Дмитрий a56dcb06b2 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:25:16 +03:00
Дмитрий f45cfb900c docs(ПИЛОТ): снимок 28.06 — сквозная сверка прод↔git↔бэкап (1:1) + Тенанты + фронт-стенд зелёный
Прод приведён к gitea-main 84dfbc85 один-в-один (rsync-checksum, потерь нет);
бэкап цел; экран Тенанты на серверную пагинацию выкачен; 992 фронт-теста зелёные;
деньги t2=1 839 405₽ целы. Остатки не-git на проде объяснены (.bak-precutover до 03.07).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:09:42 +03:00
Дмитрий dab91b62f7 test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa),
ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC),
PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API),
DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration
(window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты.
Полный прогон: 127 файлов / 992 теста зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:19 +03:00
Дмитрий fea4b47ecb feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:05:31 +03:00
Дмитрий 628423322a docs(ПИЛОТ): снимок 28.06 — починен тихо сломанный биллинг-сторож (RLS) + playwright durable
Свод за заход «закрывай хвосты»: разбор и фикс preflight-sweep/reminder (no-op с 26.06
из-за RLS-роли очереди), self-heal 4 проектов на проде, деньги t2 целы, playwright в deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:19:02 +03:00
Дмитрий e8491e81de fix(биллинг): sweep и reminder перебирают тенантов через BYPASSRLS + playwright в зависимостях
Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS),
и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк
без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок,
ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier
(BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена
на боевых данных: t25/t26 снимутся, t27/t30 заморозятся.

Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под
chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev,
durable к чистке node_modules.

Тесты Billing 18/18, pint/phpstan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:09:07 +03:00
766 changed files with 42022 additions and 58062 deletions
+6
View File
@@ -4,6 +4,12 @@
// её «пересобирать». Только инъекция контекста, ничего не блокирует.
const context = [
'🔴🔴🔴 БОЕВОЙ ПРОД liderra.ru — ЖИВЫЕ КЛИЕНТЫ И ДЕНЬГИ. 🔴🔴🔴',
'Любой доступ/изменение боевого (БД, деплой, джобы, кабинет поставщика) —',
'ТОЛЬКО с явного разрешения владельца. По умолчанию БД — только чтение.',
'ЛК поставщика на проде = crm.lead.store (логин omega.gzk); локально/тесты = crm.bp-gr.ru.',
'Снимок боевого — ПИЛОТ.md. Снос базы — только маркер PROD-DESTROY-OK + свежий бэкап.',
'',
'ОРИЕНТИР ПО БАЗЕ ЛИДЕРРЫ (важно перед любой работой с БД):',
'- ЖИВАЯ боевая база = Yandex Managed PG, кластер c9q2cvtjpq3hgq6l0r96',
' (rw-endpoint *.rw.mdb.yandexcloud.net:6432). Доступ — через app/.env',
+11
View File
@@ -224,3 +224,14 @@ ruflo-mcp-stderr.log
settings copy.json
# Строчный Ctemp-дамп (CTemp* выше не ловит из-за регистра)
Ctemp*
# ── Рабочий мусор сессий (файлы остаются на диске, git их просто не видит) ──
# Скриншоты проверок/приёмки в корне (уже отслеживаемые DUMB-*.png не затрагиваются)
/*.png
# Личное и черновики
/моя/
/scratch-*.md
# Инструменты / IDE / локальные демо-данные
.claire/
**/.phpstorm.meta.php
live-demo/steps/_sample-local/
+38 -7
View File
@@ -87,11 +87,6 @@ paths = [
'''app/composer\.lock''',
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
'''app/tests/.*\.php''',
# Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и
# синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном
# конкурента (опубликован в открытом справочнике), не клиентские ПДн.
# Та же категория, что app/tests/*.php выше.
'''app/tests/fixtures/.*''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
@@ -124,7 +119,37 @@ paths = [
'''tools/observer-pii-filter\.test\.mjs''',
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
'''tools/enforce-read-path-deny\.test\.mjs'''
'''tools/enforce-read-path-deny\.test\.mjs''',
# Заглушка ИИ-агента автоподбора (Fake*CompetitorAgent) — синтетические демо-телефоны
# конкурентов (Казань 8432…, 8-800), а не реальные ПДн. Та же категория, что
# factories/doubles; заменяется реальным движком (binding в AutopodborServiceProvider).
'''app/app/Services/Autopodbor/Agent/Fake.*Agent\.php''',
# Кликабельные прототипы фичи (демо-телефоны для визуализации макета) — та же категория,
# что docs/superpowers/{specs,plans,audits,runbooks}; не реальные ПДн.
'''docs/superpowers/prototypes/.*\.html''',
# «Косяки глазами пользователя» — audit-internal находки живого прохода портала
# с синтетическим демо-телефоном 916-123-45-67 в разделе про нормализацию формата.
# Не реальные ПДн; та же категория, что docs/superpowers/{specs,plans,audits}.
'''косяки с точки зрения пользователя/.*\.md''',
# Исследование ИИ-вебмастера (выгрузка Perplexity) — пример HTTP-заголовка
# Idempotency-Key в доке про идемпотентность ловится как generic-api-key. Не ключ.
'''вебмастер-исходники-perplexity/.*\.md''',
# Тест-фикстуры — захваченные сторонние HTML/JSON (страницы 2ГИС с ИХ
# публичными фронтенд-токенами) + синтетические телефоны. Не наши секреты/ПДн.
# Та же категория, что app/tests/*.php (уже в allowlist).
'''app/tests/fixtures/.*''',
# bylo-stanet — демо «было/станет» лендинга (HTML-макеты с демо-телефонами).
# Историческая директория; та же категория, что liderra_v8_handoff/concepts.
'''bylo-stanet/.*''',
# Findings-доки — internal audit-находки живого прохода портала (демо-телефоны
# в примерах). Та же категория, что docs/superpowers/{specs,plans,audits,runbooks,prototypes}.
'''docs/superpowers/findings/.*\.md''',
# Autopodbor preview-экраны — демо-данные конкурентов для UI-разводки макета.
# Та же категория, что mockDeals.ts / settings/*.vue.
'''app/resources/js/views/autopodbor/screens/.*\.vue''',
# .env.example — шаблон переменных окружения (плейсхолдеры/пустые значения, не
# реальные секреты). Настоящие .env — в .gitignore и сканируются pre-commit.
'''app/\.env\.example'''
]
regexTarget = "match"
regexes = [
@@ -165,6 +190,9 @@ regexes = [
'''79991234567''',
'''74955551212''',
'''89991234567''',
# Демо-номер Красноярск (3912-71-33-33) из docblock-примера удалённого
# HtmlPhoneScanner (Autopodbor Extract). Историческая находка, в текущем коде нет.
'''73912713333''',
# Plan 2: ABC-коды городов с тестовым tail "1234567" (PhonePrefixService docs/tests)
'''7\d{3}1234567''',
'''799912345678''',
@@ -172,5 +200,8 @@ regexes = [
'''\+79991234567''',
'''7 999 123 45 67''',
# 12-значные номера-маски для скриншотов и тестов
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}'''
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}''',
# Демо-плейсхолдер автоподбора (экран DetailScreen) — Казань 843 + «200-00-00», явный фейк
'''7\s?843\s?200[\s\-]?00[\s\-]?00''',
'''78432000000'''
]
+4
View File
@@ -54,6 +54,10 @@ exclude = [
# Sample/примерные адреса
"^https?://example\\.com",
"^https?://example\\.org",
# Email-адреса в тексте, склеенные с кавычкой-ёлочкой (напр. «support@liderra.ru»),
# lychee ошибочно резолвит как относительный file://-путь. Это не ссылка на файл.
"support@liderra\\.ru",
"^file://.*@[a-z0-9.-]+\\.(ru|com|org)»?$",
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
"^https?://github\\.com/CoralMinister/",
+8 -2
View File
@@ -1,5 +1,11 @@
## ⛔ ГЛАВНОЕ — прочитать первым делом
> 🔴🔴🔴 **БОЕВОЙ ПРОД liderra.ru — ЖИВЫЕ КЛИЕНТЫ И ДЕНЬГИ.** Любой доступ/изменение боевого
> (БД, деплой, джобы, кабинет поставщика) — **только с явного разрешения владельца**; БД по
> умолчанию **только чтение**. ЛК поставщика: на проде = **crm.lead.store**, локально/тесты =
> crm.bp-gr.ru. Снос базы — только маркер `PROD-DESTROY-OK` + свежий бэкап. Снимок боевого —
> `ПИЛОТ.md`; состояние (01.07.2026): база чиста и взведена для боевой работы. 🔴🔴🔴
1. **Не уверен — спроси, не гадай.** Один вопрос лучше, чем час работы не туда.
2. **Не выдумывай.** Не помнишь — открой файл и проверь, а не «вспоминай по памяти».
3. **«Готово» — только если правда проверил.** Что-то упало — скажи честно, не делай вид, что всё хорошо.
@@ -13,7 +19,7 @@
# CLAUDE.md — техконтекст Лидерры
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
**Версия:** 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
@@ -245,7 +251,7 @@ trivy image liderra:latest
**Полный журнал фаз и работ** (что и когда делалось, включая историю «мозга») — в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md).
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО), договор с **ЮKassa** готов — осталось только подписать; после подписи включается онлайн-оплата (флаг `billing_yookassa_enabled`). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источник истины — память `project-legal-entity-ip-yookassa-2026-06-25` (25.06.2026).
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО). Договор с **ЮKassa подписан 26.06.2026** (№НЭК.448000.01), магазин 1392092 активен. Флаг `billing_yookassa_enabled`**ВКЛЮЧЁН (намеренно, штатно)**, но **go-live онлайн-оплаты НЕ завершён:** успешной живой оплаты ещё не было (5 тестовых попыток 100₽ 26–27.06 отменены на стороне ЮKassa (`canceled`/`paid=false`, у P6 — `expired_on_confirmation`), деньги не списаны; happy-path «оплата→webhook→зачисление» в бою не проверялся; webhook IP-allowlist пуст). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источники истины — память `project-yookassa-online-payment-golive-2026-06-26` + снимок ПИЛОТ.md 27.06.2026.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

-9
View File
@@ -96,12 +96,3 @@ VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
# Автоподбор шаг1: модель ИИ-фильтра «поставщик или площадка-агрегатор».
# ОБЯЗАТЕЛЬНО gpt-4o (сильная): слабая gpt-4o-mini наугад штампует живые
# федеральные МФО (Екапуста/Webbankir/СрочноДеньги…) как «агрегаторы» и режет
# их из подбора. Пусто → фолбэк на gpt-4o-mini (баг «режет федералов», 07.07.2026).
AITUNNEL_AGGREGATOR_MODEL=gpt-4o
@@ -33,7 +33,8 @@ class IncidentsWatchFailures extends Command
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -47,6 +48,7 @@ class IncidentsWatchFailures extends Command
$dedupMinutes = (int) $this->option('dedup-window');
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
$since = Carbon::now()->subMinutes($windowMinutes);
$since24h = Carbon::now()->subHours(24);
@@ -221,6 +223,35 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 6: supplier-manual-queue spike =====
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
if ($thresholdManualQueue > 0) {
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
->table('supplier_manual_sync_queue')
->where('created_at', '>=', $since)
->count();
if ($manualQueueCount >= $thresholdManualQueue) {
$dedupKey = 'supplier-manual-queue-spike';
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
if (! $this->isDup($dedupKey, $dedupAt)) {
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
$created++;
}
// Письмо — каждый прогон при активном спайке, на оба адреса.
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
->send(new IncidentDetectedMail($summary, 'high'));
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
}
}
$this->info("Done. Created {$created} incident(s).");
return self::SUCCESS;
@@ -245,6 +276,7 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -259,7 +291,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($severity === 'high') {
if ($sendMail && $severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Mail\SupplierDeadlineWarningMail;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Сторож дедлайна поставщика: если робот SyncSupplierProjectsJob к порогу не закончил
* письмо. yellow=20:00, red=20:40 (МСК). Ловит и медленную работу, и зависание, и
* несработавший планировщик (за сегодня нет завершённого запуска).
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §5
*/
final class SupplierDeadlineWatchCommand extends Command
{
protected $signature = 'supplier:deadline-watch {level : yellow|red}';
protected $description = 'Проверить, успел ли робот-заказчик до дедлайна поставщика (21:00 МСК)';
public function handle(): int
{
/** @var 'yellow'|'red' $level */
$level = $this->argument('level') === 'red' ? 'red' : 'yellow';
$today = Carbon::today('Europe/Moscow')->toDateString();
$finished = DB::connection('pgsql_supplier')->table('supplier_sync_runs')
->whereRaw("(started_at AT TIME ZONE 'Europe/Moscow')::date = ?", [$today])
->whereNotNull('finished_at')
->where('status', '!=', 'aborted')
->exists();
if ($finished) {
$this->info('OK: робот сегодня завершил заказ.');
return self::SUCCESS;
}
$reason = 'За сегодня нет завершённого (не aborted) запуска SyncSupplierProjectsJob с finished_at.';
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierDeadlineWarningMail($level, $reason));
$this->warn("{$level}: робот не закончил — письмо отправлено.");
return self::SUCCESS;
}
}
@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
class RunInFlightException extends \RuntimeException {}
@@ -411,7 +411,6 @@ class AdminDashboardController extends Controller
'dadata' => (string) config('services.dadata.topup_url') ?: null,
'supplier' => (string) config('services.supplier.topup_url') ?: null,
'yandex_cloud' => $this->ycTopupUrl(),
'email' => (string) config('services.yandex360.topup_url') ?: null,
default => null,
};
}
@@ -31,21 +31,12 @@ use Illuminate\Support\Facades\DB;
*/
class AdminSystemSettingsController extends Controller
{
/** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */
private const SENSITIVE_KEYS = ['supplier_webhook_secret'];
/** GET /api/admin/system-settings */
public function index(): JsonResponse
{
$settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) {
if (in_array($s->key, self::SENSITIVE_KEYS, true)) {
$s->value = $s->value === '' ? '' : '••• (скрыто)';
}
return $s;
});
return response()->json(['settings' => $settings]);
return response()->json([
'settings' => SystemSetting::orderBy('key')->get(),
]);
}
/** PUT /api/admin/system-settings/{key} */
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* SaaS-admin Система: ручной ввод баланса Яндекс 360 (почта).
* Владелец периодически вписывает сумму из кабинета (у Яндекса нет API баланса).
*/
class AdminYandex360BalanceController extends Controller
{
public function __construct(private readonly Yandex360BalanceStore $store) {}
/** GET /api/admin/settings/yandex360-balance */
public function show(): JsonResponse
{
return response()->json($this->store->status());
}
/** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */
public function update(Request $request): JsonResponse
{
$raw = $request->input('balance');
$balance = ($raw === null || $raw === '') ? null : (float) $raw;
if ($balance !== null && $balance < 0) {
return response()->json([
'message' => 'Баланс не может быть отрицательным.',
'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']],
], 422);
}
$this->store->set($balance);
return response()->json($this->store->status());
}
}
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
@@ -105,7 +104,7 @@ class DealController extends Controller
$query = Deal::query()
->select('deals.*')
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -207,25 +206,6 @@ class DealController extends Controller
});
}
// Автоподбор: по каждой сделке — конкурент + источник (связь
// deal.project_id → autopodbor_sources.created_project_id → competitor).
// Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с
// app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк.
$sourcesByProject = collect();
$projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all();
if ($projectIds !== []) {
$sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return AutopodborSource::query()
->where('tenant_id', $tenantId)
->whereIn('created_project_id', $projectIds)
->with('competitor:id,name')
->get()
->groupBy('created_project_id');
});
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
@@ -250,21 +230,6 @@ class DealController extends Controller
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'cost_kopecks' => $costByDeal[$d->id] ?? null,
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
...(function () use ($d, $sourcesByProject) {
$group = $sourcesByProject[$d->project_id] ?? collect();
$repr = $group->firstWhere('box', 'field') ?? $group->first();
return [
'competitor_id' => $repr?->competitor_id,
'competitor_name' => $repr?->competitor?->name,
'source_identifier' => $repr?->identifier,
'source_signal_type' => $repr?->signal_type,
'source_project_id' => $repr ? $d->project_id : null,
'source_is_active' => $repr ? (bool) $d->project?->is_active : null,
'source_extra_count' => max(0, $group->count() - 1),
];
})(),
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -9,14 +9,15 @@ use App\Http\Requests\BulkProjectActionRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Billing\LaunchBalanceGate;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Project\ProjectService;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Проекты tenant'а расширенный API для ProjectsView + NewDealDialog.
@@ -133,18 +134,35 @@ class ProjectController extends Controller
return response()->json(['error' => 'requisites_required'], 422);
}
unset($validated['force_save_blocked']); // больше не блокируем создание
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
$project = $this->projects->create($tenant, $validated, launch: true);
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
return response()->json([
'data' => new ProjectResource($project->loadCount('supplierProjects')),
'launch' => [
'launched' => $project->launch_deferred ? 0 : 1,
'deferred' => $project->launch_deferred ? 1 : 0,
'balance' => $project->gate_payload,
],
], 201);
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
$project = $this->projects->create($tenant, $validated);
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
}
/** PATCH /api/projects/{id} */
@@ -153,28 +171,34 @@ class ProjectController extends Controller
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$validated = $request->validated();
$tenant = $request->user()->tenant;
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
$isLimitRaise = array_key_exists('daily_limit_target', $validated)
&& (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target
&& $project->is_active && $project->preflight_blocked_at === null;
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
// проекта + лимиты остальных активных не-blocked.
if (array_key_exists('daily_limit_target', $validated)) {
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('id', '!=', $project->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
if ($isLimitRaise) {
$newLimit = (int) $validated['daily_limit_target'];
$tenantId = $tenant->id;
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse {
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
$gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]);
if (! $gate->passes) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409);
}
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
});
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
}
$updated = $this->projects->update($project, $validated);
@@ -182,6 +206,34 @@ class ProjectController extends Controller
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
}
/**
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
if ($tiers->isEmpty()) {
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
}
$result = (new BalancePreflightService)->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $requiredLeads,
tiers: $tiers,
);
return [
'passes' => $result->passes,
'capacity_leads' => $result->capacityLeads,
'deficit_leads' => $result->deficitLeads,
];
}
/** GET /api/projects/{id} */
public function show(Request $request, int $id): JsonResponse
{
@@ -217,13 +269,23 @@ class ProjectController extends Controller
$request->validate(['is_active' => ['required', 'boolean']]);
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$result = $this->projects->setActive($project, $request->boolean('is_active'));
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
$newActive = $request->boolean('is_active');
$project->update([
'is_active' => $newActive,
'paused_at' => $newActive ? null : now(),
]);
if ($result->activate_deferred ?? false) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409);
// #10: pause/resume must reach the supplier. The job's group recompute pushes
// status=paused when no active project of the group remains (resume → active).
// G (балансовый блок): заблокированный за нехваткой баланса проект не
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
if ($project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
}
return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
}
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesAttachmentRequest;
use App\Models\SalesUser;
use App\Services\Sales\SalesAttachmentService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж управление заявками на привязку клиентов.
*
* POST /api/sales/attachments менеджер подаёт заявку (store)
* GET /api/sales/attachments список заявок (index)
* POST /api/sales/attachments/{id}/decide начальник решает по заявке (decide)
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md Task 2.3
*/
class SalesAttachmentController extends Controller
{
public function __construct(private readonly SalesAttachmentService $service) {}
/**
* POST /api/sales/attachments
*
* Менеджер подаёт заявку по логину клиента (e-mail или ИНН).
* submit() сам шлёт письма контроллер только оборачивает и отвечает.
*
* Ответ 201: {id, login_input, status, status_label, tenant_id, comment}
* Ответ 422: если клиент уже принадлежит этому менеджеру (DomainException)
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'login' => ['required', 'string'],
]);
/** @var SalesUser $user */
$user = $request->user('sales');
try {
$req = $this->service->submit($user, $data['login']);
} catch (\DomainException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json($this->formatRequest($req), 201);
}
/**
* GET /api/sales/attachments
*
* Менеджер {data: [...]} только свои заявки, все статусы, newest first.
* Начальник {pending: [...], history: [...]} очередь + история.
*
* Каждая строка для менеджера:
* {id, login_input, status, status_label, tenant_id, organization_name, comment, created_at, decided_at}
*
* Каждая строка для начальника (pending):
* те же поля + manager_name + hint (свободен/занят)
* Каждая строка для начальника (history):
* те же поля + manager_name
*/
public function index(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if ($user->isHead()) {
return $this->indexForHead();
}
return $this->indexForManager($user);
}
/**
* POST /api/sales/attachments/{id}/decide
*
* Только начальник. validate: action in [approve,reject] + optional comment.
* Ответ 200: обновлённая заявка.
* Ответ 403: если не начальник.
*/
public function decide(Request $request, int $id): JsonResponse
{
$data = $request->validate([
'action' => ['required', 'string', 'in:approve,reject'],
'comment' => ['nullable', 'string', 'max:500'],
]);
/** @var SalesUser $user */
$user = $request->user('sales');
try {
$req = $this->service->decide($user, $id, $data['action'], $data['comment'] ?? null);
} catch (AuthorizationException) {
return response()->json(['message' => 'Только начальник может решать по заявкам.'], 403);
}
return response()->json($this->formatRequest($req));
}
// ── private ──────────────────────────────────────────────────────────────
/**
* @return JsonResponse {data: [...]} только заявки этого менеджера
*/
private function indexForManager(SalesUser $user): JsonResponse
{
$rows = DB::table('sales_attachment_requests as sar')
->leftJoin('tenants', 'tenants.id', '=', 'sar.tenant_id')
->where('sar.sales_user_id', $user->id)
->orderByDesc('sar.created_at')
->orderByDesc('sar.id')
->select([
'sar.id',
'sar.login_input',
'sar.status',
'sar.tenant_id',
'tenants.organization_name',
'sar.comment',
'sar.created_at',
'sar.decided_at',
])
->get();
$data = $rows->map(fn (object $r): array => $this->formatRow($r))->all();
return response()->json(['data' => $data]);
}
/**
* @return JsonResponse {pending: [...], history: [...]} для начальника
*/
private function indexForHead(): JsonResponse
{
// Pending queue — все pending заявки
$pendingRows = DB::table('sales_attachment_requests as sar')
->leftJoin('tenants', 'tenants.id', '=', 'sar.tenant_id')
->leftJoin('sales_users as su', 'su.id', '=', 'sar.sales_user_id')
->where('sar.status', 'pending')
->orderByDesc('sar.created_at')
->orderByDesc('sar.id')
->select([
'sar.id',
'sar.login_input',
'sar.status',
'sar.tenant_id',
'tenants.organization_name',
'sar.comment',
'sar.created_at',
'sar.decided_at',
'su.name as manager_name',
])
->get();
// Собираем tenant_id-ы pending-заявок с известным клиентом
$pendingTenantIds = $pendingRows
->filter(fn (object $r): bool => $r->tenant_id !== null)
->pluck('tenant_id')
->unique()
->values()
->all();
// Проверяем, у кого из них уже есть assignment (клиент занят)
/** @var array<int, string> $takenMap tenant_id → manager_name */
$takenMap = [];
if ($pendingTenantIds !== []) {
$assignments = DB::table('sales_client_assignments as sca')
->join('sales_users as su', 'su.id', '=', 'sca.sales_user_id')
->whereIn('sca.tenant_id', $pendingTenantIds)
->select(['sca.tenant_id', 'su.name as manager_name'])
->get();
foreach ($assignments as $a) {
$takenMap[(int) $a->tenant_id] = (string) $a->manager_name;
}
}
$pending = $pendingRows->map(function (object $r) use ($takenMap): array {
$row = $this->formatRow($r);
$row['manager_name'] = $r->manager_name ?? null;
// hint: free / taken
if ($r->tenant_id !== null && isset($takenMap[(int) $r->tenant_id])) {
$row['hint'] = 'уже за: '.$takenMap[(int) $r->tenant_id];
} elseif ($r->tenant_id !== null) {
$row['hint'] = 'свободен';
} else {
$row['hint'] = null; // not_found — не нашли клиента
}
return $row;
})->all();
// History — последние 50 завершённых (approved/rejected/not_found)
$historyRows = DB::table('sales_attachment_requests as sar')
->leftJoin('tenants', 'tenants.id', '=', 'sar.tenant_id')
->leftJoin('sales_users as su', 'su.id', '=', 'sar.sales_user_id')
->whereIn('sar.status', ['approved', 'rejected', 'not_found'])
->orderByDesc('sar.created_at')
->orderByDesc('sar.id')
->limit(50)
->select([
'sar.id',
'sar.login_input',
'sar.status',
'sar.tenant_id',
'tenants.organization_name',
'sar.comment',
'sar.created_at',
'sar.decided_at',
'su.name as manager_name',
])
->get();
$history = $historyRows->map(function (object $r): array {
$row = $this->formatRow($r);
$row['manager_name'] = $r->manager_name ?? null;
return $row;
})->all();
return response()->json([
'pending' => $pending,
'history' => $history,
]);
}
/** @return array<string, mixed> */
private function formatRow(object $r): array
{
return [
'id' => (int) $r->id,
'login_input' => $r->login_input,
'status' => $r->status,
'status_label' => $this->statusLabel((string) $r->status),
'tenant_id' => $r->tenant_id !== null ? (int) $r->tenant_id : null,
'organization_name' => $r->organization_name ?? null,
'comment' => $r->comment,
'created_at' => $r->created_at,
'decided_at' => $r->decided_at,
];
}
/** @return array<string, mixed> */
private function formatRequest(SalesAttachmentRequest $req): array
{
return [
'id' => $req->id,
'login_input' => $req->login_input,
'status' => $req->status,
'status_label' => $this->statusLabel($req->status),
'tenant_id' => $req->tenant_id,
'comment' => $req->comment,
];
}
private function statusLabel(string $status): string
{
return match ($status) {
'pending' => 'На рассмотрении',
'approved' => 'Одобрено',
'rejected' => 'Отклонено',
'not_found' => 'Клиент не найден',
default => $status,
};
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Models\SalesUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
/**
* Аутентификация портала отдела продаж.
*
* Все маршруты идут через middleware admin-db (UseAdminConnection),
* который переключает default-соединение на pgsql_admin (crm_admin_user).
* Это необходимо, потому что sales_users и personal_access_tokens доступны
* crm_admin_user, а Sanctum читает токены ДО контроллера в middleware auth:sales.
*
* guard: 'sales' (Sanctum, provider sales_users) см. config/auth.php.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.5)
*/
class SalesAuthController extends Controller
{
/**
* Вход менеджера / руководителя продаж.
*
* Валидация: email (required, email) + password (required, string).
* Ошибки: 422 неверные учётные данные, 403 аккаунт отключён.
* Успех: 200 {token, user: {id, name, email, role}}.
*/
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$user = SalesUser::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json(
['message' => 'Неверный логин или пароль.'],
422
);
}
if (! $user->is_active) {
return response()->json(
['message' => 'Аккаунт отключён, обратитесь к начальнику.'],
403
);
}
$token = $user->createToken('sales')->plainTextToken;
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
],
]);
}
/**
* Текущий авторизованный менеджер.
*
* Guard: auth:sales Sanctum Bearer-токен.
* Возвращает: {id, name, email, role}.
*/
public function me(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
]);
}
/**
* Выход инвалидирует текущий токен.
*
* Guard: auth:sales.
* Возвращает: 200 {message}.
*/
public function logout(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$user->currentAccessToken()->delete();
return response()->json(['message' => 'Вы вышли.']);
}
}
@@ -0,0 +1,566 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\DerivesTenantStatus;
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
use App\Http\Controllers\Controller;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodResolver;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж экран «Мои клиенты» + карточка клиента + сводка.
*
* GET /api/sales/clients список (Task 1.3)
* GET /api/sales/clients/{tenantId} карточка (Task 1.4)
* GET /api/sales/overview сводка менеджера (Task 1.5)
*
* Менеджер видит только своих клиентов (через ScopesSalesOwnership);
* начальник (role=head) видит всех.
*
* Параметры периода (оба метода):
* ?period=this|prev|prev2|custom (default: this)
* ?from=YYYY-MM-DD (только для period=custom)
* ?to=YYYY-MM-DD (только для period=custom)
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4, Task 1.5)
*/
class SalesClientsController extends Controller
{
use DerivesTenantStatus;
use ScopesSalesOwnership;
/**
* Список клиентов с метриками периода.
*/
public function index(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
// 1. Период
$period = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
// 2. Tenant scope
$ids = $this->ownedTenantIds($user);
// 3. Базовый запрос: tenants + LEFT JOIN tenant_requisites + LEFT JOIN assignment + tariff
$query = DB::table('tenants')
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
->leftJoin('sales_client_assignments as sca', 'sca.tenant_id', '=', 'tenants.id')
->leftJoin('sales_tariffs as st', 'st.id', '=', 'sca.tariff_id')
->whereNull('tenants.deleted_at')
->select([
'tenants.id as tenant_id',
'tenants.organization_name',
'tenants.status',
'tenants.is_trial',
'tenants.balance_rub',
'tenants.chargeback_unrecovered_rub',
'tenants.last_activity_at',
'tenant_requisites.inn',
'tenant_requisites.subject_type',
'st.name as tariff_name',
]);
// Ограничение по владению: null = начальник (без ограничения)
if ($ids !== null) {
$query->whereIn('tenants.id', $ids === [] ? [-1] : $ids);
}
// Поиск
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$like = '%'.$search.'%';
$query->where(function ($q) use ($like): void {
$q->where('tenants.organization_name', 'ilike', $like)
->orWhere('tenant_requisites.inn', 'ilike', $like);
});
}
$rows = $query
->orderByDesc('tenants.last_activity_at')
->orderBy('tenants.id')
->get();
$metrics = app(SalesMetricsService::class);
$earnings = app(SalesEarningsService::class);
// Снимки тарифов привязок для отображаемых клиентов — доход по каждому.
// Клиент без привязки (начальник видит и незакреплённых) → earned_rub = null.
$assignmentsByTenant = SalesClientAssignment::query()
->whereIn('tenant_id', $rows->pluck('tenant_id')->all())
->get()
->keyBy('tenant_id');
$data = $rows->map(function (object $row) use ($metrics, $earnings, $period, $assignmentsByTenant): array {
$tenantId = (int) $row->tenant_id;
$assignment = $assignmentsByTenant->get($tenantId);
// projects_count: все проекты тенанта (без фильтра по is_active/archived).
// Counting all projects per tenant — active filter can be added if spec clarified.
$projectsCount = DB::table('projects')
->where('tenant_id', $tenantId)
->count();
// Производный статус — зеркалит AdminTenantsController CASE-логику.
$derivedStatus = $this->deriveTenantStatus($row);
return [
'tenant_id' => $tenantId,
'organization_name' => $row->organization_name,
'inn' => $row->inn,
'subject_type' => $row->subject_type,
'last_activity_at' => $row->last_activity_at !== null
? CarbonImmutable::parse($row->last_activity_at)->toIso8601String()
: null,
'balance_rub' => (string) $row->balance_rub,
'status' => $derivedStatus,
'tariff_name' => $row->tariff_name,
'projects_count' => $projectsCount,
'runway_days' => $metrics->runwayDays($tenantId),
'leads_delivered' => $metrics->leadsDelivered($tenantId, $period),
'oborot_rub' => $metrics->oborotRub($tenantId, $period),
'earned_rub' => $assignment !== null
? round($earnings->forAssignment($assignment, $period), 2)
: null,
];
})->all();
return response()->json(['data' => $data]);
}
/**
* Сводка менеджера / начальника.
*
* GET /api/sales/overview
*
* Менеджер видит агрегаты только по своим клиентам.
* Начальник (role=head) видит агрегаты по всем клиентам.
*
* Ответ:
* totals агрегаты по клиентам за период
* attention клиенты, требующие внимания (overdue / suspended / runway=0), до 10, worst-first
* top_clients топ-4 клиента по leads_delivered за период, desc
*
* Производный статус (зеркалит index / AdminTenantsController):
* is_trial=true trial
* status=suspended suspended
* balance<0 / chargeback>0 overdue
* status=active active
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5)
*/
public function overview(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
// 1. Период
$period = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
// 2. Tenant scope (null = начальник = без ограничения)
$ids = $this->ownedTenantIds($user);
// 3. Базовый запрос тенантов
$query = DB::table('tenants')
->whereNull('tenants.deleted_at')
->select([
'tenants.id as tenant_id',
'tenants.organization_name',
'tenants.status',
'tenants.is_trial',
'tenants.balance_rub',
'tenants.chargeback_unrecovered_rub',
]);
if ($ids !== null) {
$query->whereIn('tenants.id', $ids === [] ? [-1] : $ids);
}
$rows = $query->get();
if ($rows->isEmpty()) {
return response()->json([
'totals' => [
'clients_count' => 0,
'active' => 0,
'trial' => 0,
'overdue' => 0,
'balance_sum_rub' => '0.00',
'leads_delivered' => 0,
'oborot_rub' => 0.0,
'earned_rub' => 0.0,
],
'attention' => [],
'top_clients' => [],
]);
}
$metrics = app(SalesMetricsService::class);
// Суммарный доход (комиссия) по всем привязкам в scope — без оклада менеджера.
$earnings = app(SalesEarningsService::class);
$earnedSum = 0.0;
foreach (
SalesClientAssignment::query()
->whereIn('tenant_id', $rows->pluck('tenant_id')->all())
->get() as $assignment
) {
$earnedSum += $earnings->forAssignment($assignment, $period);
}
// 4. Обогащаем строки: derived status + метрики периода
$enriched = $rows->map(function (object $row) use ($metrics, $period): array {
$tenantId = (int) $row->tenant_id;
$derivedStatus = $this->deriveTenantStatus($row);
$leadsDelivered = $metrics->leadsDelivered($tenantId, $period);
$oborotRub = $metrics->oborotRub($tenantId, $period);
$runwayDays = $metrics->runwayDays($tenantId);
return [
'tenant_id' => $tenantId,
'organization_name' => $row->organization_name,
'status' => $derivedStatus,
'balance_rub' => (string) $row->balance_rub,
'leads_delivered' => $leadsDelivered,
'oborot_rub' => $oborotRub,
'runway_days' => $runwayDays,
];
});
// 5. Агрегаты totals
$clientsCount = $enriched->count();
$activeCount = $enriched->where('status', 'active')->count();
$trialCount = $enriched->where('status', 'trial')->count();
$overdueCount = $enriched->where('status', 'overdue')->count();
// balance_sum_rub — суммируем как float, возвращаем строкой (как balance_rub у тенанта)
$balanceSum = $enriched->sum(fn (array $r): float => (float) $r['balance_rub']);
$totalLeads = (int) $enriched->sum('leads_delivered');
$totalOborot = (float) $enriched->sum('oborot_rub');
// 6. attention — overdue / suspended / runway_days === 0; worst-first; ≤10
// runway_days === 0 только для active/trial (у overdue/suspended уже плохо).
// runway_days === null означает «нет активных проектов» — НЕ попадает в attention.
// Приоритет: overdue > suspended > runway=0
$attentionRows = $enriched->filter(
fn (array $r): bool => in_array($r['status'], ['overdue', 'suspended'], true)
|| ($r['runway_days'] === 0 && in_array($r['status'], ['active', 'trial'], true)),
);
// Сортировка worst-first: overdue → suspended → runway=0 → balance desc
$statusPriority = ['overdue' => 0, 'suspended' => 1];
$attentionSorted = $attentionRows->sortBy([
fn (array $a, array $b): int => ($statusPriority[$a['status']] ?? 2) <=> ($statusPriority[$b['status']] ?? 2),
fn (array $a, array $b): int => (float) $a['balance_rub'] <=> (float) $b['balance_rub'],
])->values()->take(10);
$attention = $attentionSorted->map(fn (array $r): array => [
'tenant_id' => $r['tenant_id'],
'organization_name' => $r['organization_name'],
'status' => $r['status'],
'balance_rub' => $r['balance_rub'],
'runway_days' => $r['runway_days'],
])->values()->all();
// 7. top_clients — топ-4 по leads_delivered за период, desc
$topClients = $enriched
->sortByDesc('leads_delivered')
->take(4)
->values()
->map(fn (array $r): array => [
'tenant_id' => $r['tenant_id'],
'organization_name' => $r['organization_name'],
'leads_delivered' => $r['leads_delivered'],
'oborot_rub' => $r['oborot_rub'],
])
->values()
->all();
return response()->json([
'totals' => [
'clients_count' => $clientsCount,
'active' => $activeCount,
'trial' => $trialCount,
'overdue' => $overdueCount,
'balance_sum_rub' => number_format($balanceSum, 2, '.', ''),
'leads_delivered' => $totalLeads,
'oborot_rub' => $totalOborot,
'earned_rub' => round($earnedSum, 2),
],
'attention' => $attention,
'top_clients' => $topClients,
]);
}
/**
* Карточка клиента.
*
* GET /api/sales/clients/{tenantId}
*
* Менеджер может открыть только своего клиента (иначе 403).
* Начальник открывает любого.
*
* Ответ:
* profile анкетные данные тенанта + реквизиты
* kpi текущий баланс, runway, счётчики за период
* projects список проектов тенанта
* leads_by_day лиды по дням (last ~14 дней или в рамках периода)
* recent_leads последние ~20 лидов (телефоны МАСКИРОВАНЫ)
* activity последние ~10 balance_transactions
*/
public function show(Request $request, int $tenantId): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
// 1. Проверка ownership: менеджер может смотреть только своих клиентов
$ids = $this->ownedTenantIds($user);
if ($ids !== null && ! in_array($tenantId, $ids, true)) {
abort(403, 'Этот клиент не закреплён за вами.');
}
// 2. Период для KPI-метрик
$period = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
// 3. Основные данные тенанта + реквизиты
$tenant = DB::table('tenants')
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
->where('tenants.id', $tenantId)
->whereNull('tenants.deleted_at')
->select([
'tenants.id',
'tenants.organization_name',
'tenants.contact_email',
'tenants.desired_daily_numbers',
'tenants.balance_rub',
'tenants.last_activity_at',
'tenants.created_at',
'tenants.status',
'tenants.is_trial',
'tenants.chargeback_unrecovered_rub',
'tenant_requisites.contact_name',
'tenant_requisites.contact_phone',
'tenant_requisites.inn',
'tenant_requisites.subject_type',
'tenant_requisites.legal_address',
])
->first();
if ($tenant === null) {
abort(404, 'Клиент не найден.');
}
// 4. Метрики
$metrics = app(SalesMetricsService::class);
$leadsDelivered = $metrics->leadsDelivered($tenantId, $period);
$oborotRub = $metrics->oborotRub($tenantId, $period);
$runwayDays = $metrics->runwayDays($tenantId);
// Доход менеджера с этого клиента за период — по снимку тарифа привязки.
// Клиент без привязки (начальник открыл незакреплённого) → null.
$assignment = SalesClientAssignment::query()->where('tenant_id', $tenantId)->first();
$earnedRub = $assignment !== null
? round(app(SalesEarningsService::class)->forAssignment($assignment, $period), 2)
: null;
// projects_count: все проекты тенанта
$projectsCount = DB::table('projects')
->where('tenant_id', $tenantId)
->count();
// leads_target: сумма daily_limit_target активных проектов × число дней в периоде
$totalDailyTarget = (int) DB::table('projects')
->where('tenant_id', $tenantId)
->where('is_active', true)
->sum('daily_limit_target');
$daysInPeriod = (int) max(1, $period->start->diffInDays($period->end) + 1);
$leadsTarget = $totalDailyTarget * $daysInPeriod;
$avgLeadPriceRub = $oborotRub / max(1, $leadsDelivered);
// 5. Проекты
$projects = DB::table('projects')
->where('tenant_id', $tenantId)
->orderBy('id')
->limit(100)
->get()
->map(fn (object $p): array => [
'id' => (int) $p->id,
'name' => $p->name,
'signal_type' => $p->signal_type,
'region' => $p->regions ?? [],
'daily_limit_target' => (int) $p->daily_limit_target,
'delivered_today' => (int) $p->delivered_today,
'status' => (bool) $p->is_active ? 'active' : 'paused',
])
->all();
// 6. Лиды по дням (последние 14 дней)
// Оборот за каждый день подтягиваем одним запросом из lead_charges,
// сгруппированным по дню, и мержим с результатами deals.
$last14Start = CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay();
$last14End = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addDay(); // завтра 00:00
$leadsByDayRows = DB::table('deals')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_test', false)
->where('received_at', '>=', $last14Start)
->select([
DB::raw("DATE(received_at AT TIME ZONE 'Europe/Moscow') as day"),
DB::raw('COUNT(*) as cnt'),
])
->groupBy('day')
->orderBy('day')
->get();
// lead_charges за те же 14 дней, сгруппированные по дню (МСК)
$chargesByDayRows = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charged_at', '>=', $last14Start)
->where('charged_at', '<', $last14End)
->select([
DB::raw("DATE(charged_at AT TIME ZONE 'Europe/Moscow') as day"),
DB::raw('SUM(price_per_lead_kopecks) as sum_kopecks'),
])
->groupBy('day')
->get()
->keyBy('day');
$leadsByDayFormatted = $leadsByDayRows->map(function (object $row) use ($chargesByDayRows): array {
$dayStr = (string) $row->day;
$sumKopecks = isset($chargesByDayRows[$dayStr])
? (int) $chargesByDayRows[$dayStr]->sum_kopecks
: 0;
return [
'date' => $dayStr,
'count' => (int) $row->cnt,
'oborot_rub' => $sumKopecks / 100,
];
})->all();
// 7. Последние лиды (~20), телефоны маскированы
$recentLeads = DB::table('deals')
->leftJoin('projects', 'projects.id', '=', 'deals.project_id')
->where('deals.tenant_id', $tenantId)
->whereNull('deals.deleted_at')
->where('deals.is_test', false)
->orderByDesc('deals.received_at')
->limit(20)
->select([
'deals.received_at',
'deals.phone',
'deals.region_code',
'deals.city',
'projects.name as project_name',
'projects.signal_type',
])
->get()
->map(fn (object $d): array => [
'received_at' => CarbonImmutable::parse($d->received_at)->toIso8601String(),
'phone_masked' => $this->maskPhone($d->phone),
'region' => $d->city ?? $d->region_code,
'source' => ($d->project_name ?? '—').($d->signal_type !== null ? ' / '.$d->signal_type : ''),
'project' => $d->project_name,
])
->all();
// 8. Активность — последние 10 balance_transactions
$activity = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->orderByDesc('created_at')
->orderByDesc('id')
->limit(10)
->select(['created_at', 'type', 'amount_rub', 'description'])
->get()
->map(fn (object $tx): array => [
'created_at' => CarbonImmutable::parse($tx->created_at)->toIso8601String(),
'type' => $tx->type,
'amount_rub' => (string) $tx->amount_rub,
'description' => $tx->description,
])
->all();
return response()->json([
'profile' => [
'organization_name' => $tenant->organization_name,
'contact_email' => $tenant->contact_email,
'contact_name' => $tenant->contact_name,
'contact_phone' => $tenant->contact_phone,
'inn' => $tenant->inn,
'subject_type' => $tenant->subject_type,
'created_at' => $tenant->created_at !== null
? CarbonImmutable::parse($tenant->created_at)->toIso8601String()
: null,
'desired_daily_numbers' => $tenant->desired_daily_numbers,
'last_activity_at' => $tenant->last_activity_at !== null
? CarbonImmutable::parse($tenant->last_activity_at)->toIso8601String()
: null,
],
'kpi' => [
'balance_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
'projects_count' => $projectsCount,
'leads_delivered' => $leadsDelivered,
'leads_target' => $leadsTarget,
'avg_lead_price_rub' => $avgLeadPriceRub,
'earned_rub' => $earnedRub,
],
'projects' => $projects,
'leads_by_day' => $leadsByDayFormatted,
'recent_leads' => $recentLeads,
'activity' => $activity,
]);
}
/**
* Маска телефона по 152-ФЗ: видны первые 2 цифры и 2 последних.
*
* Пример: «79161234567» «79** *** ** 67»
*
* Зеркало AdminLeadsController::maskPhone единый подход к маскированию ПДн.
*/
private function maskPhone(?string $phone): string
{
if (! $phone) {
return '—';
}
$digits = preg_replace('/\D/', '', $phone);
if (strlen((string) $digits) < 4) {
return '***';
}
$last2 = substr((string) $digits, -2);
$first = substr((string) $digits, 0, 2);
return $first.'** *** ** '.$last2;
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\DerivesTenantStatus;
use App\Http\Controllers\Controller;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж сводка отдела «Дашборд начальника» (Task 6.1a, бэк).
*
* GET /api/sales/dashboard/overview сводка по ВСЕМУ отделу за период.
*
* Метод ТОЛЬКО начальник отдела (role=head). Менеджер 403.
*
* Считает по всему отделу: все менеджеры (role=manager) и все закреплённые
* клиенты (tenant_id из sales_client_assignments). Метрики периода
* SalesMetricsService (oborot/leads/runway), доход SalesEarningsService::forManager
* (оклад × месяцы + Σ комиссий по снимку тарифа привязки).
*
* Денежные правила:
* - balance_sum_rub Σ balance_rub закреплённых тенантов, number_format(2) как строка.
* - oborot_rub Σ oborotRub закреплённых тенантов за период (kopecks-точный внутри сервиса).
* - paid_period_rub Σ sales_payouts по дате paid_on [range.start .. range.end] (закрытый).
*
* Производный статус клиента зеркалит SalesClientsController::deriveTenantStatus.
*
* Работает под middleware admin-db (crm_admin_user) через DEFAULT connection.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 6.1a)
*/
class SalesDashboardController extends Controller
{
use DerivesTenantStatus;
/**
* GET /api/sales/dashboard/overview сводка отдела (только head).
*/
public function overview(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$range = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
$metrics = app(SalesMetricsService::class);
$earnings = app(SalesEarningsService::class);
// Все менеджеры отдела с их привязками.
$managers = SalesUser::query()
->where('role', 'manager')
->with('assignments')
->orderBy('name')
->get();
// Все закреплённые тенанты (по всем привязкам отдела).
$assignedTenantIds = SalesClientAssignment::query()
->pluck('tenant_id')
->map(static fn ($id): int => (int) $id)
->all();
// Данные закреплённых тенантов (balance + статусные поля) одним запросом.
$tenantRows = $assignedTenantIds === []
? collect()
: DB::table('tenants')
->whereIn('id', $assignedTenantIds)
->whereNull('deleted_at')
->select(['id', 'status', 'is_trial', 'balance_rub', 'chargeback_unrecovered_rub'])
->get()
->keyBy('id');
// ── kpi ──────────────────────────────────────────────────────────────
$managersCount = $managers->count();
$managersActive = $managers->where('is_active', true)->count();
$managersVacation = $managersCount - $managersActive;
$clientsCount = count($assignedTenantIds);
$balanceSum = 0.0;
$oborotSum = 0.0;
$balanceProblem = 0;
foreach ($tenantRows as $row) {
$balanceSum += (float) $row->balance_rub;
$tenantId = (int) $row->id;
$oborotSum += $metrics->oborotRub($tenantId, $range);
$status = $this->deriveTenantStatus($row);
if (in_array($status, ['overdue', 'suspended'], true) || $metrics->runwayDays($tenantId) === 0) {
$balanceProblem++;
}
}
// paid_period_rub — Σ всех выплат отдела по paid_on в периоде (закрытый интервал по дате).
$paidPeriod = (float) DB::table('sales_payouts')
->where('paid_on', '>=', $range->start->toDateString())
->where('paid_on', '<=', $range->end->toDateString())
->sum('amount_rub');
// ── alerts ───────────────────────────────────────────────────────────
$invoicesAwaiting = (int) DB::table('saas_invoices')
->whereIn('status', ['issued', 'overdue'])
->count();
$attachmentsPending = (int) DB::table('sales_attachment_requests')
->where('status', 'pending')
->count();
// ── performance (по каждому менеджеру) ─────────────────────────────────
$performance = $managers->map(function (SalesUser $manager) use ($metrics, $earnings, $range): array {
$leads = 0;
$oborot = 0.0;
foreach ($manager->assignments as $assignment) {
$tenantId = (int) $assignment->tenant_id;
$leads += $metrics->leadsDelivered($tenantId, $range);
$oborot += $metrics->oborotRub($tenantId, $range);
}
$paidAllTime = (float) $manager->payouts()->sum('amount_rub');
return [
'manager_id' => $manager->id,
'name' => $manager->name,
'clients_count' => $manager->assignments->count(),
'leads_delivered' => $leads,
'oborot_rub' => round($oborot, 2),
'paid_all_time_rub' => round($paidAllTime, 2),
'earned_rub' => round($earnings->forManager($manager, $range), 2),
];
})->values()->all();
return response()->json([
'kpi' => [
'managers_count' => $managersCount,
'managers_active' => $managersActive,
'managers_vacation' => $managersVacation,
'clients_count' => $clientsCount,
'balance_sum_rub' => number_format($balanceSum, 2, '.', ''),
'oborot_rub' => round($oborotSum, 2),
'paid_period_rub' => round($paidPeriod, 2),
],
'alerts' => [
'invoices_awaiting' => $invoicesAwaiting,
'attachments_pending' => $attachmentsPending,
'clients_balance_problem' => $balanceProblem,
],
'performance' => $performance,
]);
}
// ── private ──────────────────────────────────────────────────────────────
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
private function denyIfNotHead(Request $request): ?JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if (! $user->isHead()) {
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
}
return null;
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPayoutService;
use App\Services\Sales\SalesPeriodResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж сводка «Мой доход» авторизованного менеджера (Task 4.2a).
*
* GET /api/sales/income totals + per_client + payouts (журнал СВОИХ выплат).
*
* Считается по авторизованному sales-пользователю ($request->user('sales')).
* Head без закреплённых клиентов нули (assignments пуст).
*
* Денежная семантика (снимок тарифа клиента, как в SalesEarningsService):
* totals.oborot_rub Σ oborotRub по всем клиентам менеджера за период.
* totals.accrued_rub forManager(period) = суточный оклад (тариф daily_salary) + Σ комиссий.
* totals.paid_all_time_rub Σ всех выплат менеджера (из remaining()).
* totals.to_pay_period_rub remaining_rub = начислено выплачено за период.
* per_client строки таблицы «Начислено по клиентам».
* payouts журнал выплат ЭТОГО менеджера (ownOnly=true).
*
* Работает через DEFAULT connection (под middleware admin-db), как и остальные
* sales-сервисы. Стиль инъекции через app(...), как в SalesClientsController.
*/
class SalesIncomeController extends Controller
{
/**
* Человекочитаемые названия тарифов по виду (kind).
*/
private const TARIFF_LABELS = [
'daily_salary' => 'Суточный оклад',
'topup_step' => 'Процент от пополнений',
];
public function show(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$range = app(SalesPeriodResolver::class)->resolve([
'kind' => (string) $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
$earnings = app(SalesEarningsService::class);
$metrics = app(SalesMetricsService::class);
$payouts = app(SalesPayoutService::class);
// per_client: снимки тарифов привязок менеджера за период.
$perClientRaw = $earnings->perClient($user, $range);
// Обогащаем organization_name одним запросом по всем клиентам менеджера.
$tenantIds = array_map(
static fn (array $row): int => $row['tenant_id'],
$perClientRaw,
);
$orgNames = $tenantIds === []
? collect()
: DB::table('tenants')
->whereIn('id', $tenantIds)
->pluck('organization_name', 'id');
// Σ оборота по клиентам менеджера за период.
$oborotTotal = 0.0;
$perClient = array_map(function (array $row) use ($metrics, $range, $orgNames, &$oborotTotal): array {
$tenantId = $row['tenant_id'];
$oborotTotal += $metrics->oborotRub($tenantId, $range);
return [
'tenant_id' => $tenantId,
'organization_name' => $orgNames->get($tenantId),
'tariff_kind' => $row['tariff_kind'],
'tariff_label' => $this->tariffLabel($row['tariff_kind']),
'topups_rub' => $row['topups_rub'],
'earned_rub' => $row['earned_rub'],
];
}, $perClientRaw);
$remaining = $payouts->remaining($user, $range);
return response()->json([
'totals' => [
'oborot_rub' => round($oborotTotal, 2),
'accrued_rub' => $remaining['accrued_rub'],
'paid_all_time_rub' => $remaining['paid_all_time_rub'],
'to_pay_period_rub' => $remaining['remaining_rub'],
],
'per_client' => $perClient,
'payouts' => $payouts->journal($user, ownOnly: true),
]);
}
/**
* Человекочитаемое название тарифа по виду. Неизвестный/null «—».
*/
private function tariffLabel(?string $kind): string
{
return self::TARIFF_LABELS[$kind] ?? '—';
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж счета клиентов + ручная отметка оплаты (Task 5.1a).
*
* GET /api/sales/invoices список счетов (saas_invoices + tenant)
* POST /api/sales/invoices/{id}/mark-paid отметить счёт оплаченным
*
* ОБА метода ТОЛЬКО начальник отдела (role=head). Менеджер 403.
*
* Зачисление НЕ дублируется: делегируется InvoicePaymentService::markPaid
* (идемпотентный claim issued→paid + зачисление баланса BillingTopupService +
* акт + письмо клиенту, под tenant RLS-контекстом). Мимикрирует AdminInvoiceController,
* добавляя head-гейт зоны продаж.
*
* Spec: Task 5.1a бэк SalesInvoiceController.
*/
class SalesInvoiceController extends Controller
{
public function __construct(private readonly InvoicePaymentService $payments) {}
/**
* GET /api/sales/invoices список счетов (только head).
*/
public function index(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
$query = DB::table('saas_invoices as i')
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
->select(
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
);
$status = $request->query('status');
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
$query->where('i.status', $status);
}
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('i.invoice_number', 'ilike', "%{$search}%")
->orWhere('i.payer_name', 'ilike', "%{$search}%")
->orWhere('t.organization_name', 'ilike', "%{$search}%");
});
}
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
return response()->json([
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
/**
* POST /api/sales/invoices/{id}/mark-paid отметить счёт оплаченным (только head).
*
* Зачисление делегируется InvoicePaymentService (идемпотентно, под RLS).
*/
public function markPaid(Request $request, int $id): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$this->payments->markPaid($id);
return response()->json(['status' => 'ok']);
}
// ── private ──────────────────────────────────────────────────────────────
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
private function denyIfNotHead(Request $request): ?JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if (! $user->isHead()) {
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
}
return null;
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\DerivesTenantStatus;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
use App\Services\Sales\SalesAccountService;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж результативность менеджеров «Дашборд начальника» (Task 6.2a, бэк).
*
* GET /api/sales/managers/performance строка на каждого менеджера (role=manager)
* за период с метриками и производным статусом.
*
* Метод ТОЛЬКО начальник отдела (role=head). Менеджер 403.
*
* По каждому менеджеру:
* clients_count число привязок (закреплённых клиентов);
* active_clients из них с производным статусом 'active';
* leads_delivered Σ доставленных лидов по клиентам за период;
* oborot_rub Σ оборота по клиентам за период (kopecks-точно внутри сервиса);
* paid_all_time_rub Σ всех выплат менеджера (без ограничения периодом);
* earned_rub forManager(period) = оклад × месяцы + Σ комиссий по снимку тарифа;
* status is_active ? 'active' : 'vacation'.
*
* Опц. фильтр ?search=имя ILIKE по имени менеджера.
*
* Производный статус клиента зеркалит SalesClientsController::deriveTenantStatus.
*
* Работает под middleware admin-db (crm_admin_user) через DEFAULT connection.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 6.2a)
*/
class SalesManagersController extends Controller
{
use DerivesTenantStatus;
/**
* GET /api/sales/managers/performance результативность менеджеров (только head).
*/
public function performance(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$range = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
$metrics = app(SalesMetricsService::class);
$earnings = app(SalesEarningsService::class);
// Все менеджеры отдела с их привязками (опц. фильтр по имени).
$query = SalesUser::query()
->where('role', 'manager')
->with('assignments')
->orderBy('name');
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where('name', 'ilike', '%'.$search.'%');
}
$managers = $query->get();
$data = $managers->map(function (SalesUser $manager) use ($metrics, $earnings, $range): array {
$tenantIds = $manager->assignments
->map(static fn ($a): int => (int) $a->tenant_id)
->all();
$leads = 0;
$oborot = 0.0;
foreach ($tenantIds as $tenantId) {
$leads += $metrics->leadsDelivered($tenantId, $range);
$oborot += $metrics->oborotRub($tenantId, $range);
}
// active_clients: тенанты привязок с производным статусом 'active'.
// Один запрос на менеджера по его tenant_ids.
$activeClients = 0;
if ($tenantIds !== []) {
$tenantRows = DB::table('tenants')
->whereIn('id', $tenantIds)
->whereNull('deleted_at')
->select(['id', 'status', 'is_trial', 'balance_rub', 'chargeback_unrecovered_rub'])
->get();
foreach ($tenantRows as $row) {
if ($this->deriveTenantStatus($row) === 'active') {
$activeClients++;
}
}
}
$paidAllTime = (float) $manager->payouts()->sum('amount_rub');
return [
'manager_id' => $manager->id,
'name' => $manager->name,
'email' => $manager->email,
'clients_count' => $manager->assignments->count(),
'active_clients' => $activeClients,
'leads_delivered' => $leads,
'oborot_rub' => round($oborot, 2),
'paid_all_time_rub' => round($paidAllTime, 2),
'earned_rub' => round($earnings->forManager($manager, $range), 2),
'status' => $manager->is_active ? 'active' : 'vacation',
];
})->values()->all();
return response()->json(['data' => $data]);
}
/**
* POST /api/sales/managers создать аккаунт менеджера/начальника (только head).
*/
public function store(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:sales_users,email'],
'password' => ['required', 'string', 'min:6'],
'role' => ['required', 'in:manager,head'],
]);
/** @var SalesUser $head */
$head = $request->user('sales');
$created = app(SalesAccountService::class)->create(
$head,
$validated['name'],
$validated['email'],
$validated['password'],
$validated['role'],
);
return response()->json([
'id' => $created->id,
'name' => $created->name,
'email' => $created->email,
'role' => $created->role,
'is_active' => $created->is_active,
], 201);
}
/**
* GET /api/sales/managers список всех аккаунтов отдела (только head).
*
* Сортировка: head сначала (role desc: 'manager' < 'head'), затем по name.
* По каждому: clients_count (число привязок), paid_all_time_rub (Σ выплат).
*/
public function index(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$users = SalesUser::query()
->withCount('assignments')
->withSum('payouts', 'amount_rub')
->orderByDesc('role')
->orderBy('name')
->get();
$data = $users->map(static fn (SalesUser $user): array => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'is_active' => $user->is_active,
'clients_count' => (int) $user->assignments_count,
'paid_all_time_rub' => (float) ($user->payouts_sum_amount_rub ?? 0),
])->values()->all();
return response()->json(['data' => $data]);
}
// ── private ──────────────────────────────────────────────────────────────
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
private function denyIfNotHead(Request $request): ?JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if (! $user->isHead()) {
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
}
return null;
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPayoutService;
use App\Services\Sales\SalesPeriodResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Портал продаж выплаты менеджерам (Task 4.1).
*
* POST /api/sales/payouts head проводит выплату (append-only).
* GET /api/sales/payouts журнал (менеджер свои, head все).
* GET /api/sales/payouts/remaining head: начислено/выплачено/остаток по менеджерам.
*
* store и remaining ТОЛЬКО начальник (head). Менеджер 403.
* index доступен и менеджеру (видит только свои выплаты).
*
* Денежная семантика: журнал sales_payouts append-only (DB-триггер запрещает
* UPDATE/DELETE). remaining = начислено за период выплачено в периоде (по дате
* paid_on). Начисление считается по СНИМКУ тарифа клиента (SalesEarningsService).
*/
class SalesPayoutController extends Controller
{
public function __construct(
private readonly SalesPayoutService $payouts,
private readonly SalesMetricsService $metrics,
private readonly SalesPeriodResolver $resolver,
) {}
/**
* POST /api/sales/payouts провести выплату менеджеру (только head).
*/
public function store(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
/** @var SalesUser $head */
$head = $request->user('sales');
$data = $request->validate([
'manager_id' => [
'required',
'integer',
// Менеджер должен существовать И иметь роль manager (не head).
Rule::exists('sales_users', 'id')->where('role', 'manager'),
],
'amount_rub' => ['required', 'numeric', 'min:0.01'],
'paid_on' => ['required', 'date'],
'comment' => ['nullable', 'string', 'max:500'],
]);
$payout = $this->payouts->record(
$head,
(int) $data['manager_id'],
(string) $data['amount_rub'],
(string) $data['paid_on'],
$data['comment'] ?? null,
);
return response()->json($this->payouts->row($payout), 201);
}
/**
* GET /api/sales/payouts журнал выплат.
*
* Менеджер только свои; начальник все.
*/
public function index(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$rows = $this->payouts->journal($user, ownOnly: ! $user->isHead());
return response()->json(['data' => $rows]);
}
/**
* GET /api/sales/payouts/remaining остаток к выплате по менеджерам (только head).
*/
public function remaining(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$range = $this->resolver->resolve([
'kind' => (string) $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
$managers = SalesUser::query()
->where('role', 'manager')
->with('assignments')
->orderBy('name')
->get();
$rows = $managers->map(function (SalesUser $manager) use ($range): array {
$oborot = 0.0;
foreach ($manager->assignments as $assignment) {
$oborot += $this->metrics->oborotRub((int) $assignment->tenant_id, $range);
}
$remaining = $this->payouts->remaining($manager, $range);
return [
'manager_id' => $manager->id,
'name' => $manager->name,
'clients_count' => $manager->assignments->count(),
'oborot_rub' => round($oborot, 2),
'accrued_rub' => $remaining['accrued_rub'],
'paid_period_rub' => $remaining['paid_period_rub'],
'paid_all_time_rub' => $remaining['paid_all_time_rub'],
'remaining_rub' => $remaining['remaining_rub'],
];
})->all();
return response()->json(['data' => $rows]);
}
// ── private ──────────────────────────────────────────────────────────────
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
private function denyIfNotHead(Request $request): ?JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if (! $user->isHead()) {
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
}
return null;
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodRange;
use App\Services\Sales\SalesPeriodResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Портал продаж каталог тарифов + назначение текущего тарифа менеджеру (Task 3.2a).
*
* GET /api/sales/tariffs каталог активных тарифов + менеджеры с метриками
* POST /api/sales/tariffs создать тариф-шаблон
* PUT /api/sales/tariffs/{id} обновить name/params тарифа (kind менять нельзя)
* POST /api/sales/tariffs/assign назначить менеджеру текущий тариф + оклад
*
* ВСЕ методы ТОЛЬКО начальник отдела (role=head). Менеджер 403.
*
* Денежная семантика (В12):
* - Тариф это ШАБЛОН. Уже привязанные клиенты держат СНИМОК тарифа
* (SalesClientAssignment.tariff_kind / tariff_params) и НЕ следуют live-правкам.
* - update правит только шаблон; assign правит только current_tariff_id /
* base_salary_rub менеджера. Ни один из методов НЕ трогает
* sales_client_assignments.
*
* estimated_earned_rub информационная ОЦЕНКА «если бы все клиенты менеджера
* были на его текущем тарифе за период». По факту доход идёт по снимку каждого
* клиента (SalesEarningsService::forAssignment).
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md Task 3.2 Step 1.
*/
class SalesTariffController extends Controller
{
public function __construct(
private readonly SalesMetricsService $metrics,
private readonly SalesEarningsService $earnings,
private readonly SalesPeriodResolver $resolver,
) {}
/**
* GET /api/sales/tariffs каталог активных тарифов + менеджеры с метриками периода.
*/
public function index(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$range = $this->resolver->resolve([
'kind' => (string) $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
$tariffs = SalesTariff::query()
->where('is_active', true)
->orderBy('id')
->get()
->map(fn (SalesTariff $t): array => [
'id' => $t->id,
'name' => $t->name,
'kind' => $t->kind,
'params' => $t->params,
])
->all();
$managers = SalesUser::query()
->where('role', 'manager')
->with(['assignments', 'currentTariff'])
->orderBy('name')
->get()
->map(fn (SalesUser $m): array => $this->managerRow($m, $range))
->all();
return response()->json([
'tariffs' => $tariffs,
'managers' => $managers,
]);
}
/**
* POST /api/sales/tariffs создать тариф-шаблон.
*/
public function store(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$data = $this->validateTariff($request);
$tariff = SalesTariff::create([
'name' => $data['name'],
'kind' => $data['kind'],
'params' => $data['params'],
'is_active' => true,
]);
return response()->json($this->tariffRow($tariff), 201);
}
/**
* PUT/PATCH /api/sales/tariffs/{id} обновить name/params (kind менять нельзя).
*
* Правит только шаблон-тариф; sales_client_assignments (snapshot) не трогаются.
*/
public function update(Request $request, int $id): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$tariff = SalesTariff::find($id);
if ($tariff === null) {
return response()->json(['message' => 'Тариф не найден.'], 404);
}
// Смена kind запрещена — иначе снимки/расчёты рассинхронизируются.
if ($request->input('kind') !== null && $request->input('kind') !== $tariff->kind) {
throw ValidationException::withMessages([
'kind' => 'Тип тарифа менять нельзя.',
]);
}
// Валидируем params по СУЩЕСТВУЮЩЕМУ kind (в запросе kind совпадает или отсутствует).
$request->merge(['kind' => $tariff->kind]);
$data = $this->validateTariff($request);
$tariff->update([
'name' => $data['name'],
'params' => $data['params'],
]);
return response()->json($this->tariffRow($tariff));
}
/**
* POST /api/sales/tariffs/assign назначить менеджеру текущий тариф (+ оклад).
*
* Правит только current_tariff_id / base_salary_rub менеджера.
* НЕ трогает существующие sales_client_assignments (снимки клиентов).
*/
public function assign(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$data = $request->validate([
'manager_id' => [
'required',
'integer',
// Менеджер должен существовать И иметь роль manager (не head).
Rule::exists('sales_users', 'id')->where('role', 'manager'),
],
'tariff_id' => ['required', 'integer', Rule::exists('sales_tariffs', 'id')],
'base_salary_rub' => ['sometimes', 'numeric', 'min:0'],
]);
/** @var SalesUser $manager */
$manager = SalesUser::query()->findOrFail($data['manager_id']);
$manager->current_tariff_id = (int) $data['tariff_id'];
if (array_key_exists('base_salary_rub', $data)) {
$manager->base_salary_rub = (string) $data['base_salary_rub'];
}
$manager->save();
return response()->json([
'id' => $manager->id,
'name' => $manager->name,
'current_tariff_id' => $manager->current_tariff_id,
'base_salary_rub' => (float) $manager->base_salary_rub,
]);
}
// ── private ──────────────────────────────────────────────────────────────
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
private function denyIfNotHead(Request $request): ?JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
if (! $user->isHead()) {
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
}
return null;
}
/**
* Валидация тарифа: базовые поля + структура params по kind.
*
* @return array{name: string, kind: string, params: array<string,mixed>}
*/
private function validateTariff(Request $request): array
{
$base = $request->validate([
'name' => ['required', 'string', 'max:255'],
'kind' => ['required', Rule::in(['daily_salary', 'topup_step'])],
'params' => ['nullable', 'array'],
]);
$rules = match ($base['kind']) {
// Процент от пополнений с порогом + разовым бонусом. Ступени опциональны
// (можно завести чистый бонус-тариф). Все поля настраиваются начальником.
'topup_step' => [
'params.threshold' => ['required', 'numeric', 'min:0'],
'params.reward' => ['required', 'numeric', 'min:0'],
'params.periods' => ['nullable', 'array'],
'params.periods.*.from' => ['required', 'integer', 'min:1'],
'params.periods.*.to' => ['required', 'integer', 'gte:params.periods.*.from'],
'params.periods.*.rate' => ['required', 'numeric', 'min:0'],
],
// Суточный оклад: ставка ₽/день задаётся per-manager (assign → base_salary_rub),
// не в params тарифа. Сам тариф — маркер режима, params = {}.
'daily_salary' => [],
default => [],
};
$request->validate($rules);
/** @var array<string,mixed> $params */
$params = $base['params'] ?? [];
return [
'name' => (string) $base['name'],
'kind' => (string) $base['kind'],
'params' => $params,
];
}
/**
* @return array{id: int, name: string, kind: string, params: array<string,mixed>}
*/
private function tariffRow(SalesTariff $tariff): array
{
return [
'id' => $tariff->id,
'name' => $tariff->name,
'kind' => $tariff->kind,
'params' => $tariff->params,
];
}
/**
* Строка менеджера с метриками периода и оценкой начисления.
*
* estimated_earned_rub оценка «если бы все клиенты были на текущем тарифе менеджера»
* (для daily_salary = суточный оклад; для topup_step = Σ по клиентам)
* SalesEarningsService::estimatedForCurrentTariff.
*
* @return array<string, mixed>
*/
private function managerRow(SalesUser $manager, SalesPeriodRange $range): array
{
$tenantIds = $manager->assignments->pluck('tenant_id')->all();
$topups = 0.0;
$oborot = 0.0;
foreach ($tenantIds as $tenantId) {
$topups += $this->metrics->topupsRub((int) $tenantId, $range);
$oborot += $this->metrics->oborotRub((int) $tenantId, $range);
}
return [
'id' => $manager->id,
'name' => $manager->name,
'current_tariff_id' => $manager->current_tariff_id,
'current_tariff_name' => $manager->currentTariff?->name,
'current_tariff_kind' => $manager->currentTariff?->kind,
'base_salary_rub' => (float) $manager->base_salary_rub,
'clients_count' => $manager->assignments->count(),
'topups_rub' => round($topups, 2),
'oborot_rub' => round($oborot, 2),
'estimated_earned_rub' => $this->earnings->estimatedForCurrentTariff($manager, $range),
];
}
}
@@ -7,7 +7,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use Illuminate\Database\Query\Builder;
use App\Models\LeadCharge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -37,37 +38,16 @@ class TenantChargesController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
$period = $request->query('period');
$source = $request->query('charge_source');
// Explicit tenant_id фильтр — defense-in-depth поверх RLS (в тестах PG superuser BYPASSRLS).
// Лид-списания.
$lead = DB::table('lead_charges')
// Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG
// superuser BYPASSRLS, и без явного where() запрос вернул бы строки
// других тенантов (см. InAppNotificationController паттерн).
$query = LeadCharge::query()
->where('tenant_id', $tenantId)
->selectRaw("id, charged_at, 'lead' as kind, deal_id, tier_no, charge_source, price_per_lead_kopecks, cast(null as text) as label");
$this->applyPeriodTo($lead, 'charged_at', $period);
if (is_string($source) && $source !== '') {
$lead->where('charge_source', $source);
}
->orderBy('charged_at', 'desc');
// Списания автоподбора (balance_transactions) — тоже показываем в «Списаниях», иначе клиент
// видит их только в «Истории транзакций». Фильтр charge_source (prepaid/rub) к автоподбору
// неприменим → при выбранном источнике автоподбор не подмешиваем.
if (! is_string($source) || $source === '') {
$auto = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->selectRaw("id, created_at as charged_at, 'autopodbor' as kind, cast(null as bigint) as deal_id, cast(null as integer) as tier_no, cast(null as varchar) as charge_source, cast(round(abs(amount_rub) * 100) as bigint) as price_per_lead_kopecks, cast('Автоподбор конкурентов' as text) as label");
$this->applyPeriodTo($auto, 'created_at', $period);
$rows = $lead->unionAll($auto);
} else {
$rows = $lead;
}
$this->applyFilters($query, $request);
$page = DB::query()->fromSub($rows, 'c')
->orderByDesc('charged_at')
->orderByDesc('id')
->paginate(20);
$page = $query->paginate(20);
return response()->json([
'data' => $page->items(),
@@ -161,29 +141,6 @@ class TenantChargesController extends Controller
}
});
// Списания автоподбора — тоже в CSV (после лид-строк). charge_source к ним неприменим,
// поэтому при выбранном источнике их не выгружаем (как и в списке).
if ($source === null || $source === '') {
$autoq = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->orderByDesc('created_at')
->orderByDesc('id');
$this->applyPeriodTo($autoq, 'created_at', $period);
$autoq->chunk(500, function ($rows) use ($out) {
foreach ($rows as $r) {
fputcsv($out, [
Carbon::parse($r->created_at)->toIso8601String(),
'Автоподбор конкурентов',
'',
'',
number_format(abs((float) $r->amount_rub), 2, '.', ''),
$r->balance_rub_after ?? '',
]);
}
});
}
fclose($out);
});
}, 200, [
@@ -193,12 +150,22 @@ class TenantChargesController extends Controller
}
/**
* Фильтр периода на произвольную колонку/билдер (lead_charges.charged_at либо
* balance_transactions.created_at) единый для лид- и автоподбор-веток union.
*
* @param Builder $query
* @param Builder<LeadCharge> $query
*/
private function applyPeriodTo($query, string $column, mixed $period): void
private function applyFilters($query, Request $request): void
{
$this->applyPeriodFilter($query, $request->query('period'));
$source = $request->query('charge_source');
if (is_string($source) && $source !== '') {
$query->where('charge_source', $source);
}
}
/**
* @param Builder<LeadCharge> $query
*/
private function applyPeriodFilter($query, mixed $period): void
{
if (! is_string($period) || $period === '') {
return;
@@ -207,14 +174,14 @@ class TenantChargesController extends Controller
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where($column, '>=', $now->copy()->startOfMonth());
$query->where('charged_at', '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween($column, [
$query->whereBetween('charged_at', [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where($column, '>=', $now->copy()->subDays(90));
$query->where('charged_at', '>=', $now->copy()->subDays(90));
}
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
/**
* Производный статус тенанта единый источник для sales-контроллеров портала продаж.
*
* Зеркалит CASE-логику AdminTenantsController (единый статусный словарь платформы).
* Приоритет: trial > suspended > overdue (balance<0 | chargeback>0) > active > raw status.
*
* Требует, чтобы $row содержал поля is_trial, status, balance_rub, chargeback_unrecovered_rub.
*/
trait DerivesTenantStatus
{
/**
* Производный статус тенанта.
*/
protected function deriveTenantStatus(object $row): string
{
return match (true) {
(bool) $row->is_trial => 'trial',
$row->status === 'suspended' => 'suspended',
(float) $row->chargeback_unrecovered_rub > 0 || (float) $row->balance_rub < 0 => 'overdue',
$row->status === 'active' => 'active',
default => (string) $row->status,
};
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use Illuminate\Database\Eloquent\Builder;
/**
* Ограничение ownership для портала отдела продаж.
*
* Менеджер видит только клиентов, закреплённых за ним (tenant_ids из
* sales_client_assignments). Начальник (role='head') видит всех null
* означает «без ограничения».
*
* Используется в контроллерах /api/sales/* для автоматической фильтрации
* данных в зависимости от роли авторизованного пользователя.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
*/
trait ScopesSalesOwnership
{
/**
* null => начальник (видит всех); массив => менеджер (только эти tenant_id).
*
* @return list<int>|null
*/
protected function ownedTenantIds(SalesUser $user): ?array
{
if ($user->isHead()) {
return null;
}
/** @var list<int> $ids */
$ids = SalesClientAssignment::query()
->where('sales_user_id', $user->id)
->pluck('tenant_id')
->all();
return $ids;
}
/**
* Применяет фильтр владения к Eloquent-запросу.
*
* Для начальника возвращает запрос без изменений (видит всё).
* Для менеджера добавляет whereIn по $column.
* Если у менеджера нет закреплённых клиентов подставляет [-1],
* чтобы запрос вернул пустую коллекцию.
*
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
protected function scopeByOwnership(Builder $query, SalesUser $user, string $column = 'tenant_id'): Builder
{
$ids = $this->ownedTenantIds($user);
if ($ids === null) {
return $query; // начальник — без ограничения
}
return $query->whereIn($column, $ids === [] ? [-1] : $ids); // пустой → ничего
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Гейт для зоны /api/sales/*.
*
* Проверяет, что входящий запрос аутентифицирован через guard «sales»
* (Sanctum, провайдер sales_users) и что аккаунт активен (is_active=true).
*
* Применяется через псевдоним 'sales-portal', зарегистрированный в
* bootstrap/app.php.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
*/
class EnsureSalesUser
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user('sales');
if ($user === null || ! $user->is_active) {
abort(401, 'Требуется вход в портал отдела продаж.');
}
return $next($request);
}
}
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CompetitorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'is_federal' => $this->is_federal,
'relevance_pct' => $this->relevance_pct,
'origin' => $this->origin,
'box' => $this->box,
'site_url' => $this->site_url,
'directory_urls' => $this->directory_urls,
'elements' => $this->elements ?? [],
'studied_at' => $this->studied_at?->toIso8601String(),
'study_run_id' => $this->study_run_id,
'search_run_id' => $this->search_run_id,
];
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\AutopodborQueue;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RunResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'kind' => $this->kind,
'competitor_id' => $this->competitor_id,
'status' => $this->status,
'region_code' => $this->region_code,
'params' => $this->params,
'price_rub_charged' => $this->price_rub_charged,
'error_code' => $this->error_code,
// Клиенту показать, что идёт работа: место в общей очереди (пока queued) и текущий этап (пока running).
'queue_position' => $this->status === 'queued' ? app(AutopodborQueue::class)->pending() : null,
'progress' => $this->progress,
'result' => $this->result,
'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(),
'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(),
'started_at' => $this->started_at?->toIso8601String(),
'finished_at' => $this->finished_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SourceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'competitor_id' => $this->competitor_id,
'signal_type' => $this->signal_type,
'identifier' => $this->identifier,
'phone_kind' => $this->phone_kind,
'phone_type' => $this->phone_type,
'box' => $this->box,
'provenance_url' => $this->provenance_url,
'provenance_label' => $this->provenance_label,
'created_project_id' => $this->created_project_id,
'where_found' => $this->where_found ?? [],
'office' => $this->office,
'confirmations' => $this->confirmations,
];
}
}
@@ -52,8 +52,6 @@ class ProjectResource extends JsonResource
'last_synced_at' => $this->aggregateLastSyncedAt(),
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
'balance_blocked' => $this->preflight_blocked_at !== null,
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\AutopodborDedup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class RunAutopodborResolveJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Резолв по имени — тоже шаг 1 (ИИ-поиск кандидатов), поэтому на одной очереди с поиском.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
try {
$p = $run->params;
$res = $agent->resolveByName(new ResolveByNameRequest(
name: $p['name'],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupCompetitors($res->candidates);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach ($unique as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => null,
'origin' => 'resolve',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
'provenance' => $c['provenance'] ?? [],
]
);
}
$run->update(['status' => 'done', 'finished_at' => now()]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
@@ -1,216 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Mail\AutopodborReadyMail;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\CompetitorIdentity;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// ОДИН прогон без авто-ретраев: движок платный (xfetch+ИИ+EXA), и повтор всего прогона при
// таймауте = тройная цена и время. Медленный-но-настоящий ответ теперь ждём большими таймаутами
// стадий (config services.aitunnel/exa), а не гоняем движок трижды.
public int $tries = 1;
// Живой поиск (2ГИС+Яндекс через антибот + канал В sonar ×2 + EXA + «вскрыть всё»: параллельное
// открытие карточек без лимита) идёт минутами — большой запас, чтобы не убить прогон на середине.
public int $timeout = 3600;
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 1 «собрать поле» (ИИ + внешние сервисы) — своя очередь, отдельный воркер-процесс,
// чтобы тяжёлый поиск не стоял в одной линии с быстрым шагом 2 (изучением) другого клиента.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом
* (ретрай/повторный dispatch). Ключ runId.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity, AutopodborNormalizer $normalizer, RunProgressChannel $progress): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок будет отмечать этапы именно сюда.
$progress->bind($run->id);
try {
$p = $run->params;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$request = new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
);
// Авто-повтор при ПУСТОМ сборе: внешние источники (2ГИС/Яндекс/EXA) иногда моргают и
// отдают пусто — вместо «ничего не нашлось» пробуем ещё раз (по умолчанию до 2 доп. попыток).
// Реально пустая ниша (все попытки пусты) идёт дальше штатным путём «empty» — без списания.
$retries = max(0, (int) config('autopodbor.search_retry_on_empty', 2));
$attempt = 0;
do {
$attempt++;
$res = $agent->findCompetitors($request);
} while ($res->competitors === [] && $attempt <= $retries);
$unique = $dedup->dedupCompetitors($res->competitors);
// На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же
// опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с
// АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы —
// при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои
// результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем.
$activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where('box', '!=', 'archived')
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->get(['site_url', 'directory_urls'])
->map(fn ($c) => array_flip($identity->keys([
'site_url' => $c->site_url,
'directory_urls' => $c->directory_urls ?? [],
])))
->all();
$unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool {
$keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]);
if ($keys === []) {
return true;
}
foreach ($activeKeySets as $have) {
$shares = false;
$hasNew = false;
foreach ($keys as $k) {
if (isset($have[$k])) {
$shares = true;
} else {
$hasNew = true;
}
}
if ($shares && ! $hasNew) {
return false; // полный дубль активной фирмы → скрыть
}
}
return true;
}));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
$this->notifyReady($run, 0);
return;
}
// $max === 0 → «не режем»: сохраняем ВСЕХ найденных. Иначе array_slice(…, 0, 0) обнулил бы
// непустой результат (баг: прогон сохранял 0 конкурентов, но всё равно списывал).
$saved = $max > 0 ? array_slice($unique, 0, $max) : $unique;
foreach ($saved as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => $c['relevance_pct'] ?? null,
'origin' => 'auto',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
// Телефоны (нормализованные) — для показа совпадений клиенту на «Предложениях».
// В тихую склейку НЕ идут (решение 03.07) — только клиентская «Найти дубли».
'phones' => array_values(array_unique(array_filter(array_map(
static fn ($p): string => $normalizer->phone((string) $p),
$c['phones'] ?? [],
)))),
'provenance' => $c['provenance'] ?? [],
]
);
}
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
$charge->chargeForRun($run, $price);
$run->update(['status' => 'done', 'finished_at' => now()]);
$this->notifyReady($run, count($saved));
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
/**
* Письмо клиенту «подбор готов» чтобы он не ждал у экрана. Не роняет успешный подбор,
* если почта недоступна (try/catch + report).
*/
private function notifyReady(AutopodborRun $run, int $found): void
{
try {
$email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email');
if (is_string($email) && $email !== '') {
Mail::to($email)->send(new AutopodborReadyMail($run, $found));
}
} catch (\Throwable $e) {
report($e);
}
}
}
@@ -1,168 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class RunAutopodborStudyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 2 «изучение / дайте источники» — тупой слой (без ИИ). ОТДЕЛЬНАЯ очередь + свой воркер,
// чтобы изучение шло параллельно поиску, а не ждало его в общей линии.
$this->onQueue('autopodbor-study');
}
/**
* Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(
CompetitorAgent $agent,
AutopodborDedup $dedup,
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
RunProgressChannel $progress,
): void {
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
$progress->bind($run->id);
try {
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
competitor: [
'name' => $comp->name,
'site_url' => $comp->site_url,
'directory_urls' => $comp->directory_urls ?? [],
'elements' => $comp->elements ?? [],
'is_federal' => (bool) $comp->is_federal,
],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
$newCount = 0;
$refoundDeletedIds = [];
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
$existing = AutopodborSource::where('competitor_id', $comp->id)
->where('dedup_key', $s['dedup_key'])
->first();
AutopodborSource::updateOrCreate(
[
'competitor_id' => $comp->id,
'dedup_key' => $s['dedup_key'],
],
[
'tenant_id' => $run->tenant_id,
'study_run_id' => $run->id,
'signal_type' => $s['signal_type'],
'identifier' => $identifier,
'phone_kind' => $s['phone_kind'] ?? null,
'phone_type' => $s['phone_type'] ?? null,
'provenance_url' => $s['provenance_url'] ?? null,
'provenance_label' => $s['provenance_label'] ?? null,
'where_found' => $s['where_found'] ?? null,
'office' => $s['office'] ?? null,
'confirmations' => $s['confirmations'] ?? 1,
]
);
if ($existing === null) {
$newCount++; // совсем новый → в предложения
} elseif ($existing->box === 'archived') {
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
}
}
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
$charge->chargeForRun($run, $price);
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
$run->update([
'status' => 'done',
'finished_at' => now(),
'result' => [
'new_sources' => $newCount,
'refound_deleted' => count($refoundDeletedIds),
'refound_deleted_ids' => array_values($refoundDeletedIds),
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
-89
View File
@@ -4,24 +4,17 @@ declare(strict_types=1);
namespace App\Jobs\External;
use App\Mail\ExternalServiceDownMail;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\JivoLivenessProbe;
use App\Services\External\LivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use App\Services\External\YooKassaLivenessProbe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
@@ -44,49 +37,11 @@ class RefreshExternalBalancesJob implements ShouldQueue
DadataBalanceProvider::class,
SupplierBalanceProvider::class,
YandexCloudBalanceProvider::class,
Yandex360BalanceProvider::class,
];
}
/** @var array<int,LivenessProbe>|null Подмена списка проб в тестах; null → дефолт. */
private static ?array $livenessOverride = null;
/** @param array<int,LivenessProbe> $probes */
public static function useLivenessProbes(array $probes): void
{
self::$livenessOverride = $probes;
}
public static function resetLivenessProbes(): void
{
self::$livenessOverride = null;
}
/** @return array<int,LivenessProbe> */
private function livenessProbes(): array
{
if (self::$livenessOverride !== null) {
return self::$livenessOverride;
}
return [
app(YooKassaLivenessProbe::class),
app(JivoLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
public function handle(): void
{
// Прежние цвета (для edge-trigger алерта): service_key => light.
$priorLights = DB::connection(self::DB_CONNECTION)
->table('external_service_balances')
->pluck('light', 'service_key')
->all();
/** @var array<int,array{key:string,detail:string}> $newlyRed */
$newlyRed = [];
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
@@ -130,46 +85,6 @@ class RefreshExternalBalancesJob implements ShouldQueue
'updated_at' => now(),
],
);
if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе'];
}
}
// Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу.
foreach ($this->livenessProbes() as $probe) {
$reading = $probe->check(); // не бросает
$key = $probe->serviceKey();
// Свежий builder на каждую итерацию (как в балансовом цикле).
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => null,
'currency' => 'RUB',
'daily_spend_estimate' => null,
'days_left' => null,
'light' => $reading->light,
// ok=true у green/red (статус определённый), false у grey (не смогли/не применимо).
'ok' => $reading->light !== 'grey',
// error несёт человеческую подпись для red/grey (для плитки); у green — null.
'error' => $reading->light === 'green' ? null : $reading->detail,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => $reading->detail];
}
}
// Edge-trigger: одно письмо, если появились новые красные сервисы.
if ($newlyRed !== []) {
$to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now()));
}
}
@@ -189,10 +104,6 @@ class RefreshExternalBalancesJob implements ShouldQueue
(float) config('services.supplier.red_floor_rub', 5000),
(float) config('services.supplier.amber_floor_rub', 15000),
],
'email' => [
(float) config('services.yandex360.red_floor_rub', 500),
(float) config('services.yandex360.amber_floor_rub', 2000),
],
default => [0.0, 0.0],
};
}
+36 -39
View File
@@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue
'created_at' => now(),
]);
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
$totalCsvRows = count($delivered);
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
// unique-violation в норме не наступает (иначе abort транзакции).
$existingVids = [];
$deliveredVids = array_map('intval', array_keys($delivered));
if ($deliveredVids !== []) {
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->whereIn('vid', $deliveredVids)
->pluck('vid')
->each(function ($vid) use (&$existingVids): void {
$existingVids[(int) $vid] = true;
});
}
$totalCsvRows = count($csvByKey);
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$missing = array_diff_key($csvByKey, $existingKeys);
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
$missing = array_diff_key($delivered, $existingVids);
$recoveredCount = 0;
$unparseableCount = 0;
@@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue
}
try {
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
// vid вернёт существующий лид, второй сделки не будет.
$lead = SupplierLead::create([
'vid' => null,
'vid' => $row['vid'],
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => $row,
'raw_payload' => [
'project' => $row['project'],
'phone' => (string) $row['phone'],
'vid' => $row['vid'],
],
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
@@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
Log::info('csv_reconcile.lead_insert_skipped_race', [
'vid' => $row['vid'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
@@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue
}
}
/**
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
@@ -193,7 +193,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
} finally {
$this->recordRunSummary(
$runId = $this->recordRunSummary(
startedAt: $startedAt,
groupsTotal: count($groups),
syncedOk: $syncedOk,
@@ -202,6 +202,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
failed: $failed,
aborted: $aborted,
);
// Итоговая проверка: после «готово» перечитать живой кабинет и сверить.
// Идёт всегда — и при штатном финише, и при обрыве. При auth-сбое (кабинет
// недоступен) сам джоб зафиксирует unable_to_verify.
VerifySupplierOrderJob::dispatch(1, $runId);
}
}
@@ -217,7 +222,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
int $deferred,
int $failed,
bool $aborted,
): void {
): int {
if ($aborted) {
$status = 'aborted';
} elseif ($failed > 0 && $syncedOk === 0) {
@@ -228,7 +233,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
$status = 'ok';
}
DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
$id = DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insertGetId([
'started_at' => $startedAt,
'finished_at' => now(),
'groups_total' => $groupsTotal,
@@ -239,6 +244,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
'status' => $status,
'created_at' => now(),
]);
return $id;
}
/**
@@ -394,12 +401,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
// platforms before a throw are recovered next run via the missing-set recovery below.
foreach ($platforms as $platform) {
// Iterate only platforms with a ≥1 share ($shares omits 0-share — cabinet rejects limit=0).
foreach (array_keys($shares) as $platform) {
$dto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
limit: $shares[$platform],
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -420,7 +428,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $shares[$platform],
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -454,11 +462,15 @@ class SyncSupplierProjectsJob implements ShouldQueue
if ($deadSps->isNotEmpty()) {
foreach ($deadSps as $sp) {
// Пропускаем площадку, у которой теперь доля 0 (кабинет отклонит limit=0).
if (! isset($shares[$sp->platform])) {
continue;
}
$recreateDto = new SupplierProjectDto(
platform: $sp->platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$sp->platform] ?? 0,
limit: $shares[$sp->platform],
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -486,7 +498,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
// (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
// Только площадки с долей ≥1 ($shares уже без 0-долей).
$missingPlatforms = array_values(array_diff(array_keys($shares), $existingPlatforms));
if ($missingPlatforms !== []) {
foreach ($missingPlatforms as $platform) {
@@ -494,7 +507,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
platform: $platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
limit: $shares[$platform],
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -514,7 +527,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $shares[$platform],
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -537,11 +550,16 @@ class SyncSupplierProjectsJob implements ShouldQueue
if ($sp->supplier_external_id === null) {
continue;
}
// Площадка потеряла долю (лимит группы упал) → не шлём update с limit=0
// (кабинет отклонит «Введите limit!»). Оставляем как есть до следующего пересчёта.
if (! isset($shares[$sp->platform])) {
continue;
}
$perPlatformDto = new SupplierProjectDto(
platform: $sp->platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $shares[$sp->platform] ?? 0,
limit: $shares[$sp->platform],
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
@@ -551,7 +569,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
);
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
$sp->forceFill([
'current_limit' => $shares[$sp->platform] ?? 0,
'current_limit' => $shares[$sp->platform],
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Mail\SupplierOrderMismatchMail;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierOrderPlan;
use App\Services\Supplier\SupplierOrderVerifier;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Итоговая проверка заказа: живой кабинет поставщика vs задуманное по формуле.
* Диспатчится из SyncSupplierProjectsJob в finally. При расхождении на attempt=1
* переочередь через 90 сек (attempt=2) гасит транзиентный лаг применения.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4
*/
final class VerifySupplierOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier';
public const RECHECK_DELAY_SECONDS = 90;
public function __construct(
public readonly int $attempt = 1,
public readonly ?int $syncRunId = null,
) {}
public function handle(?SupplierPortalClient $client = null): void
{
$client ??= app(SupplierPortalClient::class);
$targetDate = Carbon::tomorrow('Europe/Moscow');
// 1. Задуманное по формуле.
$eligible = (new SyncSupplierProjectsJob)->collectEligibleProjects();
$plan = SupplierOrderPlan::build($eligible, $targetDate);
$intended = $plan['intended'];
// Ключи активных по формуле — для (а) исключения из shouldBeOff, (б) ничего более.
$intendedKeys = [];
foreach ($intended as $r) {
$intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']] = true;
}
// Наши external_id в кабинете — признак «наша строка» (tag НЕ годится: робот
// шлёт tag=регион/«РФ», не маркер). Сверка живого идёт только по нашим строкам.
$ourExternalIds = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->pluck('supplier_external_id')
->map(fn ($id) => (string) $id)
->all();
// 2. shouldBeOff — наши выключенные supplier_projects, КРОМЕ ключей,
// которые сейчас активны по формуле (иначе ложный should_be_off на
// реактивированной группе с зависшим inactive_since).
$shouldBeOff = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('inactive_since')
->get(['signal_type', 'unique_key', 'platform'])
->map(fn (SupplierProject $sp) => [
'signal_type' => (string) $sp->signal_type,
'identifier' => (string) $sp->unique_key,
'platform' => (string) $sp->platform,
])
->reject(fn (array $r): bool => isset($intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']]))
->values()
->all();
// 3. Живой кабинет (только наши строки — по supplier_external_id).
try {
$live = $this->normalizeLive($client->listProjects(), $ourExternalIds);
} catch (Throwable $e) {
$this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]);
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierOrderMismatchMail([[
'kind' => 'unable_to_verify', 'signal_type' => '-', 'identifier' => '-',
'platform' => '-', 'expected' => 'чтение кабинета', 'actual' => $e->getMessage(),
]]));
return;
}
// 4. Сверка.
$mismatches = SupplierOrderVerifier::diff($intended, $shouldBeOff, $live);
if ($mismatches !== [] && $this->attempt < 2) {
// Транзиентный лаг применения — перепроверить через 90 сек.
self::dispatch(2, $this->syncRunId)->delay(now()->addSeconds(self::RECHECK_DELAY_SECONDS));
return;
}
$status = $mismatches === [] ? 'ok' : 'mismatch';
$this->record($status, count($intended), count($live), count($mismatches), $mismatches);
if ($mismatches !== []) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierOrderMismatchMail($mismatches));
}
}
/**
* Портальные строки формат SupplierOrderVerifier. «Наши» = те, чей портальный id
* совпадает с нашим supplier_external_id (tag НЕ используется робот шлёт регион).
*
* @param array<int, array<string,mixed>> $rows
* @param list<string> $ourExternalIds
* @return list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}>
*/
private function normalizeLive(array $rows, array $ourExternalIds): array
{
$ours = array_flip($ourExternalIds);
$out = [];
foreach ($rows as $row) {
if (! isset($ours[(string) ($row['id'] ?? '')])) {
continue;
}
$name = (string) ($row['name'] ?? '');
if (preg_match('/^(B[123])_/', $name, $mm) !== 1) {
continue;
}
$signalType = match ($row['type'] ?? null) {
'hosts' => 'site', 'calls' => 'call', 'sms' => 'sms', default => null,
};
if ($signalType === null) {
continue;
}
$out[] = [
'signal_type' => $signalType,
'identifier' => (string) ($row['content'] ?? ''),
'platform' => $mm[1],
'limit' => (int) ($row['lim'] ?? 0),
'on' => (string) ($row['status'] ?? '') === '1',
];
}
return $out;
}
/**
* @param list<array<string,mixed>>|array{error:string} $details
*/
private function record(string $status, int $intendedRows, int $liveRows, int $mismatchCount, array $details): void
{
DB::connection(self::DB_CONNECTION)->table('supplier_order_checks')->insert([
'checked_at' => now(),
'sync_run_id' => $this->syncRunId,
'intended_rows' => $intendedRows,
'live_rows' => $liveRows,
'mismatch_count' => $mismatchCount,
'status' => $status,
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
'created_at' => now(),
]);
}
}
+15 -7
View File
@@ -224,11 +224,12 @@ class SyncSupplierProjectJob implements ShouldQueue
if ($existingSps->isEmpty()) {
// Create path: one save PER platform with that platform's divided share
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
// Только площадки с долей ≥1 ($shares без 0-долей — кабинет отклоняет limit=0).
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, array_keys($shares));
$idMap = $createResult['ids'];
$retryWorthy = array_merge($retryWorthy, $createResult['failed']);
foreach ($platforms as $platform) {
foreach (array_keys($shares) as $platform) {
$externalId = $idMap[$platform] ?? null;
if ($externalId === null) {
continue;
@@ -240,7 +241,7 @@ class SyncSupplierProjectJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $shares[$platform],
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -266,7 +267,8 @@ class SyncSupplierProjectJob implements ShouldQueue
);
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
// Пересоздаём только площадки с долей ≥1 (0-долю кабинет отклоняет).
$deadPlatforms = array_values(array_intersect($deadSps->pluck('platform')->all(), array_keys($shares)));
$deadResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
$recreatedIdMap = $deadResult['ids'];
$retryWorthy = array_merge($retryWorthy, $deadResult['failed']);
@@ -281,7 +283,8 @@ class SyncSupplierProjectJob implements ShouldQueue
// Partial-set recovery: если предыдущий run создал не все platforms.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
// Только площадки с долей ≥1 ($shares без 0-долей).
$missingPlatforms = array_values(array_diff(array_keys($shares), $existingPlatforms));
if ($missingPlatforms !== []) {
$missingResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
@@ -299,7 +302,7 @@ class SyncSupplierProjectJob implements ShouldQueue
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $shares[$platform] ?? 0,
'current_limit' => $shares[$platform],
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
@@ -314,6 +317,11 @@ class SyncSupplierProjectJob implements ShouldQueue
if ($sp->supplier_external_id === null) {
continue;
}
// Активная группа, но у этой площадки доля упала до 0 → не шлём update с limit=0
// (кабинет отклонит «Введите limit!»). Оставляем как есть до следующего пересчёта.
if ($groupActive && ! isset($shares[$sp->platform])) {
continue;
}
// Portal requires a non-zero `limit` even when status=paused — sending 0
// returns "Введите limit!". When the whole group is paused, keep the previous
// current_limit so the portal accepts the update; status=paused stops orders.
@@ -507,7 +515,7 @@ class SyncSupplierProjectJob implements ShouldQueue
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: $shares[$platform] ?? 0,
limit: $shares[$platform],
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
-47
View File
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\AutopodborRun;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Уведомление клиенту, что фоновый подбор конкурентов завершён (клиент не ждёт у экрана
* поставил задачу, работает дальше, получает письмо «готово»).
*/
class AutopodborReadyMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public AutopodborRun $run,
public int $found,
) {}
public function envelope(): Envelope
{
$subject = $this->found > 0
? "Подбор конкурентов готов: {$this->found} — Лидерра"
: 'Подбор конкурентов готов — Лидерра';
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
markdown: 'mail.autopodbor-ready',
with: [
'found' => $this->found,
'url' => rtrim((string) config('app.url'), '/').'/autopodbor',
],
);
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-алерт: один или несколько внешних сервисов впервые перешли в «красный»
* (упали или деньги на исходе). Шлётся из RefreshExternalBalancesJob по edge-trigger.
*/
final class ExternalServiceDownMail extends Mailable
{
use Queueable;
use SerializesModels;
/** @param array<int,array{key:string,detail:string}> $services */
public function __construct(
public readonly array $services,
public readonly CarbonInterface $checkedAt,
) {}
public function envelope(): Envelope
{
$names = implode(', ', array_map(fn ($s) => $s['key'], $this->services));
return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names);
}
public function content(): Content
{
return new Content(view: 'emails.external_service_down');
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Mail\Sales;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо менеджеру: его заявка на привязку клиента одобрена или отклонена.
*
* Отправляется decide() после принятия решения начальником.
* Получатель устанавливается вызывающим кодом: Mail::to($email)->queue(new ...).
*/
final class AttachmentDecidedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
/** Имя менеджера, которому адресовано письмо */
public readonly string $managerName,
/** Логин клиента, указанный при подаче заявки */
public readonly string $loginInput,
/** Название организации клиента (или null если не найдено) */
public readonly ?string $tenantName,
/** 'approved' или 'rejected' */
public readonly string $action,
/** Название тарифа (только при action='approved' и если тариф есть) */
public readonly ?string $tariffName,
/** Комментарий руководителя (только при action='rejected') */
public readonly ?string $comment,
) {}
public function envelope(): Envelope
{
$subject = $this->action === 'approved'
? 'Ваша заявка одобрена'
: 'Ваша заявка отклонена';
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
view: 'mail.sales.attachment-decided',
with: [
'managerName' => $this->managerName,
'loginInput' => $this->loginInput,
'tenantName' => $this->tenantName,
'action' => $this->action,
'tariffName' => $this->tariffName,
'comment' => $this->comment,
],
);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Mail\Sales;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо менеджеру: клиент по указанному логину не найден.
*
* Отправляется при submit() когда lookup() вернул null (status='not_found').
*/
final class AttachmentNotFoundMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $managerName,
public readonly string $loginInput,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Клиент не найден — заявка на привязку',
);
}
public function content(): Content
{
return new Content(
view: 'mail.sales.attachment-not-found',
with: [
'managerName' => $this->managerName,
'loginInput' => $this->loginInput,
],
);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mail\Sales;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо менеджеру и всем руководителям: заявка на привязку клиента подана.
*
* Отправляется при submit() когда клиент найден (status='pending').
* Получателей устанавливает вызывающий код через Mail::to($email)->queue(new ...).
*/
final class AttachmentSubmittedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $managerName,
public readonly string $loginInput,
public readonly ?string $tenantName,
public readonly ?string $conflictComment,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Новая заявка на привязку клиента',
);
}
public function content(): Content
{
return new Content(
view: 'mail.sales.attachment-submitted',
with: [
'managerName' => $this->managerName,
'loginInput' => $this->loginInput,
'tenantName' => $this->tenantName,
'conflictComment' => $this->conflictComment,
],
);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mail\Sales;
use App\Models\SalesPayout;
use App\Models\SalesUser;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо менеджеру: проведена выплата (append-only журнал, Task 4.1).
*
* Отправляется при record() выплаты. Получателя устанавливает вызывающий код
* через Mail::to($manager->email)->send(new ...).
*/
final class SalesPayoutRecordedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly SalesPayout $payout,
public readonly SalesUser $manager,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Проведена выплата',
);
}
public function content(): Content
{
return new Content(
view: 'mail.sales.payout-recorded',
with: [
'managerName' => $this->manager->name,
'amountRub' => (float) $this->payout->amount_rub,
'paidOn' => $this->payout->paid_on->format('d.m.Y'),
'comment' => $this->payout->comment,
],
);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Сторож времени: робот-заказчик не закончил к порогу (yellow=20:00 / red=20:40).
*/
final class SupplierDeadlineWarningMail extends Mailable implements ShouldQueue
{
use Queueable;
/** @param 'yellow'|'red' $level */
public function __construct(
public readonly string $level,
public readonly string $reason,
) {}
public function envelope(): Envelope
{
$tag = $this->level === 'red' ? 'КРАСНЫЙ' : 'ЖЁЛТЫЙ';
return new Envelope(subject: "[Лидерра {$tag}] Робот-заказчик не успевает к 21:00");
}
public function content(): Content
{
return new Content(
text: 'emails.supplier_deadline_warning_text',
with: [
'level' => $this->level,
'reason' => $this->reason,
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
],
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Итоговая проверка нашла расхождения заказа с живым кабинетом поставщика.
*
* @param list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:mixed,actual:mixed}> $mismatches
*/
final class SupplierOrderMismatchMail extends Mailable implements ShouldQueue
{
use Queueable;
public function __construct(public readonly array $mismatches) {}
public function envelope(): Envelope
{
$n = count($this->mismatches);
return new Envelope(subject: "[Лидерра] Заказ у поставщика: расхождений {$n}");
}
public function content(): Content
{
return new Content(
text: 'emails.supplier_order_mismatch_text',
with: [
'mismatches' => $this->mismatches,
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
],
);
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborCompetitor extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'search_run_id',
'name',
'description',
'is_federal',
'relevance_pct',
'origin',
'site_url',
'directory_urls',
'phones',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
'elements',
'dismissed_actualize_keys',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'elements' => 'array',
'phones' => 'array',
'provenance' => 'array',
'dismissed_actualize_keys' => 'array',
'studied_at' => 'datetime',
'created_at' => 'datetime',
];
public function sources(): HasMany
{
return $this->hasMany(AutopodborSource::class, 'competitor_id');
}
public function searchRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'search_run_id');
}
public function studyRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'study_run_id');
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Событие слияния конкурентов: что во что слили + снимок поглощённых карточек.
* Журнал делает склейку прозрачной и обратимой (вернуть поглощённую карточку из снимка).
*/
class AutopodborMergeEvent extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'survivor_id',
'survivor_name',
'reason',
'absorbed',
'restored',
];
protected $casts = [
'absorbed' => 'array',
'restored' => 'bool',
'created_at' => 'datetime',
];
public function survivor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'survivor_id');
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborRun extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'kind',
'status',
'region_code',
'params',
'competitor_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
'progress',
'result',
];
protected $casts = [
'params' => 'array',
'progress' => 'array',
'result' => 'array',
'price_rub_charged' => 'decimal:2',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'created_at' => 'datetime',
];
public function competitors(): HasMany
{
return $this->hasMany(AutopodborCompetitor::class, 'search_run_id');
}
}
-47
View File
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutopodborSource extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'competitor_id',
'study_run_id',
'signal_type',
'identifier',
'phone_kind',
'phone_type',
'provenance_url',
'provenance_label',
'dedup_key',
'created_project_id',
'box',
'where_found',
'office',
'confirmations',
];
protected $casts = [
'created_at' => 'datetime',
'where_found' => 'array',
'confirmations' => 'integer',
];
public function competitor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'created_project_id');
}
}
-2
View File
@@ -42,8 +42,6 @@ class BalanceTransaction extends Model
public const TYPE_MIGRATION = 'migration';
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
public $timestamps = false;
protected $fillable = [
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Заявка менеджера на привязку клиента к своему профилю.
*
* SaaS-level модель: без RLS.
*
* status: 'pending' | 'approved' | 'rejected' | 'not_found'
* tenant_id nullable заполняется после поиска клиента по login_input.
* decided_by ссылка на sales_users.id (руководитель, принявший решение).
*
* Timestamps: только created_at (нет updated_at).
*
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
*
* @property int $id
* @property int $sales_user_id
* @property string $login_input
* @property int|null $tenant_id
* @property string $status
* @property string|null $comment
* @property int|null $decided_by
* @property Carbon|null $decided_at
* @property Carbon $created_at
*/
class SalesAttachmentRequest extends Model
{
public $timestamps = false;
protected $fillable = [
'sales_user_id',
'login_input',
'tenant_id',
'status',
'comment',
'decided_by',
'decided_at',
];
protected function casts(): array
{
return [
'decided_at' => 'datetime',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<SalesUser, $this> */
public function salesUser(): BelongsTo
{
return $this->belongsTo(SalesUser::class, 'sales_user_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
/** @return BelongsTo<SalesUser, $this> */
public function decider(): BelongsTo
{
return $this->belongsTo(SalesUser::class, 'decided_by');
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Привязка «один менеджер один клиент» с snapshot тарифа.
*
* SaaS-level модель: без RLS. tenant_id UNIQUE один клиент всегда
* принадлежит не более чем одному менеджеру.
*
* tariff_params snapshot параметров тарифа на момент привязки
* (копируется из SalesTariff.params, не следует live изменениям тарифа).
*
* Timestamps: только created_at (нет updated_at без timestamps = false,
* задаём через $timestamps).
*
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
*
* @property int $id
* @property int $sales_user_id
* @property int $tenant_id
* @property int|null $tariff_id
* @property string|null $tariff_kind
* @property array<string,mixed> $tariff_params
* @property Carbon $assigned_at
* @property Carbon $created_at
*/
class SalesClientAssignment extends Model
{
public $timestamps = false;
protected $fillable = [
'sales_user_id',
'tenant_id',
'tariff_id',
'tariff_kind',
'tariff_params',
'assigned_at',
];
protected function casts(): array
{
return [
'tariff_params' => 'array',
'assigned_at' => 'datetime',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<SalesUser, $this> */
public function salesUser(): BelongsTo
{
return $this->belongsTo(SalesUser::class, 'sales_user_id');
}
/** @return BelongsTo<SalesTariff, $this> */
public function tariff(): BelongsTo
{
return $this->belongsTo(SalesTariff::class, 'tariff_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Append-only журнал выплат менеджерам портала отдела продаж.
*
* SaaS-level модель: без RLS.
*
* Append-only: UPDATE/DELETE запрещены DB-триггером sales_payouts_no_mutate()
* (бросает EXCEPTION). Не добавляй update/delete логику в этот класс.
*
* Timestamps: только created_at (нет updated_at).
*
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
*
* @property int $id
* @property int $sales_user_id
* @property string $amount_rub
* @property Carbon $paid_on
* @property string|null $comment
* @property int $created_by
* @property Carbon $created_at
*/
class SalesPayout extends Model
{
public $timestamps = false;
protected $fillable = [
'sales_user_id',
'amount_rub',
'paid_on',
'comment',
'created_by',
];
protected function casts(): array
{
return [
'amount_rub' => 'decimal:2',
'paid_on' => 'date',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<SalesUser, $this> */
public function salesUser(): BelongsTo
{
return $this->belongsTo(SalesUser::class, 'sales_user_id');
}
/** @return BelongsTo<SalesUser, $this> */
public function creator(): BelongsTo
{
return $this->belongsTo(SalesUser::class, 'created_by');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Тарифный план портала отдела продаж.
*
* SaaS-level модель: без RLS. Используется как шаблон при привязке
* менеджера к клиенту (snapshot копируется в SalesClientAssignment).
*
* kind: 'daily_salary' | 'topup_step'
* daily_salary суточный оклад (ставка /день = per-manager base_salary_rub; params = {}).
* topup_step процент от пополнений с порогом + разовым бонусом
* (params = {threshold, reward, periods:[{from,to,rate}]}).
* params: JSONB с параметрами тарифа (зависят от kind).
*
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
*
* @property int $id
* @property string $name
* @property string $kind
* @property array<string,mixed> $params
* @property bool $is_active
*/
class SalesTariff extends Model
{
protected $fillable = [
'name',
'kind',
'params',
'is_active',
];
protected function casts(): array
{
return [
'params' => 'array',
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/** @return HasMany<SalesUser, $this> */
public function salesUsers(): HasMany
{
return $this->hasMany(SalesUser::class, 'current_tariff_id');
}
/** @return HasMany<SalesClientAssignment, $this> */
public function assignments(): HasMany
{
return $this->hasMany(SalesClientAssignment::class, 'tariff_id');
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;
/**
* Аккаунт менеджера или руководителя портала отдела продаж.
*
* SaaS-level модель: без RLS. Отдельная таблица sales_users
* не путать с tenant-level users (User.php).
*
* role: 'manager' | 'head'
*
* Используется как Authenticatable для auth портала продаж;
* HasApiTokens нужен для Sanctum API-токенов (будущие фазы).
*
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
*
* @property int $id
* @property string $name
* @property string $email
* @property string $password
* @property string $role
* @property bool $is_active
* @property string $base_salary_rub
* @property int|null $current_tariff_id
* @property int|null $created_by
* @property CarbonInterface $created_at
* @property CarbonInterface|null $updated_at
*/
class SalesUser extends Authenticatable
{
use HasApiTokens;
protected $fillable = [
'name',
'email',
'password',
'role',
'is_active',
'base_salary_rub',
'current_tariff_id',
'created_by',
];
protected $hidden = [
'password',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'base_salary_rub' => 'decimal:2',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/**
* Менеджер является руководителем (head) отдела продаж.
*/
public function isHead(): bool
{
return $this->role === 'head';
}
/** @return BelongsTo<SalesTariff, $this> */
public function currentTariff(): BelongsTo
{
return $this->belongsTo(SalesTariff::class, 'current_tariff_id');
}
/** @return HasMany<SalesClientAssignment, $this> */
public function assignments(): HasMany
{
return $this->hasMany(SalesClientAssignment::class, 'sales_user_id');
}
/** @return HasMany<SalesPayout, $this> */
public function payouts(): HasMany
{
return $this->hasMany(SalesPayout::class, 'sales_user_id');
}
}
-2
View File
@@ -21,7 +21,6 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -49,7 +48,6 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
@@ -1,226 +0,0 @@
<?php
namespace App\Providers;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier;
use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory;
use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CachingPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher;
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
use App\Services\Autopodbor\Agent\Fetch\LivePageFetcher;
use App\Services\Autopodbor\Agent\Fetch\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\PlaywrightPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\RoutingBatchFetcher;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\Agent\Study\CompositePhoneReverse;
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
use App\Services\Autopodbor\Agent\Study\ParallelPhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverseExpander;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexCardReader;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexPhoneSearch;
use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// Один канал прогресса на процесс: джоба привязывает его к своему прогону, движок пишет этапы.
$this->app->singleton(RunProgressChannel::class);
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru ИЛИ свой рендер-сервис.
//
// Слой добычи 2ГИС/Яндекс переключается флагом autopodbor.self_render (§4, §12):
// ВКЛ → SelfRenderClient (виртуалка+прокси; 2ГИС отдаёт initialState) + TwoGisJsonReader.
// ВЫКЛ → XfetchClient (xf4) + HtmlTwoGisReader — как сейчас.
// Фетчер и 2ГИС-ридер меняются ПАРОЙ (иначе JSON уйдёт в HTML-парсер и вернёт пусто).
//
// Шаг 1 (поиск): при autopodbor.real_find — ЖИВОЙ движок; без ИИ-ключа отсев/похожесть отключены
// (деградация). Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку.
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
$selfRender = (bool) config('autopodbor.self_render');
$sr = $selfRender ? $this->selfRenderClient() : null;
// Загрузчик xf4 (нужен всегда для xf4-пути и как база RoutingBatchFetcher под Яндекс).
$xfetch = new XfetchClient(
apiKey: config('services.xfetch.key'),
endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
concurrency: (int) config('services.xfetch.concurrency', 4),
);
// Основной загрузчик КАРТОЧЕК: self-render ИЛИ xf4 — обёрнут кэшем (§render_cache).
// Ключ по URL в Redis → шаг 1 и шаг 2 делят ОДИН кэш; кэшируются только карточки
// (/firm/, /maps/org/), поиск/ленты (/search/) идут мимо (passthrough). Флаг ВЫКЛ → как есть.
$pages = $this->wrapCache($sr ?? $xfetch);
// В self-render режиме обёрнутый загрузчик = обёрнутый self-render (тот же экземпляр $pages).
$srCards = $sr !== null ? $pages : null;
$reader = $this->twoGisReader();
// Таймаут 15с (было 45): сайт конкурента отвечает за 1-3с; режем только висяки, не теряя живых.
$siteFetcher = new CurlPlaywrightFetcher(timeout: 15);
$fetcher = new CompositeFetcher(
siteFetcher: $siteFetcher,
directoryFetcher: new XfetchDirectoryFetcher($pages),
);
$deep = config('autopodbor.deep_study')
? new DeepStudyCollector(
xfetch: $pages,
opener: new SiteOpener($siteFetcher),
reverse: config('autopodbor.phone_reverse')
? $this->buildPhoneReverse($pages, $reader)
: null,
reader: $reader,
// self-render: Яндекс-справочник шага 2 — через виртуалку+прокси; иначе (null) движок
// строит дефолт (локальный Playwright). §5.3/§5.7. Загрузчик карточек — обёрнутый кэшем.
yandexDir: $srCards !== null ? new XfetchDirectoryFetcher($srCards, new DirectoryParser) : null,
)
: null;
$liveFind = config('autopodbor.real_find')
? $this->buildLiveFind($xfetch, $srCards, $reader)
: null;
return new RealCompetitorAgent(
$fetcher,
new FakeCompetitorAgent,
liveFind: $liveFind,
deep: $deep,
progress: $app->make(RunProgressChannel::class),
);
});
}
/**
* Обернуть загрузчик КАРТОЧЕК общим кэшем (§render_cache). ВКЛ {@see CachingPageFetcher}
* (карточки /firm/, /maps/org/ в Redis на N дней; поиск/ленты мимо). ВЫКЛ фетчер как есть.
* Ключ по URL любые обёрнутые экземпляры делят один кэш (шаг 1 шаг 2 повторный прогон).
*/
public function wrapCache(BatchPageFetcher $inner): BatchPageFetcher
{
return config('autopodbor.render_cache')
? new CachingPageFetcher($inner, (int) config('autopodbor.render_cache_ttl_days', 5))
: $inner;
}
/** Клиент своего рендер-сервиса (виртуалка). Без endpoint/key деградирует в пусто (§5.6). */
private function selfRenderClient(): SelfRenderClient
{
return new SelfRenderClient(
endpoint: (string) config('services.self_render.endpoint', ''),
key: (string) config('services.self_render.key', ''),
concurrency: (int) config('services.self_render.concurrency', 8),
);
}
/** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */
public function twoGisReader(): TwoGisReader
{
return config('autopodbor.self_render') ? new TwoGisJsonReader : new HtmlTwoGisReader;
}
/** Источник ленты Яндекс.Карт: виртуалка+прокси (self-render) ИЛИ локальный Playwright (xf4). */
public function yandexListSource(): YandexListSource
{
if (config('autopodbor.self_render')) {
return new RenderServiceYandexListSource(
(string) config('services.self_render.endpoint', ''),
(string) config('services.self_render.key', ''),
);
}
return new LocalYandexListSource((string) config('autopodbor.node_bin', 'node'));
}
private function buildPhoneReverse(BatchPageFetcher $pages, TwoGisReader $reader): PhoneReverse
{
$gis = new PhoneReverseExpander($pages, $reader);
if (! config('autopodbor.phone_reverse_yandex')) {
return $gis;
}
$node = (string) config('autopodbor.node_bin', 'node');
// Яндекс-разворот: под self_render — виртуалка+прокси (лента + чтение карточки), иначе локальный Playwright.
// Чтение карточек (/maps/org/) под self_render оборачиваем общим кэшем; Playwright (xf4) — нет.
$cardFetcher = config('autopodbor.self_render')
? $this->wrapCache($this->selfRenderClient())
: new PlaywrightPageFetcher($node);
$yandex = new YandexPhoneReverseExpander(
new PlaywrightYandexPhoneSearch($this->yandexListSource()),
new PlaywrightYandexCardReader($cardFetcher),
);
// phone_reverse_parallel: 2ГИС и Яндекс крутятся ОДНОВРЕМЕННО (итог = max, оба источника целы).
// Иначе — последовательный композит (как раньше). По умолчанию ВЫКЛ, откат — флаг в false.
return config('autopodbor.phone_reverse_parallel')
? new ParallelPhoneReverse($gis, $yandex)
: new CompositePhoneReverse([$gis, $yandex]);
}
/**
* Живой движок поиска шага 1 ФИНАЛ v4: АНАЛИЗ (мелкая модель запросы-рубрики) КАНАЛ А (скрейп
* категории 2ГИС с пагинацией карточки) КАНАЛ В (модель ИМЕНА федералов; их САЙТ через EXA)
* сборка (отсев агрегаторов + дедуп + похожесть + DTO). Все ИИ/exa/фетч-клиенты деградируют при пустом
* ключе. Слой 2ГИС/Яндекс self-render ($sr) ИЛИ xf4 ($xfetch), пара с $reader.
*/
private function buildLiveFind(XfetchClient $xfetch, ?BatchPageFetcher $srCards, TwoGisReader $reader): LiveFindCompetitors
{
$http = $this->app->make(HttpFactory::class);
$node = (string) config('autopodbor.node_bin', 'node');
// Скрейп листинга 2ГИС (страницы /search/ — не кэшируются): self-render ИЛИ LivePageFetcher(xfetch).
$catPages = $srCards ?? new LivePageFetcher($xfetch);
// Обогатитель КАРТОЧЕК: self-render (обёрнут кэшем) ИЛИ RoutingBatchFetcher, где 2ГИС-база
// обёрнута кэшем (/firm/ из кэша), а карточки Яндекса — локальный Playwright (кэш не трогаем).
$enrichPages = $srCards ?? new RoutingBatchFetcher($this->wrapCache($xfetch), ['yandex' => new PlaywrightPageFetcher($node)]);
$assembler = new FindCompetitorsAssembler(
new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($this->app->make(AitunnelEmbedder::class)),
);
return new LiveFindCompetitors(
new AitunnelQueryAnalyzer($http),
// 2ГИС: 4 страницы на рубрику (5-я — «хвост» низкоранговых, шум). Кол-во режем на старте.
new CategoryScraper($catPages, $reader, maxPages: 4),
new PlaywrightYandexDirectory($this->yandexListSource()),
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
new ExaSiteFinder($http),
$assembler,
enricher: new CompetitorCardEnricher($enrichPages, new DirectoryParser, $reader),
// Канал прогресса (синглтон): джоба привяжет его к прогону, движок отметит этапы.
progress: $this->app->make(RunProgressChannel::class),
);
}
}
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Граница «это поставщик услуги или площадка-агрегатор?» (§12.6 движка v4). За ней LLM-рассуждение
* (sonar-reasoning-pro и т.п.), а НЕ статический список площадок (он мёртв: все агрегаторы не знаем,
* плодятся). Позволяет фильтровать агрегаторов офлайн на фейке.
*/
interface AggregatorClassifier
{
/**
* true площадка-агрегатор (Авито/Юла/Zoon/Банки.ру…); false поставщик услуги;
* null модель не смогла решить (тогда конкурента НЕ выкидываем, см. {@see AggregatorFilter}).
*/
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool;
}
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Отсев площадок-агрегаторов из кандидатов (§12.6): спрашивает {@see AggregatorClassifier} по каждому.
* Консервативно: выкидываем ТОЛЬКО когда классификатор уверенно сказал «агрегатор» (true). При
* неуверенности (null) или «поставщик» (false) конкурента оставляем, чтобы не потерять настоящего.
*/
final class AggregatorFilter
{
public function __construct(private readonly AggregatorClassifier $classifier) {}
/**
* @param array<int, array{name?:string,site_url?:?string,description?:?string}> $candidates
* @return array<int, array>
*/
public function filter(array $candidates): array
{
$candidates = array_values($candidates);
// Классификатор умеет батч → один запрос на весь список (иначе ~90 запросов по одному, «умирает»).
$verdicts = $this->classifier instanceof BatchAggregatorClassifier && $candidates !== []
? $this->classifier->classifyManyAggregators(array_map(fn (array $c): array => [
'name' => (string) ($c['name'] ?? ''),
'site_url' => $c['site_url'] ?? null,
'description' => $c['description'] ?? null,
], $candidates))
: null;
$out = [];
foreach ($candidates as $i => $c) {
$verdict = $verdicts !== null
? ($verdicts[$i] ?? null)
: $this->classifier->isAggregator(
(string) ($c['name'] ?? ''),
$c['site_url'] ?? null,
$c['description'] ?? null,
);
if ($verdict === true) {
continue; // площадка — не конкурент
}
$out[] = $c;
}
return array_values($out);
}
}
@@ -1,189 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see AggregatorClassifier} через AITUNNEL (OpenAI-совместимый chat, §12.6): спрашивает
* у модели, поставщик ли это услуги или площадка-агрегатор. POST {base}/chat/completions
* {choices:[{message:{content}}]}. Ответ AGGREGATOR true, SUPPLIER false, иначе/без ключа/
* при ошибке null (тогда конкурента НЕ выкидываем консервативно).
*/
final class AitunnelAggregatorClassifier implements AggregatorClassifier, BatchAggregatorClassifier
{
public function __construct(private readonly HttpFactory $http) {}
/**
* Батч отсева агрегаторов: список режем на ПОРЦИИ по aggregator_batch_size (иначе один запрос
* на ~100 фирм долгий и рискует не успеть в таймаут). Каждая порция один запрос с длинным
* таймаутом aggregator_timeout_sec. Пропущенные/непонятные/сбой порции null (консервативно).
*
* @param list<array{name:string,site_url:?string,description:?string}> $companies
* @return list<?bool>
*/
public function classifyManyAggregators(array $companies): array
{
$companies = array_values($companies);
$n = count($companies);
$none = array_fill(0, $n, null);
if ($n === 0) {
return [];
}
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return $none;
}
$batchSize = max(1, (int) ($cfg['aggregator_batch_size'] ?? 40));
$out = $none;
$offset = 0;
foreach (array_chunk($companies, $batchSize) as $chunk) {
foreach ($this->classifyChunk($chunk, $key, $cfg) as $i => $verdict) {
$out[$offset + $i] = $verdict;
}
$offset += count($chunk);
}
return $out;
}
/**
* Одна порция: нумеруем 1..m, один запрос к модели с длинным таймаутом. Ответ вердикты порции.
*
* @param list<array{name:string,site_url:?string,description:?string}> $chunk
* @return list<?bool>
*/
private function classifyChunk(array $chunk, string $key, array $cfg): array
{
$none = array_fill(0, count($chunk), null);
$lines = [];
foreach ($chunk as $i => $c) {
$lines[] = ($i + 1).'. «'.($c['name'] ?? '').'» сайт: '.($c['site_url'] ?: '—').
' описание: '.($c['description'] ?: '—');
}
$prompt = "Список компаний:\n".implode("\n", $lines)."\n\n".
'Для КАЖДОЙ определи: это сам поставщик услуги/товара (SUPPLIER) или площадка-агрегатор/'
.'каталог/маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
.'(AGGREGATOR)? Ответ — СТРОГО JSON-массив по номерам, без текста вне него: '
.'[{"n":1,"v":"SUPPLIER"},{"n":2,"v":"AGGREGATOR"}]';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['aggregator_timeout_sec'] ?? 90))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $this->model($cfg),
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return $none;
}
return $this->parseVerdicts((string) $resp->json('choices.0.message.content'), $none);
} catch (\Throwable) {
return $none;
}
}
/**
* JSON-массив [{n,v}] из ответа вердикты по индексам (n 1). Пропущенные остаются null.
*
* @param list<?bool> $none болванка нужной длины
* @return list<?bool>
*/
private function parseVerdicts(string $content, array $none): array
{
$start = strpos($content, '[');
$end = strrpos($content, ']');
if ($start === false || $end === false || $end < $start) {
return $none;
}
$arr = json_decode(substr($content, $start, $end - $start + 1), true);
if (! is_array($arr)) {
return $none;
}
$out = $none;
$count = count($none);
foreach ($arr as $row) {
if (! is_array($row) || ! isset($row['n'])) {
continue;
}
$idx = ((int) $row['n']) - 1;
if ($idx < 0 || $idx >= $count) {
continue;
}
$v = mb_strtolower((string) ($row['v'] ?? ''));
if (str_contains($v, 'aggregator')) {
$out[$idx] = true;
} elseif (str_contains($v, 'supplier')) {
$out[$idx] = false;
}
}
return $out;
}
/**
* Модель для отсева агрегаторов: aggregator_model (посильнее, вариант А) фолбэк на общий
* chat_model gpt-4o-mini. Отдельный ключ, чтобы усилить точность классификации, не трогая
* остальные ИИ-вызовы.
*
* @param array<string,mixed> $cfg
*/
private function model(array $cfg): string
{
$m = (string) ($cfg['aggregator_model'] ?? '');
return $m !== '' ? $m : (string) ($cfg['chat_model'] ?? 'gpt-4o-mini');
}
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return null;
}
$prompt = "Компания: «{$name}». Сайт: ".($siteUrl ?: '—').'. Описание: '.($description ?: '—').'. '
.'Это сам поставщик услуги/товара (ответь SUPPLIER) или площадка-агрегатор/каталог/'
.'маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
.'(ответь AGGREGATOR)? Ответь РОВНО одним словом: SUPPLIER или AGGREGATOR.';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $this->model($cfg),
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return null;
}
$content = mb_strtolower((string) $resp->json('choices.0.message.content'));
if (str_contains($content, 'aggregator')) {
return true;
}
if (str_contains($content, 'supplier')) {
return false;
}
return null;
} catch (\Throwable) {
return null;
}
}
}
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Батч-версия {@see AggregatorClassifier}: классифицирует ВЕСЬ список за один запрос к модели,
* а не по одному (иначе на ~90 фирм 90 последовательных запросов, «умирает»). Классификаторы,
* умеющие батч, реализуют этот интерфейс; {@see AggregatorFilter} использует его, если он есть.
*/
interface BatchAggregatorClassifier
{
/**
* @param list<array{name:string,site_url:?string,description:?string}> $companies
* @return list<?bool> вердикты по индексам входа: true агрегатор, false поставщик, null не решил
*/
public function classifyManyAggregators(array $companies): array;
}
@@ -1,135 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see QueryAnalyzer} через AITUNNEL: мелкая chat-модель разбивает описание клиента на короткие
* запросы-рубрики для скрейпа категории справочников (шаг АНАЛИЗ канала А).
*
* ⚠️ Промт ВОССТАНОВЛЕН (оригинал жил в самоочистившемся collectA.js) на живом прогоне точим.
* Нет ключа / ошибка / пустой разбор fallback: один запрос = само описание (канал А не мёртв).
*/
final class AitunnelQueryAnalyzer implements QueryAnalyzer
{
private const MAX_QUERIES = 8;
public function __construct(private readonly HttpFactory $http) {}
public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): array
{
$description = trim($description);
$fallback = $description !== '' ? [$description] : [];
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || $description === '') {
return $fallback;
}
// Контекст для модели: наш сайт + сайты/карточки конкурентов из запроса (условно — если переданы).
$context = $this->contextClause($selfSite, $competitorExamples);
// Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой
// сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и
// запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки).
$prompt = "Описание деятельности клиента: «{$description}». {$context}Регион: {$region}.\n".
'Дай от 3 до 6 КОРОТКИХ запросов-рубрик, по которым в справочниках 2ГИС и Яндекс.Карты '.
"ищут именно ПРЯМЫХ конкурентов клиента — как называется его УЗКАЯ категория.\n".
'Правило: рубрики только КОНКРЕТНЫЕ и узкие. ЗАПРЕЩЕНЫ общие «зонтичные» слова, которые '.
'тащат кого попало: «услуги», «финансовые услуги», «финансы», «медицина», «товары», «магазин», '.
"«сервис», «компания». Каждая рубрика — точная специализация, а не сфера целиком.\n".
'Примеры (узкое, НЕ зонтичное): стоматология → «стоматология», «имплантация зубов» (НЕ «медицина»); '.
'автосервис → «шиномонтаж», «автосервис» (НЕ «услуги»); займы под залог авто → «автоломбард», '.
'«займ под ПТС» (НЕ «финансовые услуги»). Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '.
'Только сами запросы, без пояснений, без названий фирм и без города. '.
'Ответ — строго JSON-массив строк: ["...","..."]';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $cfg['chat_model'] ?? 'gpt-4o-mini',
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return $fallback;
}
$queries = $this->parseQueries((string) $resp->json('choices.0.message.content'));
return $queries !== [] ? $queries : $fallback;
} catch (\Throwable) {
return $fallback;
}
}
/**
* Контекстная фраза «посмотри наш сайт X, а также наших главных конкурентов условно:
* каждая часть добавляется, только если данные есть. Пусто ''.
*
* @param list<string> $competitorExamples
*/
private function contextClause(string $selfSite, array $competitorExamples): string
{
$selfSite = trim($selfSite);
$examples = array_values(array_filter(array_map(
static fn ($v): string => trim((string) $v),
$competitorExamples,
), static fn (string $v): bool => $v !== ''));
$parts = [];
if ($selfSite !== '') {
$parts[] = "посмотри наш сайт {$selfSite}";
}
if ($examples !== []) {
$parts[] = 'а также наших главных конкурентов '.implode(', ', $examples);
}
return $parts !== [] ? implode(', ', $parts).' ' : '';
}
/**
* JSON-массив строк из сырого ответа: фрагмент `[``]`, дедуп (без регистра), отсев пустых, лимит.
*
* @return list<string>
*/
private function parseQueries(string $raw): array
{
$start = strpos($raw, '[');
$end = strrpos($raw, ']');
if ($start === false || $end === false || $end < $start) {
return [];
}
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
if (! is_array($decoded)) {
return [];
}
$out = [];
$seen = [];
foreach ($decoded as $q) {
if (! is_string($q)) {
continue;
}
$q = trim($q);
$k = mb_strtolower($q, 'UTF-8');
if ($q === '' || isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $q;
if (count($out) >= self::MAX_QUERIES) {
break;
}
}
return $out;
}
}
@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Лёгкий разбор страницы категории 2ГИС (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка, БЕЗ захода в карточку).
* Из списка по каждой фирме берём: ИМЯ + ссылку на карточку (firm/<id>) + САЙТ.
*
* Сайт лежит в редиректе «Перейти на сайт»: `…/<firmId>/null/<hash>?http://site.ru…` id в пути = id
* фирмы, настоящий адрес после `?`. Мусор (агрегаторы/мессенджеры/трекеры/иностранные TLD) отсеиваем
* это не сайт фирмы. Телефоны/описание тут НЕ трогаем (это шаг 2). Чистый: на вход HTML, наружу не ходит.
*/
final class CategoryListingParser
{
/** Не сайт фирмы: агрегаторы/каталоги/соцсети/мессенджеры/трекеры. */
private const BLACKLIST = [
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', 'flamp.ru', 'orgpage.ru',
'max.ru', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'wa.me', 'facebook.com', 'instagram.com',
'youtube.com', 'dzen.ru', 'rambler.ru', 'mail.ru', 'tns-counter.ru', 'top100.ru',
'serving-sys.ru', 'otello.ru', 'russpass.ru', 'gosuslugi.ru',
];
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md', '.com.tr'];
/**
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function parse(string $html): array
{
$sites = $this->siteMap($html);
$out = [];
$seen = [];
$re = '#<a href="(/[a-z0-9_-]+/firm/(\d+))[^"]*"\s+class="_1rehek"><span class="_lvwrwt"><span>([^<]+)</span>#u';
if (preg_match_all($re, $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$id = $hit[2];
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$name = trim(html_entity_decode($hit[3], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($name === '') {
continue;
}
$out[] = [
'name' => $name,
'card_url' => 'https://2gis.ru'.$hit[1],
'site' => $sites[$id] ?? null,
];
}
}
return $out;
}
/**
* firmId домен сайта из редиректов «Перейти на сайт». Первый «настоящий» на фирму.
*
* @return array<string,string>
*/
private function siteMap(string $html): array
{
$sites = [];
if (preg_match_all('#/(\d{10,})/null/[^"?]*\?(https?://[^"\\\\?]+)#', $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$id = $hit[1];
if (isset($sites[$id])) {
continue;
}
$domain = $this->domain($hit[2]);
if ($domain !== null && $this->isRealSite($domain)) {
$sites[$id] = $domain;
}
}
}
return $sites;
}
private function domain(string $url): ?string
{
$url = mb_strtolower(trim($url), 'UTF-8');
$url = preg_replace('#^https?://#', '', $url);
$url = preg_replace('#^www\.#', '', $url);
$url = explode('/', $url)[0];
$url = explode('?', $url)[0];
return $url !== '' ? $url : null;
}
private function isRealSite(string $domain): bool
{
if (in_array($domain, self::BLACKLIST, true)) {
return false;
}
foreach (self::FOREIGN_TLD as $tld) {
if (str_ends_with($domain, $tld)) {
return false;
}
}
return true;
}
}
@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* Канал А (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка БЕЗ захода в карточку): по каждому запросу-рубрике
* из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ всех страниц и берём из СПИСКА по
* фирме имя + ссылку на карточку + сайт (через {@see CategoryListingParser}). Карточки не открываем
* это резко быстрее. Дедуп между страницами и запросами по ссылке карточки. Остановка: пустая
* страница / страница без новых фирм / лимит maxPages.
*
* Загрузка страниц ПАРАЛЛЕЛЬНАЯ ПОПЕРЁК РУБРИК (страница P всех активных рубрик за один
* {@see BatchPageFetcher::htmlBatch()} вызов). Параллельность живёт внутри htmlBatch (режет на
* порции по services.xfetch.concurrency). Если передан простой PageFetcher (тестовый стаб без
* batch) деградирует на одиночные html() вызовы, не ломая существующие тесты.
*/
final class CategoryScraper
{
public function __construct(
private readonly PageFetcher $pages,
// Разбор списка 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к фетчеру.
private readonly TwoGisReader $reader,
private readonly int $maxPages = 5,
) {}
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function collectTwoGis(string $slug, array $queries): array
{
$seen = [];
$out = [];
// Нормализуем и фильтруем пустые рубрики
$activeQueries = [];
foreach ($queries as $query) {
$query = trim((string) $query);
if ($query !== '') {
$activeQueries[] = $query;
}
}
if ($activeQueries === []) {
return [];
}
// Базовые URL для каждой рубрики (страница 1)
$baseUrls = [];
foreach ($activeQueries as $query) {
$baseUrls[$query] = "https://2gis.ru/{$slug}/search/".rawurlencode($query);
}
// active — список рубрик, которые ещё «живы» (дали новые фирмы на предыдущей странице)
$active = $activeQueries;
for ($page = 1; $page <= max(1, $this->maxPages); $page++) {
if ($active === []) {
break;
}
// Строим URL текущей страницы для каждой активной рубрики
$urlByQuery = [];
foreach ($active as $query) {
$base = $baseUrls[$query];
$urlByQuery[$query] = $page === 1 ? $base : $base."/page/{$page}";
}
// Загружаем пачку: BatchPageFetcher — параллельно; простой PageFetcher — по одному
$htmlByUrl = $this->fetchBatch(array_values($urlByQuery));
// Разбираем каждую рубрику; те, что дали 0 новых — выбывают
$stillActive = [];
foreach ($active as $query) {
$url = $urlByQuery[$query];
$html = $htmlByUrl[$url] ?? '';
$rows = $this->reader->listing($html);
if ($rows === []) {
// Пустая страница — рубрика выбывает
continue;
}
$added = 0;
foreach ($rows as $row) {
$key = $row['card_url'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
$added++;
}
if ($added > 0) {
$stillActive[] = $query; // есть новые — рубрика живёт
}
// $added === 0: страница без новых фирм → рубрика тоже выбывает
}
$active = $stillActive;
}
return $out;
}
/**
* Загружает HTML нескольких URL. Если fetcher реализует BatchPageFetcher делегирует
* в htmlBatch() (параллельно, порциями по лимиту сервиса). Иначе цикл html() по одному
* (сохраняет совместимость с простыми PageFetcher-стабами в тестах).
*
* @param list<string> $urls
* @return array<string, string> url => html
*/
private function fetchBatch(array $urls): array
{
if ($this->pages instanceof BatchPageFetcher) {
return $this->pages->htmlBatch($urls);
}
$result = [];
foreach ($urls as $url) {
$result[$url] = $this->pages->html($url);
}
return $result;
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Fetch\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
/**
* Живой {@see YandexDirectory}: по каждой рубрике рендерит ленту `yandex.ru/maps/?text=<запрос> <город>`,
* скроллит и берёт организации (имя + карточка). Сам рендер за {@see YandexListSource}: локальный
* Playwright (xf4-путь) ИЛИ виртуалка+прокси (self-render). Дедуп по SLUG в {@see YandexListParser}.
*
* Источник за интерфейсом оркестрация (URL по рубрике, потолок, слияние) тестируется офлайн фейком.
*/
final class PlaywrightYandexDirectory implements YandexDirectory
{
public function __construct(
private readonly YandexListSource $source = new LocalYandexListSource,
// Берём ВСЕ рубрики (аналитик отдаёт до 8): каждая рубрика первой даёт самые релевантные фирмы.
private readonly int $maxQueries = 8,
// Сколько раз прокрутить ленту в каждой рубрике. Мало = быстро (верх выдачи = самые релевантные).
private readonly int $scrolls = 6,
private readonly YandexListParser $parser = new YandexListParser,
) {}
public function collect(string $city, array $queries): array
{
// Сырьё по всем рубрикам; дедуп/опознание фирмы по SLUG — в парсере («Корунд ×15 → один»).
$orgs = [];
foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) {
$query = trim((string) $query);
if ($query === '') {
continue;
}
$url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city));
foreach ($this->source->fetch($url, $this->scrolls) as $org) {
$orgs[] = $org;
}
}
return $this->parser->parse($orgs);
}
}
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Шаг АНАЛИЗ канала А: из описания деятельности клиента НЕСКОЛЬКО коротких запросов-рубрик для
* скрейпа категории 2ГИС/Яндекса (ZAFIKSIROVANO §0-БИС / §12.7 в тесте Омеги было ~6 запросов).
* Реализация решает, какой моделью (по умолчанию мелкая chat-модель). За границей для офлайн-теста.
*/
interface QueryAnalyzer
{
/**
* @param string $selfSite сайт клиента из запроса на поиск (пусто не добавляем в промт)
* @param list<string> $competitorExamples сайты/карточки конкурентов из запроса (пусто не добавляем)
* @return list<string> короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»).
*/
public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): array;
}
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Второй справочник канала А Яндекс.Карты (ZAFIKSIROVANO: Яндекс = имя + карточка, БЕЗ сайта;
* сайт в списке Яндекса не отдаётся, только на карточке не открываем). Собирает по запросам-рубрикам
* организации: имя + ссылка на карточку. За границей для офлайн-теста оркестратора через фейк.
*/
interface YandexDirectory
{
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<array{name:string,card_url:string}>
*/
public function collect(string $city, array $queries): array;
}
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Разбор списка организаций Яндекс.Карт (канал А). Дедуп по SLUG коду фирмы от самого Яндекса
* из адреса страницы `/maps/org/<slug>/<id>`, а НЕ по id филиала и НЕ по тексту имени.
*
* Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой
* поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 один» и склеивает разные написания
* одной фирмы («КрасЛомбард»/«Красломбард» slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим
* именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника,
* а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит.
*/
final class YandexListParser
{
/**
* @param list<array<string,mixed>> $orgs сырые организации из render-скрипта (name, href, category, ...)
* @return list<array{name:string,card_url:string,slug:string,description:?string}>
*/
public function parse(array $orgs): array
{
$seen = [];
$out = [];
foreach ($orgs as $org) {
$href = trim((string) ($org['href'] ?? ''));
$name = trim((string) ($org['name'] ?? ''));
if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) {
continue;
}
$slug = mb_strtolower($m[1], 'UTF-8');
if (isset($seen[$slug])) {
continue; // одна фирма (slug) = одна строка; филиалы схлопнуты
}
$seen[$slug] = true;
// Рубрика из списка Яндекса («Ломбард, автоломбард», «Микрофинансовая организация») = описание.
$category = trim((string) ($org['category'] ?? ''));
$out[] = [
'name' => $name,
'card_url' => 'https://yandex.ru'.$href,
'slug' => $slug,
'description' => $category !== '' ? $category : null,
];
}
return $out;
}
}
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see ResearcherClient} через AITUNNEL (OpenAI-совместимый chat). ФИНАЛ (ZAFIKSIROVANO §0-БИС):
* ОДНА рассуждающая модель (`research_model`, по умолчанию sonar-reasoning-pro) с веб-поиском, temperature:0.
* POST {base}/chat/completions [system, user] {choices:[{message:{content}}]}.
* Нет ключа / ошибка сети возвращаем '[]' (движок деградирует, не падает; канал В даст 0 имён).
*/
final class AitunnelResearcher implements ResearcherClient
{
public function __construct(private readonly HttpFactory $http) {}
public function research(string $system, string $user): string
{
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return '[]';
}
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['research_timeout_sec'] ?? 120))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $cfg['research_model'] ?? 'sonar-reasoning-pro',
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
'temperature' => 0,
]);
if (! $resp->successful()) {
return '[]';
}
$content = (string) $resp->json('choices.0.message.content');
return $content !== '' ? $content : '[]';
} catch (\Throwable) {
return '[]';
}
}
}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Канал В (федералы/онлайн) генератор ИМЁН моделью, ФИНАЛ владельца (ZAFIKSIROVANO §0-БИС + §11.3):
* На вход ИИ даём СПИСОК ИЗ КАНАЛА А (фирмы, уже найденные в справочниках) + примеры клиента
* как «уже известных, не повторять».
* ИИ выдаёт ТОЛЬКО НАЗВАНИЯ новых конкурентов (+ тип), которых в списке нет. Без сайтов/карточек.
* ОДНА модель × 2 прохода: проход 2 получает стоп-лист = известные + найденное в проходе 1.
* Сайт каждого имени добывает ПОТОМ EXA (нет сайта нет якоря имя выкидывается).
*
* Дедуп по имени (ё→е, без не-букв/цифр). Модель за границей {@see ResearcherClient} (офлайн-тест).
*/
final class ChannelBSearch
{
/** Промт канала В: ТОЛЬКО НАЗВАНИЯ+тип федералов, ведущих деятельность в регионе. Сайт находит EXA. */
public const SYSTEM_PROMPT = <<<'TXT'
Ты поисковик конкурентов для нашей компании. Используй интернет и справочники. Дай список НАЗВАНИЙ реальных компаний-конкурентов ведущих свою деятельность в указанном регионе федеральных игроков в этой сфере чем больше реальных, тем лучше.
Дай ТОЛЬКО НАЗВАНИЕ и ТИП каждой фирмы. Сайты и ссылки НЕ нужны их мы найдём сами. Не выдумывай фирмы ради объёма; лучше меньше, но реальные.
В запросе дан список уже известных НЕ повторяй их.
ФОРМА строго JSON-массив, без текста вне него: [{"name":"Название","type":"федеральная"}]
TXT;
public function __construct(
private readonly ResearcherClient $client,
private readonly ResearcherParser $parser,
) {}
/**
* @param list<string> $known имена, уже известные (список из канала А + примеры клиента)
* @return list<array{name:string,type:?string}> только НОВЫЕ фирмы (имя + тип; сайт найдёт EXA)
*/
public function harvest(string $profile, string $region, string $clientSite, array $known, int $passes = 2): array
{
$accumulated = [];
$seen = [];
$stop = [];
foreach ($known as $name) {
$name = trim((string) $name);
if ($name === '') {
continue;
}
$k = $this->key($name);
if (isset($seen[$k])) {
continue; // чистим стоп-лист от дублей-филиалов: «Корунд» ×27 → один
}
$seen[$k] = true;
$stop[] = $name;
}
for ($pass = 1; $pass <= max(1, $passes); $pass++) {
$user = $this->userPrompt($profile, $region, $clientSite, $stop);
$raw = $this->client->research(self::SYSTEM_PROMPT, $user);
foreach ($this->parser->parse($raw) as $cand) {
$k = $this->key($cand['name']);
if (isset($seen[$k])) {
continue; // уже известен (канал А, пример или прошлый проход)
}
$seen[$k] = true;
$accumulated[] = $cand;
$stop[] = $cand['name']; // стоп-лист растёт к следующему проходу
}
}
return $accumulated;
}
/** Пользовательский промт §11.3: профиль/регион/сайт клиента + список известных имён. */
private function userPrompt(string $profile, string $region, string $clientSite, array $stop): string
{
$list = $stop === []
? '(пока пусто)'
: implode("\n", array_map(static fn (string $n): string => '- '.$n, $stop));
return "Наша компания: {$profile} в {$region}. Наш сайт: {$clientSite}.\n".
"Уже известные конкуренты — НЕ выводить их повторно:\n".
"{$list}\n".
"Дай ТОЛЬКО НОВЫЕ фирмы-конкуренты в {$region}, которых нет в списке — только название и тип. Ответ — строго в формате из системного промта.";
}
/** Ключ дедупа по имени: ё→е, нижний регистр, только буквы/цифры. */
private function key(string $name): string
{
$name = str_replace('ё', 'е', mb_strtolower($name, 'UTF-8'));
return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $name);
}
}
@@ -1,176 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\Response;
/**
* Нормализация канала В (ZAFIKSIROVANO / §11.411.5): у федерала НЕТ карточки в 2ГИС/Яндексе на регион,
* поэтому его САЙТ ищем по имени через EXA. Берём домен первого «настоящего» результата, отсеивая
* каталоги/агрегаторы/реестры/отзовики и иностранные TLD (это НЕ сайт самой фирмы).
*
* POST {base}/search {query, numResults} (header x-api-key) {results:[{url}]}. Нет ключа/ошибка/
* нет подходящих null (тогда у имени из В сайта не будет; резолвер решит «нет филиала»).
*/
final class ExaSiteFinder
{
/** Домены-каталоги/агрегаторы/реестры/карты/отзовики — НЕ сайт фирмы (§11.4). */
private const BLACKLIST = [
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', '1000bankov.ru',
'yandex.ru', 'ya.ru', '2gis.ru', 'flamp.ru', 'orgpage.ru', 'rusprofile.ru',
'list-org.com', 'sbis.ru', 'spark-interfax.ru', 'otzovik.com', 'irecommend.ru',
'vk.com', 'ok.ru', 'instagram.com', 'facebook.com', 't.me', 'telegram.me',
'wikipedia.org', 'youtube.com', 'dzen.ru', 'zen.yandex.ru',
];
/** Иностранные TLD — не сайт российской фирмы (§11.4). */
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md'];
public function __construct(private readonly HttpFactory $http) {}
public function findSite(string $name, string $region): ?string
{
$cfg = (array) config('services.exa');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || trim($name) === '') {
return null;
}
try {
$resp = $this->http
->withHeaders(['x-api-key' => $key])
->withOptions($this->proxyOptions())
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', $this->body($name));
return $this->pickDomain($resp);
} catch (\Throwable) {
return null;
}
}
/**
* Пакетный поиск сайтов ПАРАЛЛЕЛЬНО пулом Laravel Http, порциями по services.exa.concurrency
* (внутри порции одновременно, порции по очереди). Лимит EXA на КЛЮЧ (один на всех клиентов),
* поэтому не «все разом», а бережно; глобальный потолок между клиентами держит очередь.
* Сбой/429 по имени его сайт null (не роняет пул и не теряет остальных).
*
* @param list<string> $names
* @return array<string, ?string> имя домен (или null)
*/
public function findSites(array $names, string $region): array
{
$names = array_values(array_unique(array_filter(
array_map(static fn ($n): string => trim((string) $n), $names),
static fn (string $n): bool => $n !== '',
)));
$out = [];
foreach ($names as $n) {
$out[$n] = null;
}
if ($names === []) {
return $out;
}
$cfg = (array) config('services.exa');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return $out;
}
$url = rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search';
$timeout = (int) ($cfg['timeout_sec'] ?? 30);
$concurrency = max(1, (int) ($cfg['concurrency'] ?? 5));
foreach (array_chunk($names, $concurrency) as $chunk) {
try {
$responses = $this->http->pool(fn ($pool) => array_map(
fn (string $n) => $pool->as($n)
->withHeaders(['x-api-key' => $key])
->withOptions($this->proxyOptions())
->timeout($timeout)
->post($url, $this->body($n)),
$chunk,
));
} catch (\Throwable) {
continue; // весь чанк не удался — имена остаются null, идём к следующему
}
foreach ($chunk as $n) {
$out[$n] = $this->pickDomain($responses[$n] ?? null);
}
}
return $out;
}
/** Тело запроса EXA по имени. */
private function body(string $name): array
{
return ['query' => $name.' официальный сайт', 'numResults' => 10, 'type' => 'auto'];
}
/**
* Опции Guzzle для запроса EXA: прокси (services.exa.proxy), если задан. EXA за Cloudflare
* режет российские IP, поэтому на боевом канал В ходит через зарубежный выход. Пусто напрямую.
*
* @return array<string, mixed>
*/
private function proxyOptions(): array
{
$proxy = trim((string) (config('services.exa.proxy') ?? ''));
return $proxy !== '' ? ['proxy' => $proxy] : [];
}
/** Первый «настоящий» домен из ответа EXA (не каталог/агрегатор/иностранный TLD), иначе null. */
private function pickDomain(mixed $resp): ?string
{
if (! $resp instanceof Response || ! $resp->successful()) {
return null;
}
foreach ((array) $resp->json('results') as $r) {
$url = is_array($r) ? (string) ($r['url'] ?? '') : '';
$domain = $this->domain($url);
if ($domain !== null && $this->isRealSite($domain)) {
return $domain;
}
}
return null;
}
/** Голый домен из URL: без схемы/www/пути, нижний регистр. */
private function domain(string $url): ?string
{
$url = mb_strtolower(trim($url), 'UTF-8');
if ($url === '') {
return null;
}
$url = preg_replace('#^https?://#', '', $url);
$url = preg_replace('#^www\.#', '', $url);
$url = explode('/', $url)[0];
$url = explode('?', $url)[0];
return $url !== '' ? $url : null;
}
/** Настоящий сайт фирмы — не из чёрного списка и не иностранный TLD. */
private function isRealSite(string $domain): bool
{
if (in_array($domain, self::BLACKLIST, true)) {
return false;
}
foreach (self::FOREIGN_TLD as $tld) {
if (str_ends_with($domain, $tld)) {
return false;
}
}
return true;
}
}
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Граница живого канала В: один вызов модели-исследователя (system+user промт сырой текст ответа).
* Финал движка (см. ZAFIKSIROVANO §0-БИС) ОДНА модель, гоняется 2 прохода; реализация решает,
* какую модель шлёт (по умолчанию sonar-reasoning-pro). Логика проходов/стоп-листа за границей,
* в {@see ChannelBSearch}, чтобы тестироваться офлайн через фейк-клиент.
*/
interface ResearcherClient
{
public function research(string $system, string $user): string;
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Канал В (§11.3 + ZAFIKSIROVANO §0-БИС): модель-исследователь даёт ТОЛЬКО НАЗВАНИЯ конкурентов
* (+ тип регион/федерал), без сайтов/карточек/телефонов их добывает ПОТОМ Firecrawl/резолвер.
* Парсер вытаскивает JSON-массив имён из сырого ответа (часто в markdown-обёртке или с текстом вокруг).
* Чистый: на вход строка, наружу не ходит.
*/
final class ResearcherParser
{
/**
* @return list<array{name:string,type:?string}>
*/
public function parse(string $raw): array
{
$out = [];
foreach ($this->decodeArray($raw) as $row) {
if (! is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue; // без имени — мусор
}
// ТОЛЬКО имя+тип. Если модель по инерции положит site/card — игнорируем: сайт ищет EXA.
$out[] = [
'name' => $name,
'type' => $this->str($row['type'] ?? null),
];
}
return $out;
}
/**
* Достаёт JSON-массив: фрагмент от первой `[` до последней `]`. Не распарсилось пустой массив.
*
* @return array<int, mixed>
*/
private function decodeArray(string $raw): array
{
$start = strpos($raw, '[');
$end = strrpos($raw, ']');
if ($start === false || $end === false || $end < $start) {
return [];
}
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
return is_array($decoded) ? $decoded : [];
}
private function str(mixed $v): ?string
{
if (! is_string($v)) {
return null;
}
$v = trim($v);
return $v !== '' ? $v : null;
}
}
@@ -1,19 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
interface CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
}
@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* «Вскрыть всё» на этапе сбора: открываем карточку справочника у КАЖДОГО кандидата с карточкой и без
* сайта, и с сайтом (максимум качества). Из вскрытой карточки берём телефоны + описание всегда, а сайт
* только если его ещё нет (свой сайт из листинга надёжнее, его не затираем). Это нужно для чистой склейки
* дублей (Автодом24 без сайта AutoMoney) и богатого описания. БЕЗ лимита на число карточек открываем
* ВСЕ ПАЧКОЙ (параллельно), если фетчер умеет ({@see BatchPageFetcher}); каждый запрос с таймаутом, поэтому
* зависнуть не может. Кол-во режем на старте (страницы/рубрики), а не гейтом на вскрытии. Единственное
* реальное ограничение частотный лимит самого 2ГИС (карточка вернёт пусто тогда просто пропускаем).
*
* Остальные дубли (по вскрывшемуся при ИЗУЧЕНИИ телефону/адресу) добирает кнопка «Найти дубли».
* Фетчер за границей {@see PageFetcher} тестируется офлайн.
*/
final class CompetitorCardEnricher
{
public function __construct(
private readonly PageFetcher $pages,
private readonly DirectoryParser $parser,
// Разбор карточек 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к
// фетчеру. Яндекс-карточки остаются на $parser->parseYandexCard (свой формат, не 2ГИС).
private readonly TwoGisReader $twoGis = new HtmlTwoGisReader,
) {}
/**
* @param array<int, array> $candidates
* @return array<int, array>
*/
public function enrich(array $candidates): array
{
// Цели = ВСЕ кандидаты, у которых есть карточка справочника (гейта «сайт есть → пропустить»
// больше нет — максимум качества: вскрываем всё, кол-во режем на старте страницами/рубриками).
// idx => первая карточка. Без лимита.
$targets = [];
foreach ($candidates as $idx => $c) {
foreach ($c['directory_urls'] ?? [] as $u) {
$u = trim((string) $u);
if ($u !== '') {
$targets[$idx] = $u;
break;
}
}
}
if ($targets === []) {
return $candidates;
}
// Пачкой (параллельно) если умеет, иначе по одной.
$urls = array_values(array_unique(array_values($targets)));
$htmls = $this->pages instanceof BatchPageFetcher
? $this->pages->htmlBatch($urls)
: $this->fetchSequential($urls);
foreach ($targets as $idx => $url) {
$html = $htmls[$url] ?? '';
if ($html === '') {
continue;
}
$c = $candidates[$idx];
foreach ($this->parseCards($html, $url) as $card) {
if (empty($c['site_url']) && $card->siteUrl !== null && $card->siteUrl !== '') {
$c['site_url'] = $card->siteUrl;
}
if (empty($c['description']) && $card->description !== null && $card->description !== '') {
$c['description'] = $card->description;
}
if ($card->number !== '') {
$c['phones'][] = $card->number;
}
}
$c['phones'] = array_values(array_unique($c['phones'] ?? []));
$candidates[$idx] = $c;
}
return $candidates;
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
private function fetchSequential(array $urls): array
{
$out = [];
foreach ($urls as $u) {
$out[$u] = $this->pages->html($u);
}
return $out;
}
/**
* Разбор карточки по типу справочника: Яндекс parseYandexCard, 2ГИС parse2gisCard.
*
* @return list<DirectoryCard>
*/
private function parseCards(string $html, string $url): array
{
if (str_contains($url, 'yandex')) {
return $this->parser->parseYandexCard($html, $url);
}
if (str_contains($url, '2gis')) {
return $this->twoGis->card($html, $url);
}
return [];
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Dto;
final class CollectedSource
{
/** @param array<int,array{label:string,url:?string}> $sources */
public function __construct(
public readonly string $signalType, // call | site
public readonly string $identifier, // 7XXXXXXXXXX | домен
public readonly ?string $phoneKind, // real | substitute | null
public readonly ?string $phoneType, // city | mobile | tollfree | null
public readonly ?string $office, // подпись филиала | null
public readonly array $sources, // «где нашли»
) {}
public function confirmations(): int
{
return count($this->sources);
}
}
@@ -1,14 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsRequest
{
public function __construct(
public readonly int $regionCode,
public readonly array $examples,
public readonly array $aboutSelf,
public readonly bool $includeFederal,
public readonly int $maxCompetitors,
) {}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsResult
{
/**
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
*/
public function __construct(public readonly array $competitors) {}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameRequest
{
public function __construct(
public readonly string $name,
public readonly int $regionCode,
) {}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameResult
{
/**
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
*/
public function __construct(public readonly array $candidates) {}
}
@@ -1,14 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorRequest
{
/**
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
*/
public function __construct(
public readonly array $competitor,
public readonly int $regionCode,
) {}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorResult
{
/**
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string,where_found?:array<int,array{label:string,url:?string}>,office?:?string,confirmations?:int}> $sources
*/
public function __construct(public readonly array $sources) {}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class CalltrackingDetector
{
private const PROVIDERS = ['roistat', 'calltouch', 'comagic', 'uiscom', 'mango-office', 'callibri', 'ringostat', 'phonet'];
/** @return list<string> */
public function detect(string $html): array
{
$found = [];
foreach (self::PROVIDERS as $p) {
if (stripos($html, $p) !== false) {
$found[] = $p;
}
}
return array_values(array_unique($found));
}
}
@@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
use App\Services\Autopodbor\AutopodborNormalizer;
final class CandidateBuilder
{
public function __construct(
private HtmlPhoneScanner $scanner = new HtmlPhoneScanner,
private CalltrackingDetector $detector = new CalltrackingDetector,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
) {}
/**
* @param list<FetchedSite> $sites
* @param list<DirectoryCard> $cards
* @param ?string $defaultAreaCode запасной код города (по региону конкурента)
* для достройки коротких номеров, если на странице нет полных
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
*/
public function build(array $sites, array $cards, ?string $defaultAreaCode = null): array
{
$out = [];
foreach ($sites as $site) {
$scan = $this->scanner->scan($site->rawHtml, $defaultAreaCode);
$hasTracker = $this->detector->detect($site->rawHtml) !== [];
foreach (array_keys($scan['code']) as $number) {
$out[] = [
'number' => (string) $number,
'kind' => 'code',
'label' => 'в коде сайта',
'url' => $site->url,
'office' => null,
'tracker' => $hasTracker,
];
}
// короткие локальные номера, код города которых не удалось определить —
// НЕ теряем, отдаём клиенту с пометкой «требует проверки»
foreach ($scan['uncertain'] ?? [] as $short) {
$out[] = [
'number' => (string) $short,
'kind' => 'uncertain',
'label' => 'локальный номер на сайте — код города не определён, требует проверки',
'url' => $site->url,
'office' => null,
'tracker' => $hasTracker,
];
}
$codeNumbers = array_map('strval', array_keys($scan['code']));
// видимые отрендеренные номера → displayed (подменный), если их нет в коде
$visible = [];
foreach ($site->visiblePhones as $raw) {
$n = $this->normalize($raw);
if ($n === null) {
continue;
}
$visible[] = $n;
if (! in_array($n, $codeNumbers, true)) {
$out[] = [
'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)',
'url' => $site->url, 'office' => null, 'tracker' => $hasTracker,
];
}
}
// пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере
if ($hasTracker) {
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) {
$out[] = [
'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены',
'url' => $site->url, 'office' => null, 'tracker' => true,
];
}
}
} else {
// без коллтрекинга: номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ (не tel:/schema,
// не отрендеренные) — не теряем, а отдаём клиенту с пометкой «проверьте». На
// верифицированном сайте фирмы это чаще её линии, но без tel:-ссылки за 100%
// настоящие не выдаём. (Число вхождений не важно — номер уже в видимом тексте.)
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if (in_array($number, $codeNumbers, true) || in_array($number, $visible, true)) {
continue;
}
$out[] = [
'number' => $number, 'kind' => 'text', 'label' => 'в тексте сайта — проверьте',
'url' => $site->url, 'office' => null, 'tracker' => false,
];
}
}
// номера филиалов со страницы «Контакты» (рендер)
foreach ($site->contactsNumbers as $row) {
$n = $this->normalize($row['number']);
if ($n === null) {
continue;
}
$out[] = [
'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»',
'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker,
];
}
}
foreach ($cards as $card) {
$n = $this->normalize($card->number);
if ($n === null) {
continue;
}
$out[] = [
'number' => $n, 'kind' => 'directory', 'label' => $card->source,
'url' => $card->url, 'office' => $card->office, 'tracker' => false,
];
}
return $out;
}
private function normalize(string $raw): ?string
{
$n = $this->norm->phone($raw);
return (strlen($n) === 11 && $n[0] === '7') ? $n : null;
}
}
@@ -1,352 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* Разбор страниц справочников (2ГИС/Яндекс): список филиалов и карточка филиала.
* Чистый: на вход уже отрендеренный HTML, на выход ссылки/карточки.
*/
final class DirectoryParser
{
/**
* Ссылки на карточки филиалов со страницы списка (2ГИС: /city/firm/<id>).
*
* @return list<string>
*/
public function parseBranchList(string $html): array
{
// Путь карточки филиала /<город>/firm/<id> — 2ГИС отдаёт его то как href,
// то внутри JSON-состояния страницы. Берём отовсюду (с дедупом) — иначе на части
// прорисовок филиалы теряются (поймано на живом 2ГИС).
$out = [];
if (preg_match_all('#/[a-z0-9_-]+/firm/\d+#i', $html, $m)) {
foreach ($m[0] as $href) {
if (! in_array($href, $out, true)) {
$out[] = $href;
}
}
}
return $out;
}
/**
* Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы.
*
* @return list<DirectoryCard>
*/
public function parseFirmCard(string $html, string $url, string $source): array
{
$office = $this->officeFromTitle($html);
$out = [];
if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) {
$seen = [];
foreach ($m[1] as $raw) {
$num = trim($raw);
if (isset($seen[$num])) {
continue;
}
$seen[$num] = true;
$out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source);
}
}
return $out;
}
/**
* Карточка организации Яндекс.Карт: телефоны + сайт + адрес из заголовка.
*
* Приоритеты парсинга:
* 1. Телефоны из <div class="orgpage-phones-view__phone-number">
* 2. Резервные номера из wa.me-ссылок (если не совпадают по цифрам с уже найденными)
* 3. Сайт из <a class="business-urls-view__link" href="">
* 4. Адрес офиса через officeFromTitle()
*
* Если телефонов нет, но сайт есть одна карточка с number=''.
* Если нет ни телефона, ни сайта [].
*
* @return list<DirectoryCard>
*/
public function parseYandexCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается» — рубрика из заголовка карточки Яндекса («Имя, РУБРИКА, адрес…, Город»).
$description = $this->categoryFromTitle($html);
// Телефоны из <div class="orgpage-phones-view__phone-number"…>ТЕЛЕФОН</div>
$phones = [];
if (preg_match_all(
'/class="orgpage-phones-view__phone-number"[^>]*>\s*([^<]+?)\s*</',
$html,
$m,
)) {
foreach ($m[1] as $raw) {
$raw = trim($raw);
if ($raw !== '') {
$phones[] = $raw;
}
}
}
// Телефоны из скрытого JSON (Яндекс прячет 2-й и последующие за раскрывашкой,
// но все объекты {"number":"...","type":"phone","value":"..."} лежат в HTML).
// Берём поле value — это чистый номер в международном формате.
if (preg_match_all(
'/"number":"[^"]+","type":"phone","value":"(\+?\d{10,15})"/u',
$html,
$jm,
)) {
foreach ($jm[1] as $jsonNum) {
$jsonDigits = preg_replace('/\D/', '', $jsonNum);
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $jsonDigits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = $jsonNum;
}
}
}
// Резервные: wa.me/7XXXXXXXXXX — добавляем только если цифры нового
if (preg_match_all('/wa\.me\/(\d{10,15})/i', $html, $wm)) {
foreach ($wm[1] as $digits) {
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $digits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = '+'.$digits;
}
}
}
// Сайт
$siteUrl = null;
if (preg_match('/class="business-urls-view__link"\s+href="([^"]+)"/i', $html, $sm)) {
$siteUrl = $sm[1];
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
$seenDigits = [];
foreach ($phones as $raw) {
$digits = preg_replace('/\D/', '', $raw);
if (isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Карточка фирмы 2ГИС «вскрыть всё»: сайт + ВСЕ телефоны + описание из карточки.
*
* 2ГИС хранит контакты JSON-массивом объектов:
* - сайт объект {"url":"","type":"website",} берём поле `url` (НЕ `value`:
* там редирект link.2gis.ru, а не настоящий сайт);
* - телефоны объекты {"type":"phone","value":"+7…"} берём `value`;
* - описание «чем занимается» best-effort из og:description (стабильнее хэш-классов 2ГИС).
*
* Мусор-домены (счётчики/трекеры в <script src>) сюда не попадают: сайт берём ТОЛЬКО из
* объекта-контакта website, а не «первый домен на странице».
*
* Если телефонов нет, но сайт есть одна карточка с number=''. Нет ни того, ни другого [].
*
* @return list<DirectoryCard>
*/
public function parse2gisCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается»: сначала своё описание (og), если оно не заглушка; иначе — рубрика из
// заголовка карточки («Имя, РУБРИКА, адрес…, Город»). Рекламный текст 2ГИС грузится отдельно
// (не в HTML) — его тут нет, рубрика — надёжный минимум.
$description = $this->descriptionFromMeta($html) ?? $this->categoryFromTitle($html);
// Сайт: в объекте-контакте url идёт ДО "type":"website" (порядок ключей url,text,code,type).
// Берём первый такой объект; [^}]*? не даёт «перепрыгнуть» в соседний объект.
$siteUrl = null;
if (preg_match('/"url":"([^"]+)"[^}]*?"type":"website"/u', $html, $sm)) {
$siteUrl = $sm[1];
}
// Телефоны: value у объектов {"type":"phone","value":"+7…"}; дедуп по цифрам.
// ВАЖНО: разбираем ПООБЪЕКТНО (не сквозной регуляркой по всей странице), чтобы отсеять
// РЕКЛАМНЫЕ кнопки-действия. 2ГИС вшивает в карточку рекламу чужих фирм («Связаться с
// менеджером») — это объект с caption/platforms и БЕЗ code/print_text; его value — телефон
// другой компании. Если его взять, чужой номер попадает в контакты фирмы и даёт ложную
// склейку/«объединить?» (живой случай: телефон Айклауда прилетал в Ломбард Бодрый).
$phones = [];
$seenDigits = [];
if (preg_match_all('/\{[^{}]*?"type":"phone"[^{}]*?\}/u', $html, $om)) {
foreach ($om[0] as $obj) {
// Рекламная кнопка-действие, а не контакт фирмы → пропускаем.
if (str_contains($obj, '"platforms"') || str_contains($obj, '"caption"')) {
continue;
}
if (! preg_match('/"value":"(\+?\d{10,15})"/u', $obj, $vm)) {
continue;
}
$digits = preg_replace('/\D/', '', $vm[1]);
if ($digits === '' || isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$phones[] = $vm[1];
}
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Описание «чем занимается» из og:description / meta description (best-effort, может быть null).
*/
private function descriptionFromMeta(string $html): ?string
{
if (preg_match('/<meta[^>]+property="og:description"[^>]+content="([^"]+)"/i', $html, $m)
|| preg_match('/<meta[^>]+name="description"[^>]+content="([^"]+)"/i', $html, $m)) {
$d = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return ($d !== '' && ! $this->isJunkDescription($d)) ? $d : null;
}
return null;
}
/**
* Служебная заглушка справочника (не «чем занимается»): «Оценка N, N фото, N отзывов. Вся
* информация об этом месте есть в 2ГИС» и т.п. Такое в описание не пишем это мусор для
* релевантности и отсева агрегаторов.
*/
private function isJunkDescription(string $d): bool
{
$low = mb_strtolower($d, 'UTF-8');
// Упоминает сам справочник (2ГИС/Яндекс) или это блок «оценка … фото … отзыв».
return str_contains($low, '2гис')
|| str_contains($low, '2gis')
|| (str_contains($low, 'фото') && str_contains($low, 'отзыв'))
|| (bool) preg_match('/^оценка\s+\d/u', $low);
}
/**
* Рубрика «чем занимается» из <title> вида «Имя, РУБРИКА, адрес…, Город 2ГИС/Яндекс».
* Рубрика 2-й кусок, НО только если он не похож на адрес (иначе это «Имя, адрес…, Город» без рубрики).
*/
private function categoryFromTitle(string $html): ?string
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return null;
}
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // «Имя, Город» — отдельной рубрики нет
}
$cand = $parts[1] ?? '';
return ($cand !== '' && ! $this->looksLikeAddress($cand)) ? $cand : null;
}
/** Кусок заголовка похож на адрес (улица/проспект/дом), а не на рубрику. */
private function looksLikeAddress(string $s): bool
{
$low = mb_strtolower($s, 'UTF-8');
return (bool) preg_match('/улиц|ул\.|проспект|пр-т|пр\.|переул|пер\.|бульвар|шоссе|набереж|площад|проезд|тракт|\d/u', $low);
}
/**
* Адрес офиса из <title> вида «Имя, адрес…, Город 2ГИС».
* Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем).
*/
private function officeFromTitle(string $html): ?string
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return null;
}
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
// нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // нет адреса между именем и городом
}
// имя фирмы — первый кусок, город — последний; адрес — середина
$address = array_slice($parts, 1, count($parts) - 2);
return implode(', ', $address) ?: null;
}
}
@@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class HtmlPhoneScanner
{
/**
* @param ?string $defaultAreaCode код города из запроса (напр. «391») запасной для
* достройки коротких локальных номеров, если на странице нет полных
* @return array{code: array<string,list<string>>, body: array<string,int>, emails: list<string>, uncertain: list<string>}
*/
public function scan(string $html, ?string $defaultAreaCode = null): array
{
$code = [];
$uncertain = [];
// 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata).
// Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны.
$rawCandidates = [];
$patterns = [
['/tel:([+0-9()\s-]{6,})/i', 'tel'],
['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'],
['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'],
];
foreach ($patterns as [$re, $slot]) {
if (preg_match_all($re, $html, $m)) {
foreach ($m[1] as $x) {
$rawCandidates[] = ['raw' => $x, 'slot' => $slot];
}
}
}
// 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города.
$national = [];
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$national[] = substr($n, 1);
}
}
$add = function (string $n, string $slot) use (&$code): void {
$code[$n] ??= [];
if (! in_array($slot, $code[$n], true)) {
$code[$n][] = $slot;
}
};
// 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города.
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$add($n, $c['slot']);
continue;
}
[$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode);
if ($status === 'built') {
$add((string) $value, $c['slot']);
} elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) {
$uncertain[] = (string) $value; // код города не определить — не теряем, к проверке
}
// 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем
}
// Номера в теле берём из ВИДИМОГО текста: вырезаем <script>/<style>, чтобы не тянуть
// технический мусор из конфигов/аналитики. Реальные номера из JSON-LD уже пойманы выше (schema).
$bodyHtml = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $bodyHtml, $m)) {
foreach ($m[0] as $x) {
$n = $this->normalizeMaybe($x);
if ($n !== null) {
$body[$n] = ($body[$n] ?? 0) + 1;
}
}
}
$emails = [];
if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) {
foreach ($m[1] as $local) {
$d = preg_replace('/\D+/', '', $local) ?? '';
if (strlen($d) >= 7) {
$emails[] = $d;
}
}
}
return ['code' => $code, 'body' => $body, 'emails' => $emails, 'uncertain' => $uncertain];
}
private function normalizeMaybe(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
return '7'.substr($digits, 1);
}
if (strlen($digits) === 10) {
return '7'.$digits;
}
return null;
}
/**
* Классифицирует короткую (67 цифр) форму номера из явного телефонного контекста.
* Возвращает [статус, значение]:
* - 'built' + 7XXXXXXXXXX локальный номер достроен кодом города;
* - 'fragment' + null обрезок полного номера (страна+код), выкидываем;
* - 'uncertain' + цифры код города не определить, не теряем, помечаем к проверке;
* - 'skip' + null не 67-значная форма, не наше.
* Пример: «271-33-33» при «+7 (391) …» ['built','73912713333'];
* обрезок «+7 (391) 271» (7391271) ['fragment', null].
*
* @param list<string> $national 10-значные национальные части полных номеров страницы
* @param ?string $defaultAreaCode запасной код города из запроса (если полных нет)
* @return array{0:string,1:?string}
*/
private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
$len = strlen($digits);
if ($len < 6 || $len > 7) {
return ['skip', null];
}
$area = $this->areaCode($national, 10 - $len, $defaultAreaCode);
if ($area === null) {
return ['uncertain', $digits]; // код города не определить
}
// Обрезок полного номера: уже содержит код страны 7/8 + код города (напр. 7391271 = 7·391·271).
// Настоящий локальный (2713333, или московский 7712233 при коде 495) этим не задевается.
if (str_starts_with($digits, '7'.$area) || str_starts_with($digits, '8'.$area)) {
return ['fragment', null];
}
return ['built', '7'.$area.$digits];
}
/**
* Код города нужной длины: самый частый префикс среди полных номеров страницы,
* иначе запасной из запроса. null если не определить.
*
* @param list<string> $national
*/
private function areaCode(array $national, int $prefixLen, ?string $defaultAreaCode): ?string
{
if ($national !== []) {
$counts = [];
foreach ($national as $nat) {
$p = substr($nat, 0, $prefixLen);
$counts[$p] = ($counts[$p] ?? 0) + 1;
}
arsort($counts);
$prefix = (string) array_key_first($counts);
if (strlen($prefix) === $prefixLen) {
return $prefix;
}
}
if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) {
return $defaultAreaCode;
}
return null;
}
}
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* xf4-путь шва {@see TwoGisReader}: тонкий делегат к нынешним HTML-парсерам. Поведение 1:1 с прямым
* вызовом {@see CategoryListingParser}/{@see DirectoryParser} старые парсеры не трогаются, xf4-путь
* остаётся целым. Развилка HTML↔JSON на уровне провайдера (какой ридер инжектится), не внутри парсеров.
*/
final class HtmlTwoGisReader implements TwoGisReader
{
public function __construct(
private readonly CategoryListingParser $listing = new CategoryListingParser,
private readonly DirectoryParser $directory = new DirectoryParser,
) {}
public function listing(string $payload): array
{
return $this->listing->parse($payload);
}
/** @return list<DirectoryCard> */
public function card(string $payload, string $url): array
{
return $this->directory->parse2gisCard($payload, $url);
}
/** @return list<string> */
public function branchList(string $payload): array
{
return $this->directory->parseBranchList($payload);
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class PhoneType
{
/** @param string $p номер в виде 7XXXXXXXXXX */
public static function of(string $p): string
{
$code = substr($p, 1, 3);
if ($code === '800') {
return 'tollfree';
}
if (isset($code[0]) && $code[0] === '9') {
return 'mobile';
}
return 'city';
}
}
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
final class SourceAggregator
{
private const TRUSTED = ['code', 'contacts', 'directory', 'email'];
/**
* @param array<int,array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<CollectedSource>
*/
public function aggregate(array $candidates): array
{
/** @var array<string,array{sources:array<string,array{label:string,url:?string}>,kinds:list<string>,office:?string,tracker:bool}> $by */
$by = [];
foreach ($candidates as $c) {
$n = $c['number'];
$by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false];
$by[$n]['kinds'][] = $c['kind'];
$by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker'];
$by[$n]['office'] ??= $c['office'];
if ($c['kind'] !== 'pool') {
$key = $c['label'];
if (! isset($by[$n]['sources'][$key])) {
$by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']];
}
}
}
$out = [];
foreach ($by as $n => $info) {
$n = (string) $n; // PHP приводит числовые ключи массива к int — возвращаем строку
$kinds = $info['kinds'];
$hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds);
$hasDisplayed = in_array('displayed', $kinds, true);
$hasText = in_array('text', $kinds, true);
// короткий локальный номер без определимого кода города — «требует проверки»
$isUncertain = ! $hasTrusted && ! $hasDisplayed && ! $hasText && in_array('uncertain', $kinds, true);
// номер только из текста сайта (не подтверждён tel:/справочником) — показываем «проверьте»
$isTextOnly = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && $hasText;
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && ! $isTextOnly; // только pool-свалка
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
if ($isUncertain) {
$phoneKind = 'uncertain';
$phoneType = null; // тип не выдумываем — номер ещё не достроен
} elseif ($isTextOnly) {
$phoneKind = 'unverified'; // «на сайте — проверьте»
$phoneType = PhoneType::of($n); // номер полный — тип знаем
} else {
$phoneKind = $hasTrusted ? 'real' : 'substitute';
$phoneType = PhoneType::of($n);
}
$out[] = new CollectedSource(
signalType: 'call',
identifier: $n,
phoneKind: $phoneKind,
phoneType: $phoneType,
office: $info['office'],
sources: array_values($info['sources']),
);
}
usort($out, function (CollectedSource $a, CollectedSource $b): int {
$sa = $a->phoneKind === 'substitute' ? 1 : 0;
$sb = $b->phoneKind === 'substitute' ? 1 : 0;
if ($sa !== $sb) {
return $sa <=> $sb; // подменные — вниз
}
if ($a->confirmations() !== $b->confirmations()) {
return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше
}
return strcmp($a->identifier, $b->identifier);
});
return $out;
}
}
@@ -1,269 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* self-render путь шва {@see TwoGisReader}: разбирает встроенное состояние 2ГИС (`window.initialState`,
* приходит строкой-JSON от рендер-сервиса) в ТОТ ЖЕ контракт, что HTML-парсеры. Карта JSON-путей и
* рецепт рендера docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md.
*
* Структура: упорядоченный список результатов `data.search.pagination.{searchId}.{page}.data`
* (id фирм по рангу) профили `data.entity.profile.{id}.data` (имя/город/рубрика/сайт/телефоны).
* 🔴 Ходим ТОЛЬКО по pagination-списку, НЕ по ключам profile (там бывают посторонние сущности).
*/
final class TwoGisJsonReader implements TwoGisReader
{
/** Тип «фирма» в 2ГИС — branch (филиал организации). Здания/улицы/маршруты = не фирма, отсекаем. */
private const FIRM_TYPE = 'branch';
/**
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function listing(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue; // не фирма (здание/улица/маршрут) или нет профиля — пропускаем
}
$name = trim((string) ($d['name'] ?? ''));
if ($name === '') {
continue;
}
$out[] = [
'name' => $name,
'card_url' => $this->cardUrl($d, $id),
'site' => $this->siteOf($d),
];
}
return $out;
}
/**
* @return list<DirectoryCard>
*/
public function card(string $payload, string $url): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
// Целевая фирма — по id из URL /firm/{id}; если не нашли — единственный профиль страницы.
$id = null;
if (preg_match('#/firm/(\d+)#', $url, $m)) {
$id = $m[1];
}
$d = $id !== null ? $this->profileData($state, $id) : null;
if ($d === null) {
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$first = $profiles !== [] ? array_key_first($profiles) : null;
$d = $first !== null ? ($profiles[$first]['data'] ?? null) : null;
}
if (! is_array($d)) {
return [];
}
$site = $this->siteOf($d);
$office = isset($d['address_name']) ? (string) $d['address_name'] : null;
$description = $this->firstRubric($d);
// Телефоны из contact_groups → contacts type=phone → поле value (чистый +7…). Дедуп по цифрам.
$phones = [];
$seen = [];
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) !== 'phone') {
continue;
}
$value = (string) ($c['value'] ?? $c['text'] ?? '');
$digits = preg_replace('/\D/', '', $value) ?? '';
if ($digits === '' || isset($seen[$digits])) {
continue;
}
$seen[$digits] = true;
$phones[] = $value;
}
// Семантика 1:1 с parse2gisCard: нет ни телефона, ни сайта → []; нет телефонов, есть сайт →
// одна карточка с number=''; иначе карточка на каждый телефон (общий сайт/описание/офис).
if ($phones === [] && $site === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(number: '', office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(number: $raw, office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description);
}
return $out;
}
/**
* @return list<string>
*/
public function branchList(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue;
}
$path = '/'.($d['city_alias'] ?? '').'/firm/'.$id;
if (! in_array($path, $out, true)) {
$out[] = $path;
}
}
return $out;
}
// --- helpers ---
/** @return array<mixed>|null */
private function decode(string $payload): ?array
{
$payload = trim($payload);
if ($payload === '' || $payload[0] !== '{') {
return null;
}
$state = json_decode($payload, true);
return is_array($state) ? $state : null;
}
/**
* Упорядоченный список id результатов: первый searchId все страницы по числовому ключу data.
*
* @param array<mixed> $state
* @return list<string>
*/
private function resultIds(array $state): array
{
$pagination = $this->arr($state, ['data', 'search', 'pagination']);
if ($pagination === []) {
return [];
}
$searchId = array_key_first($pagination);
$pages = is_array($pagination[$searchId] ?? null) ? $pagination[$searchId] : [];
// Страницы по возрастанию номера (payload обычно = одна страница, но подстрахуемся).
$keys = array_keys($pages);
sort($keys, SORT_NATURAL);
$ids = [];
$seen = [];
foreach ($keys as $k) {
foreach (($pages[$k]['data'] ?? []) as $id) {
$id = (string) $id;
if ($id !== '' && ! isset($seen[$id])) {
$seen[$id] = true;
$ids[] = $id;
}
}
}
return $ids;
}
/**
* @param array<mixed> $state
* @return array<mixed>|null
*/
private function profileData(array $state, string $id): ?array
{
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$d = $profiles[$id]['data'] ?? null;
return is_array($d) ? $d : null;
}
/** @param array<mixed> $d */
private function cardUrl(array $d, string $id): string
{
return 'https://2gis.ru/'.($d['city_alias'] ?? '').'/firm/'.$id;
}
/**
* Сайт из contact_groups contact type=website поле url (НЕ value: там редирект link.2gis.ru).
*
* @param array<mixed> $d
*/
private function siteOf(array $d): ?string
{
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) === 'website') {
$url = (string) ($c['url'] ?? '');
if ($url !== '') {
return $url;
}
}
}
return null;
}
/** @param array<mixed> $d @return list<array<mixed>> */
private function contacts(array $d): array
{
$out = [];
foreach (($d['contact_groups'] ?? []) as $group) {
foreach (($group['contacts'] ?? []) as $c) {
if (is_array($c)) {
$out[] = $c;
}
}
}
return $out;
}
/** @param array<mixed> $d */
private function firstRubric(array $d): ?string
{
foreach (($d['rubrics'] ?? []) as $r) {
$name = trim((string) ($r['name'] ?? ''));
if ($name !== '') {
return $name;
}
}
return null;
}
/**
* @param array<mixed> $state
* @param list<string> $path
* @return array<mixed>
*/
private function arr(array $state, array $path): array
{
$node = $state;
foreach ($path as $key) {
if (! is_array($node) || ! array_key_exists($key, $node)) {
return [];
}
$node = $node[$key];
}
return is_array($node) ? $node : [];
}
}

Some files were not shown because too many files have changed in this diff Show More